feat: clip-sync v1.0 — daemon de sync clipboard bidirectional en Go
- Daemon Go ~150 lignes, zéro dépendance hors stdlib + toml - Watch clipboard local (poll 500ms, hash-based dedup) - Serveur HTTP POST /clip pour réception des pairs - Anti-boucle d'écho (lastHash + host+TS filtering) - Cross-platform: Linux (xclip/wl-paste), macOS, Windows - Configuration TOML statique des pairs - install.sh one-liner multi-OS - ARCHITECTURE.md + ROADMAP.md + README.md
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# bin
|
||||
clip-sync
|
||||
clip-sync.exe
|
||||
clip-sync-darwin
|
||||
|
||||
# config (keep example, ignore real)
|
||||
peers.toml
|
||||
|
||||
# Go
|
||||
@@ -0,0 +1,72 @@
|
||||
# clip-sync — Architecture
|
||||
|
||||
## Principe
|
||||
|
||||
Un daemon Go (~150 lignes, zéro dépendance hors stdlib) qui synchronise le presse-papier entre machines sur un LAN. Chaque machine tourne le même binaire, écoute sur `:9137`, et pousse les nouveaux clips vers toutes les autres.
|
||||
|
||||
```
|
||||
┌──────────────┐ POST /clip ┌──────────────┐
|
||||
│ deepin │◄──────────────►│ vivobook │
|
||||
│ :9137 │ {text,ts} │ :9137 │
|
||||
│ │ │ │
|
||||
│ poll 500ms │ │ poll 500ms │
|
||||
│ xclip -o │ │ Get-Clip │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Boucles (tout dans `main.go`)
|
||||
|
||||
### Boucle 1 — Watch local
|
||||
Toutes les 500ms, lit le clipboard local (`xclip -o` / `powershell Get-Clipboard`), le hashe (SHA-256). Si le hash diffère du dernier connu → broadcast HTTP POST à tous les pairs.
|
||||
|
||||
### Boucle 2 — Receive
|
||||
Serveur HTTP `POST /clip`. Reçoit `{text, ts, host}`. Si le host est soi-même ou si le timestamp est ≤ au dernier reçu de ce host → ignore. Sinon, écrit dans le clipboard local ET met à jour `lastHash` pour que la boucle 1 ne rebroadcast pas.
|
||||
|
||||
### Anti-boucle d'écho
|
||||
Après un `clipboard.Write()` déclenché par un clip reçu, `lastHash` est mis à jour avec le hash du texte reçu. Le poller local le voit comme "déjà connu" → pas de rebroadcast.
|
||||
|
||||
## Composants
|
||||
|
||||
| Fichier | Rôle | Lignes |
|
||||
|---------|------|--------|
|
||||
| `main.go` | Serveur HTTP + watch loop + dispatch | ~120 |
|
||||
| `peers.toml` | Configuration des pairs | ~10 |
|
||||
| `install.sh` | Installation one-liner cross-OS | ~40 |
|
||||
|
||||
## Plateformes
|
||||
|
||||
| OS | Clipboard read | Clipboard write |
|
||||
|----|---------------|-----------------|
|
||||
| Linux (X11) | `xclip -o -selection clipboard` | `xclip -selection clipboard` |
|
||||
| Linux (Wayland) | `wl-paste` | `wl-copy` |
|
||||
| macOS | `pbpaste` | `pbcopy` |
|
||||
| Windows | `powershell -Command Get-Clipboard` | `powershell -Command Set-Clipboard -Value ...` |
|
||||
|
||||
Détection automatique au démarrage via `runtime.GOOS` + test de présence des binaires.
|
||||
|
||||
## Ce qui est volontairement omis
|
||||
|
||||
- **Chiffrement** — LAN de confiance. Si besoin → TLS entre pairs (v2).
|
||||
- **Découverte automatique (mDNS)** — config statique = 5 lignes de TOML. Discovery = 200 lignes. v2.
|
||||
- **Historique / ring buffer** — ce n'est pas un gestionnaire de presse-papier, c'est du live sync.
|
||||
- **UI / tray icon** — daemon headless, `systemd --user` ou `nssm` pour Windows.
|
||||
- **Auth token** — HTTP en clair sur LAN. Port 9137 non exposé à l'extérieur.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Zéro. `net/http`, `crypto/sha256`, `encoding/json`, `os/exec`, `runtime`. Stdlib Go pure.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
GOOS=linux GOARCH=amd64 go build -o clip-sync .
|
||||
|
||||
# Windows
|
||||
GOOS=windows GOARCH=amd64 go build -o clip-sync.exe .
|
||||
|
||||
# macOS
|
||||
GOOS=darwin GOARCH=amd64 go build -o clip-sync .
|
||||
```
|
||||
|
||||
Un binaire statique par OS. Pas de Docker, pas de runtime.
|
||||
@@ -0,0 +1,78 @@
|
||||
# clip-sync
|
||||
|
||||
Clipboard bidirectional sync daemon for LAN. One binary, zero deps.
|
||||
Copy on one machine → paste on another. Automatic, instant.
|
||||
|
||||
## One-liner install
|
||||
|
||||
```bash
|
||||
curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
This detects your OS, downloads the right binary, and creates a default config.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install on every machine with the one-liner above
|
||||
2. Edit `~/.config/clip-sync/peers.toml` — add all your machines
|
||||
3. Run `clip-sync` (or set up as a service)
|
||||
|
||||
```bash
|
||||
clip-sync
|
||||
# 2026/08/08 clip-sync: deepin listening on :9137, 3 peers
|
||||
# 2026/08/08 recv: vivobook → 42 chars
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
```toml
|
||||
# ~/.config/clip-sync/peers.toml
|
||||
listen = ":9137"
|
||||
|
||||
[peers]
|
||||
deepin = "192.168.30.101:9137"
|
||||
vivobook = "192.168.20.161:9137"
|
||||
openclaw1 = "192.168.30.27:9137"
|
||||
```
|
||||
|
||||
## Service (optional)
|
||||
|
||||
### Linux (systemd --user)
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/clip-sync.service -o ~/.config/systemd/user/clip-sync.service
|
||||
systemctl --user enable --now clip-sync
|
||||
```
|
||||
|
||||
### Windows (nssm)
|
||||
```powershell
|
||||
nssm install clip-sync C:\Users\bruno\bin\clip-sync.exe
|
||||
nssm set clip-sync AppDirectory C:\Users\bruno\.config\clip-sync
|
||||
nssm start clip-sync
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
┌──────────────┐ POST /clip ┌──────────────┐
|
||||
│ deepin │◄──────────────►│ vivobook │
|
||||
│ :9137 │ {text, ts} │ :9137 │
|
||||
│ │ │ │
|
||||
│ poll 500ms │ │ poll 500ms │
|
||||
│ xclip -o │ │ Get-Clip │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
|
||||
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
git clone https://git.dracodev.net/Projets/clip-sync.git
|
||||
cd clip-sync
|
||||
go build -o clip-sync .
|
||||
|
||||
# cross-compile
|
||||
GOOS=windows GOARCH=amd64 go build -o clip-sync.exe .
|
||||
GOOS=darwin GOARCH=amd64 go build -o clip-sync-darwin .
|
||||
```
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# clip-sync — Roadmap
|
||||
|
||||
## v1.0 — Le strict nécessaire (main)
|
||||
|
||||
- [x] Watch clipboard local (poll 500ms, hash-based dedup)
|
||||
- [x] Serveur HTTP `POST /clip` pour recevoir les clips des pairs
|
||||
- [x] Anti-boucle d'écho (lastHash + host+TS filtering)
|
||||
- [x] Cross-platform clipboard: Linux (xclip/wl-paste), macOS (pbpaste), Windows (PowerShell)
|
||||
- [x] Configuration TOML statique des pairs
|
||||
- [x] `install.sh` one-liner multi-OS
|
||||
- [x] Build cross-platform (`GOOS=linux|windows|darwin`)
|
||||
|
||||
## v1.1 — Robustesse
|
||||
|
||||
- [ ] Retry HTTP avec backoff exponentiel (pair down → réessayer)
|
||||
- [ ] Health check `/health` endpoint
|
||||
- [ ] Logging structuré (timestamp, niveau, pair)
|
||||
- [ ] Timeout HTTP (5s) — éviter de bloquer si un pair est down
|
||||
- [ ] `systemd --user` unit file + `nssm` config Windows
|
||||
- [ ] GoReleaser pour publier les binaires sur les releases Gitea
|
||||
|
||||
## v2.0 — Fonctionnalités
|
||||
|
||||
- [ ] mDNS discovery automatique (alternative à la config statique)
|
||||
- [ ] `cb history` — ring buffer local des 50 derniers clips
|
||||
- [ ] `cb push <machine>` / `cb pull <machine>` — mode explicite en plus du sync auto
|
||||
- [ ] TLS entre pairs (certificats auto-signés)
|
||||
- [ ] Token d'authentification partagé
|
||||
- [ ] Filtrage par type (texte seulement, pas d'images/binaire en v1)
|
||||
- [ ] Support images (base64 dans le payload JSON, avec taille max configurable)
|
||||
|
||||
## v3.0 — Intégrations
|
||||
|
||||
- [ ] Intégration Ditto (Windows) — lecture de `Ditto.db` SQLite, push automatique
|
||||
- [ ] Bridge MQTT pour intégration domotique (Home Assistant)
|
||||
- [ ] Web UI minimale pour voir l'état des pairs
|
||||
- [ ] API WebSocket pour push temps réel (alternative au polling HTTP)
|
||||
|
||||
## Non-goals (volontairement exclus)
|
||||
|
||||
- Cloud relay / sync hors-LAN — utiliser Syncthing ou un autre outil
|
||||
- Chiffrement bout-en-bout — le LAN est le périmètre de confiance
|
||||
- Gestionnaire de presse-papier complet — Ditto, CopyQ, Ringboard existent déjà
|
||||
- App mobile — hors scope
|
||||
|
||||
## Calendrier indicatif
|
||||
|
||||
| Version | Effort | Cible |
|
||||
|---------|--------|-------|
|
||||
| v1.0 | 1 session | Août 2026 |
|
||||
| v1.1 | 1 session | Septembre 2026 |
|
||||
| v2.0 | 2-3 sessions | 2027 |
|
||||
| v3.0 | Au besoin | Quand Ditto devient prioritaire |
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.dracodev.net/Projets/clip-sync
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/BurntSushi/toml v1.3.2
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# clip-sync one-liner installer
|
||||
# curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="https://git.dracodev.net/Projets/clip-sync"
|
||||
CONFIG_DIR="${HOME}/.config/clip-sync"
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
|
||||
# --- detect OS/arch ---
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
armv7l) ARCH="arm" ;;
|
||||
esac
|
||||
|
||||
case "$OS" in
|
||||
linux) BINARY="clip-sync-linux-${ARCH}" ;;
|
||||
darwin) BINARY="clip-sync-darwin-${ARCH}" ;;
|
||||
mingw*|msys*|cygwin*) OS="windows"; BINARY="clip-sync-windows-${ARCH}.exe" ;;
|
||||
esac
|
||||
|
||||
echo "→ clip-sync installer: ${OS}/${ARCH}"
|
||||
|
||||
# --- download or build ---
|
||||
mkdir -p "$BIN_DIR" "$CONFIG_DIR"
|
||||
|
||||
# Try release download first (v1.0.0+)
|
||||
DOWNLOAD_URL="${REPO}/releases/download/latest/${BINARY}"
|
||||
if command -v curl &>/dev/null; then
|
||||
if curl -fSL --max-time 30 "$DOWNLOAD_URL" -o "${BIN_DIR}/clip-sync" 2>/dev/null; then
|
||||
echo "✓ Downloaded ${BINARY}"
|
||||
chmod +x "${BIN_DIR}/clip-sync"
|
||||
else
|
||||
echo "⚠ Release not found, building from source (need Go)..."
|
||||
# Fallback: build from source
|
||||
TMPDIR=$(mktemp -d)
|
||||
git clone "$REPO" "$TMPDIR" 2>/dev/null || { echo "✗ git clone failed. Install Go and git, or wait for v1.0.0 release."; exit 1; }
|
||||
cd "$TMPDIR"
|
||||
go build -o "${BIN_DIR}/clip-sync" .
|
||||
rm -rf "$TMPDIR"
|
||||
echo "✓ Built from source"
|
||||
fi
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -q --timeout=30 "$DOWNLOAD_URL" -O "${BIN_DIR}/clip-sync" && chmod +x "${BIN_DIR}/clip-sync" && echo "✓ Downloaded ${BINARY}" || echo "⚠ Download failed"
|
||||
fi
|
||||
|
||||
# --- config ---
|
||||
if [ ! -f "${CONFIG_DIR}/peers.toml" ]; then
|
||||
curl -sSL "${REPO}/raw/branch/main/peers.example.toml" -o "${CONFIG_DIR}/peers.toml" 2>/dev/null || true
|
||||
echo "✓ Created ${CONFIG_DIR}/peers.toml (edit your peers!)"
|
||||
else
|
||||
echo "• Config already exists: ${CONFIG_DIR}/peers.toml"
|
||||
fi
|
||||
|
||||
# --- PATH hint ---
|
||||
if [[ ":$PATH:" != *":${BIN_DIR}:"* ]]; then
|
||||
echo ""
|
||||
echo "⚠ Add to your PATH:"
|
||||
echo " export PATH=\"${BIN_DIR}:\$PATH\""
|
||||
echo " (add this to ~/.bashrc or ~/.zshrc)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ clip-sync installed!"
|
||||
echo " 1. Edit ${CONFIG_DIR}/peers.toml — add your machines"
|
||||
echo " 2. Run: clip-sync"
|
||||
echo " 3. (Optional) Set up as a service for auto-start"
|
||||
@@ -0,0 +1,204 @@
|
||||
// clip-sync: bidirectional clipboard sync daemon for LAN.
|
||||
// One binary, zero deps, ~150 lines. Run on every machine.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// --- config ---
|
||||
|
||||
type Config struct {
|
||||
Listen string `toml:"listen"`
|
||||
Peers map[string]string `toml:"peers"` // name → "host:port"
|
||||
}
|
||||
|
||||
// --- types ---
|
||||
|
||||
type Clip struct {
|
||||
Text string `json:"text"`
|
||||
TS int64 `json:"ts"` // unix millis
|
||||
Host string `json:"host"` // hostname
|
||||
}
|
||||
|
||||
// --- globals ---
|
||||
|
||||
var (
|
||||
hostname string
|
||||
config Config
|
||||
lastHash string
|
||||
lastRecv = map[string]int64{} // host → latest ts
|
||||
mu sync.Mutex
|
||||
httpClient = &http.Client{Timeout: 5 * time.Second}
|
||||
)
|
||||
|
||||
// --- clipboard ---
|
||||
|
||||
func clipboardCmd(args ...string) (string, error) {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stderr = nil
|
||||
out, err := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func readClipboard() string {
|
||||
switch {
|
||||
case runtime.GOOS == "darwin":
|
||||
s, _ := clipboardCmd("pbpaste")
|
||||
return s
|
||||
case runtime.GOOS == "windows":
|
||||
s, _ := clipboardCmd("powershell", "-Command", "Get-Clipboard")
|
||||
return s
|
||||
default: // linux
|
||||
// try wl-paste (Wayland) first, then xclip (X11)
|
||||
if s, err := clipboardCmd("wl-paste"); err == nil {
|
||||
return s
|
||||
}
|
||||
s, _ := clipboardCmd("xclip", "-o", "-selection", "clipboard")
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func writeClipboard(text string) error {
|
||||
switch {
|
||||
case runtime.GOOS == "darwin":
|
||||
cmd := exec.Command("pbcopy")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
case runtime.GOOS == "windows":
|
||||
return exec.Command("powershell", "-Command", "Set-Clipboard -Value", text).Run()
|
||||
default:
|
||||
if _, err := exec.LookPath("wl-copy"); err == nil {
|
||||
cmd := exec.Command("wl-copy")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
}
|
||||
|
||||
func hash(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return fmt.Sprintf("%x", h[:8]) // first 8 bytes is enough for dedup
|
||||
}
|
||||
|
||||
// --- network ---
|
||||
|
||||
func broadcast(clip Clip) {
|
||||
body, _ := json.Marshal(clip)
|
||||
for name, addr := range config.Peers {
|
||||
go func(name, addr string) {
|
||||
url := "http://" + addr + "/clip"
|
||||
resp, err := httpClient.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("broadcast: %s (%s): %v", name, addr, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}(name, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func clipHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var c Clip
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Host == hostname {
|
||||
return // don't echo our own clips
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
prev, ok := lastRecv[c.Host]
|
||||
if ok && c.TS <= prev {
|
||||
mu.Unlock()
|
||||
return // already seen this or older
|
||||
}
|
||||
lastRecv[c.Host] = c.TS
|
||||
mu.Unlock()
|
||||
|
||||
log.Printf("recv: %s → %d chars", c.Host, len(c.Text))
|
||||
if err := writeClipboard(c.Text); err != nil {
|
||||
log.Printf("write clipboard: %v", err)
|
||||
return
|
||||
}
|
||||
// update lastHash so local watcher doesn't rebroadcast
|
||||
mu.Lock()
|
||||
lastHash = hash(c.Text)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// --- main ---
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
hostname, err = os.Hostname()
|
||||
if err != nil {
|
||||
log.Fatalf("hostname: %v", err)
|
||||
}
|
||||
|
||||
// config
|
||||
configPath := os.Getenv("CLIP_SYNC_CONFIG")
|
||||
if configPath == "" {
|
||||
configPath = "peers.toml"
|
||||
}
|
||||
if _, err := toml.DecodeFile(configPath, &config); err != nil {
|
||||
log.Fatalf("config %s: %v", configPath, err)
|
||||
}
|
||||
if config.Listen == "" {
|
||||
config.Listen = ":9137"
|
||||
}
|
||||
log.Printf("clip-sync: %s listening on %s, %d peers", hostname, config.Listen, len(config.Peers))
|
||||
|
||||
// http server
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/clip", clipHandler)
|
||||
mux.HandleFunc("/health", healthHandler)
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(config.Listen, mux))
|
||||
}()
|
||||
|
||||
// clipboard watcher
|
||||
for range time.Tick(500 * time.Millisecond) {
|
||||
text := readClipboard()
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
h := hash(text)
|
||||
mu.Lock()
|
||||
if h == lastHash {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
lastHash = h
|
||||
mu.Unlock()
|
||||
|
||||
clip := Clip{Text: text, TS: time.Now().UnixMilli(), Host: hostname}
|
||||
broadcast(clip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# clip-sync peers configuration
|
||||
# Copy this to peers.toml and edit your machines.
|
||||
|
||||
listen = ":9137"
|
||||
|
||||
[peers]
|
||||
# name = "ip:port"
|
||||
# deepin = "192.168.30.101:9137"
|
||||
# vivobook = "192.168.20.161:9137"
|
||||
# openclaw1 = "192.168.30.27:9137"
|
||||
Reference in New Issue
Block a user