commit 736c9024f3c53041d0b62a8d269d538a4bc29fcf Author: bruno Date: Sat Aug 8 12:02:58 2026 -0400 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 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"