feat: implémentation complete v0.1.0 — daemon de sync clipboard bidirectionnel
CI / Lint (push) Failing after 14m46s
CI / Test (push) Failing after 4m23s
CI / Build (darwin/amd64) (push) Skipped
CI / Build (linux/amd64) (push) Skipped
CI / Build (windows/amd64) (push) Skipped
CI / Build (darwin/arm64) (push) Skipped
CI / Build (linux/arm64) (push) Skipped
CI / Build (windows/arm64) (push) Skipped
CI / Release (push) Skipped
CI / Lint (push) Failing after 14m46s
CI / Test (push) Failing after 4m23s
CI / Build (darwin/amd64) (push) Skipped
CI / Build (linux/amd64) (push) Skipped
CI / Build (windows/amd64) (push) Skipped
CI / Build (darwin/arm64) (push) Skipped
CI / Build (linux/arm64) (push) Skipped
CI / Build (windows/arm64) (push) Skipped
CI / Release (push) Skipped
This commit is contained in:
@@ -1,204 +1,72 @@
|
||||
// clip-sync: bidirectional clipboard sync daemon for LAN.
|
||||
// One binary, zero deps, ~150 lines. Run on every machine.
|
||||
// clip-sync — bidirectional LAN clipboard synchronization daemon.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// clip-sync [--config path]
|
||||
//
|
||||
// clip-sync polls the local clipboard every 500 ms and broadcasts any new
|
||||
// text content to all configured peers via HTTP POST /clip. It also runs an
|
||||
// HTTP server on :9137 (configurable) to receive clips from peers and write
|
||||
// them to the local clipboard.
|
||||
//
|
||||
// Configuration is read from ~/.config/clip-sync/peers.toml (TOML format).
|
||||
// Environment variables CLIP_SYNC_PORT and CLIP_SYNC_POLL_MS override
|
||||
// the corresponding config values.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/config"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/daemon"
|
||||
)
|
||||
|
||||
// --- 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 ---
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
hostname, err = os.Hostname()
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("clip-sync %s\n", version)
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.SetPrefix("")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("hostname: %v", err)
|
||||
log.Fatalf("clip-sync: config: %v", err)
|
||||
}
|
||||
|
||||
// config
|
||||
configPath := os.Getenv("CLIP_SYNC_CONFIG")
|
||||
if configPath == "" {
|
||||
configPath = "peers.toml"
|
||||
d, err := daemon.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("clip-sync: init: %v", err)
|
||||
}
|
||||
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)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Handle graceful shutdown on SIGINT / SIGTERM.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(config.Listen, mux))
|
||||
sig := <-sigCh
|
||||
log.Printf("clip-sync: received %v, shutting down...", sig)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// 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)
|
||||
if err := d.Run(ctx); err != nil && err != context.Canceled {
|
||||
log.Fatalf("clip-sync: %v", err)
|
||||
}
|
||||
|
||||
log.Println("clip-sync: stopped")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user