Files
clip-sync/main.go
T
bruno 736c9024f3 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
2026-08-08 12:02:58 -04:00

205 lines
4.4 KiB
Go

// 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)
}
}