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:
2026-08-08 12:02:58 -04:00
commit 736c9024f3
9 changed files with 503 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# bin
clip-sync
clip-sync.exe
clip-sync-darwin
# config (keep example, ignore real)
peers.toml
# Go
+72
View File
@@ -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.
+78
View File
@@ -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
View File
@@ -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 |
+5
View File
@@ -0,0 +1,5 @@
module git.dracodev.net/Projets/clip-sync
go 1.21
require github.com/BurntSushi/toml v1.3.2
+2
View File
@@ -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
View File
@@ -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"
+204
View File
@@ -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)
}
}
+10
View File
@@ -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"