From 736c9024f3c53041d0b62a8d269d538a4bc29fcf Mon Sep 17 00:00:00 2001 From: bruno Date: Sat, 8 Aug 2026 12:02:58 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20clip-sync=20v1.0=20=E2=80=94=20daemon?= =?UTF-8?q?=20de=20sync=20clipboard=20bidirectional=20en=20Go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 9 ++ ARCHITECTURE.md | 72 ++++++++++++++++ README.md | 78 +++++++++++++++++ ROADMAP.md | 53 ++++++++++++ go.mod | 5 ++ go.sum | 2 + install.sh | 70 ++++++++++++++++ main.go | 204 +++++++++++++++++++++++++++++++++++++++++++++ peers.example.toml | 10 +++ 9 files changed, 503 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 install.sh create mode 100644 main.go create mode 100644 peers.example.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..297aea5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# bin +clip-sync +clip-sync.exe +clip-sync-darwin + +# config (keep example, ignore real) +peers.toml + +# Go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5854c84 --- /dev/null +++ b/ARCHITECTURE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0111d0 --- /dev/null +++ b/README.md @@ -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 . +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..84b271f --- /dev/null +++ b/ROADMAP.md @@ -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 ` / `cb pull ` — 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 | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2312549 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.dracodev.net/Projets/clip-sync + +go 1.21 + +require github.com/BurntSushi/toml v1.3.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ef0f966 --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b4bd180 --- /dev/null +++ b/install.sh @@ -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" diff --git a/main.go b/main.go new file mode 100644 index 0000000..73ec436 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/peers.example.toml b/peers.example.toml new file mode 100644 index 0000000..b55e3e0 --- /dev/null +++ b/peers.example.toml @@ -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"