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