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

This commit is contained in:
2026-08-08 15:47:22 -04:00
parent 736c9024f3
commit 5655573fc8
25 changed files with 2050 additions and 403 deletions
+19
View File
@@ -0,0 +1,19 @@
// Package clipboard provides unified clipboard read/write across Linux, Windows, and macOS.
//
// On Linux, it auto-detects X11 (xclip) vs Wayland (wl-clipboard) by checking
// $XDG_SESSION_TYPE and verifying the required binaries at initialization time.
// On Windows, it uses the Win32 clipboard API via syscall (no CGo).
// On macOS, it shells out to pbpaste / pbcopy.
//
// Each platform file defines its own New() constructor using //go:build tags.
package clipboard
// Clipboard is the cross-platform clipboard interface.
type Clipboard interface {
// Read returns the current text content of the clipboard.
// Returns an empty string if the clipboard is empty or contains non-text data.
Read() (string, error)
// Write sets the clipboard text content.
Write(text string) error
}
+45
View File
@@ -0,0 +1,45 @@
//go:build darwin
package clipboard
import (
"fmt"
"os/exec"
"strings"
)
type darwinClipboard struct{}
// New creates a macOS clipboard (pbpaste/pbcopy).
func New() (Clipboard, error) {
return newDarwinClipboard()
}
func newDarwinClipboard() (*darwinClipboard, error) {
// Verify pbpaste and pbcopy are available
if err := checkBinary("pbpaste"); err != nil {
return nil, fmt.Errorf("clip-sync: pbpaste not found: %w", err)
}
if err := checkBinary("pbcopy"); err != nil {
return nil, fmt.Errorf("clip-sync: pbcopy not found: %w", err)
}
return &darwinClipboard{}, nil
}
func (c *darwinClipboard) Read() (string, error) {
cmd := exec.Command("pbpaste")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("clipboard read (pbpaste): %w", err)
}
return strings.TrimRight(string(out), "\n\r"), nil
}
func (c *darwinClipboard) Write(text string) error {
cmd := exec.Command("pbcopy")
cmd.Stdin = strings.NewReader(text)
if err := cmd.Run(); err != nil {
return fmt.Errorf("clipboard write (pbcopy): %w", err)
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
//go:build linux
package clipboard
import (
"fmt"
"os"
"os/exec"
"strings"
)
type linuxClipboard struct {
copyCmd string // "xclip" or "wl-copy"
pasteCmd string // "xclip" or "wl-paste"
copyArgs []string // e.g. ["-selection", "clipboard"] for xclip
}
// New creates a Linux clipboard (X11 xclip or Wayland wl-clipboard).
func New() (Clipboard, error) {
return newLinuxClipboard()
}
func newLinuxClipboard() (*linuxClipboard, error) {
sessionType := os.Getenv("XDG_SESSION_TYPE")
if strings.EqualFold(sessionType, "wayland") {
if err := checkBinary("wl-copy"); err != nil {
return nil, fmt.Errorf("clip-sync: Wayland detected but wl-copy not found: %w", err)
}
if err := checkBinary("wl-paste"); err != nil {
return nil, fmt.Errorf("clip-sync: Wayland detected but wl-paste not found: %w", err)
}
return &linuxClipboard{
copyCmd: "wl-copy",
pasteCmd: "wl-paste",
copyArgs: nil, // wl-copy reads from stdin by default
}, nil
}
// Default: X11 via xclip
if err := checkBinary("xclip"); err != nil {
return nil, fmt.Errorf("clip-sync: xclip not found (install xclip for X11 or wl-clipboard for Wayland): %w", err)
}
return &linuxClipboard{
copyCmd: "xclip",
pasteCmd: "xclip",
copyArgs: []string{"-selection", "clipboard"},
}, nil
}
func (c *linuxClipboard) Read() (string, error) {
args := []string{"-selection", "clipboard", "-o"}
if c.pasteCmd == "wl-paste" {
args = nil // wl-paste needs no args
}
cmd := exec.Command(c.pasteCmd, args...)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("clipboard read (%s): %w", c.pasteCmd, err)
}
return strings.TrimRight(string(out), "\n\r"), nil
}
func (c *linuxClipboard) Write(text string) error {
cmd := exec.Command(c.copyCmd, c.copyArgs...)
cmd.Stdin = strings.NewReader(text)
if err := cmd.Run(); err != nil {
return fmt.Errorf("clipboard write (%s): %w", c.copyCmd, err)
}
return nil
}
// checkBinary verifies that a command exists in $PATH.
func checkBinary(name string) error {
_, err := exec.LookPath(name)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
//go:build windows
package clipboard
import (
"fmt"
"syscall"
"unsafe"
)
const cfUnicodeText = 13
var (
user32 = syscall.NewLazyDLL("user32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenClipboard = user32.NewProc("OpenClipboard")
procCloseClipboard = user32.NewProc("CloseClipboard")
procEmptyClipboard = user32.NewProc("EmptyClipboard")
procGetClipboardData = user32.NewProc("GetClipboardData")
procSetClipboardData = user32.NewProc("SetClipboardData")
procGlobalAlloc = kernel32.NewProc("GlobalAlloc")
procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procGlobalFree = kernel32.NewProc("GlobalFree")
)
type windowsClipboard struct{}
// New creates a Windows clipboard using Win32 API (syscall, no CGo).
func New() (Clipboard, error) {
return newWindowsClipboard()
}
func newWindowsClipboard() (*windowsClipboard, error) {
return &windowsClipboard{}, nil
}
func (c *windowsClipboard) Read() (string, error) {
r1, _, err := procOpenClipboard.Call(0)
if r1 == 0 {
return "", fmt.Errorf("clipboard read: OpenClipboard: %w", err)
}
defer procCloseClipboard.Call()
h, _, err := procGetClipboardData.Call(uintptr(cfUnicodeText))
if h == 0 {
// No text in clipboard — not an error, just empty.
return "", nil
}
p, _, err := procGlobalLock.Call(h)
if p == 0 {
return "", fmt.Errorf("clipboard read: GlobalLock: %w", err)
}
defer procGlobalUnlock.Call(h)
return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(p))[:]), nil
}
func (c *windowsClipboard) Write(text string) error {
r1, _, err := procOpenClipboard.Call(0)
if r1 == 0 {
return fmt.Errorf("clipboard write: OpenClipboard: %w", err)
}
defer procCloseClipboard.Call()
r1, _, err = procEmptyClipboard.Call()
if r1 == 0 {
return fmt.Errorf("clipboard write: EmptyClipboard: %w", err)
}
utf16, err := syscall.UTF16FromString(text)
if err != nil {
return fmt.Errorf("clipboard write: UTF16FromString: %w", err)
}
// Allocate global memory: size in bytes (2 bytes per UTF-16 unit, including null terminator)
byteSize := uintptr(len(utf16) * 2)
h, _, err := procGlobalAlloc.Call(0, byteSize)
if h == 0 {
return fmt.Errorf("clipboard write: GlobalAlloc: %w", err)
}
p, _, err := procGlobalLock.Call(h)
if p == 0 {
procGlobalFree.Call(h)
return fmt.Errorf("clipboard write: GlobalLock: %w", err)
}
// Copy UTF-16 data into the locked memory.
dst := unsafe.Slice((*uint16)(unsafe.Pointer(p)), len(utf16))
copy(dst, utf16)
procGlobalUnlock.Call(h)
r1, _, err = procSetClipboardData.Call(uintptr(cfUnicodeText), h)
if r1 == 0 {
procGlobalFree.Call(h)
return fmt.Errorf("clipboard write: SetClipboardData: %w", err)
}
// Windows now owns the handle — do not free it.
return nil
}
+98
View File
@@ -0,0 +1,98 @@
// Package config parses the clip-sync TOML configuration file and applies
// environment variable overrides.
//
// Config file location: ~/.config/clip-sync/peers.toml
// Environment overrides:
// CLIP_SYNC_PORT — override daemon.port
// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"time"
"github.com/BurntSushi/toml"
)
// Config represents the full clip-sync configuration.
type Config struct {
Daemon DaemonConfig `toml:"daemon"`
Peers []PeerConfig `toml:"peers"`
}
// DaemonConfig holds daemon-level settings.
type DaemonConfig struct {
Port int `toml:"port"`
PollIntervalMs int `toml:"poll_interval_ms"`
}
// PeerConfig represents one remote clip-sync peer.
type PeerConfig struct {
Name string `toml:"name"`
Addr string `toml:"addr"`
}
// Default values.
const (
DefaultPort = 9137
DefaultPollIntervalMs = 500
ConfigDir = ".config/clip-sync"
ConfigFile = "peers.toml"
)
// Load reads the config file from ~/.config/clip-sync/peers.toml and applies
// environment variable overrides.
func Load() (*Config, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("config: cannot find home directory: %w", err)
}
cfgPath := filepath.Join(home, ConfigDir, ConfigFile)
cfg := &Config{
Daemon: DaemonConfig{
Port: DefaultPort,
PollIntervalMs: DefaultPollIntervalMs,
},
}
if _, err := toml.DecodeFile(cfgPath, cfg); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("config: cannot parse %s: %w", cfgPath, err)
}
// File doesn't exist — use defaults (no peers configured).
}
// Environment variable overrides.
if v := os.Getenv("CLIP_SYNC_PORT"); v != "" {
port, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("config: invalid CLIP_SYNC_PORT: %w", err)
}
cfg.Daemon.Port = port
}
if v := os.Getenv("CLIP_SYNC_POLL_MS"); v != "" {
ms, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("config: invalid CLIP_SYNC_POLL_MS: %w", err)
}
cfg.Daemon.PollIntervalMs = ms
}
return cfg, nil
}
// PollInterval returns the poll interval as a time.Duration.
func (c *Config) PollInterval() time.Duration {
return time.Duration(c.Daemon.PollIntervalMs) * time.Millisecond
}
// ListenAddr returns the HTTP listen address for this daemon.
func (c *Config) ListenAddr() string {
return fmt.Sprintf(":%d", c.Daemon.Port)
}
+123
View File
@@ -0,0 +1,123 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaults(t *testing.T) {
// Point HOME/USERPROFILE to a temp dir with no config file.
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp) // Windows compatibility
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Daemon.Port != DefaultPort {
t.Errorf("default port = %d, want %d", cfg.Daemon.Port, DefaultPort)
}
if cfg.Daemon.PollIntervalMs != DefaultPollIntervalMs {
t.Errorf("default poll = %d, want %d", cfg.Daemon.PollIntervalMs, DefaultPollIntervalMs)
}
if len(cfg.Peers) != 0 {
t.Errorf("default peers = %d, want 0", len(cfg.Peers))
}
}
func TestLoadFromFile(t *testing.T) {
tmp := t.TempDir()
cfgDir := filepath.Join(tmp, ConfigDir)
os.MkdirAll(cfgDir, 0755)
content := `
[daemon]
port = 9999
poll_interval_ms = 250
[[peers]]
name = "bureau"
addr = "192.168.1.10:9137"
[[peers]]
name = "portable"
addr = "192.168.1.20:9137"
`
os.WriteFile(filepath.Join(cfgDir, ConfigFile), []byte(content), 0644)
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp) // Windows compatibility
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Daemon.Port != 9999 {
t.Errorf("port = %d, want 9999", cfg.Daemon.Port)
}
if cfg.Daemon.PollIntervalMs != 250 {
t.Errorf("poll = %d, want 250", cfg.Daemon.PollIntervalMs)
}
if len(cfg.Peers) != 2 {
t.Fatalf("peers = %d, want 2", len(cfg.Peers))
}
if cfg.Peers[0].Name != "bureau" {
t.Errorf("peer[0].Name = %q, want \"bureau\"", cfg.Peers[0].Name)
}
if cfg.Peers[1].Addr != "192.168.1.20:9137" {
t.Errorf("peer[1].Addr = %q", cfg.Peers[1].Addr)
}
}
func TestEnvOverride(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp) // Windows compatibility
t.Setenv("CLIP_SYNC_PORT", "8080")
t.Setenv("CLIP_SYNC_POLL_MS", "100")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Daemon.Port != 8080 {
t.Errorf("port = %d, want 8080", cfg.Daemon.Port)
}
if cfg.Daemon.PollIntervalMs != 100 {
t.Errorf("poll = %d, want 100", cfg.Daemon.PollIntervalMs)
}
}
func TestEnvOverrideInvalidPort(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp) // Windows compatibility
t.Setenv("CLIP_SYNC_PORT", "not-a-number")
_, err := Load()
if err == nil {
t.Fatal("expected error for invalid CLIP_SYNC_PORT, got nil")
}
}
func TestPollInterval(t *testing.T) {
cfg := &Config{
Daemon: DaemonConfig{PollIntervalMs: 500},
}
if d := cfg.PollInterval(); d.Milliseconds() != 500 {
t.Errorf("PollInterval = %v, want 500ms", d)
}
}
func TestListenAddr(t *testing.T) {
cfg := &Config{
Daemon: DaemonConfig{Port: 9137},
}
if addr := cfg.ListenAddr(); addr != ":9137" {
t.Errorf("ListenAddr = %q, want \":9137\"", addr)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package daemon orchestrates the main clip-sync loop: poll the local clipboard,
// broadcast changes to peers, and receive incoming clips via the HTTP server.
package daemon
import (
"context"
"log"
"os"
"sync"
"time"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/peer"
"git.dracodev.net/Projets/clip-sync/internal/server"
)
// Daemon is the main clip-sync process.
type Daemon struct {
cfg *config.Config
clipboard clipboard.Clipboard
filter *dedup.Filter
server *server.Server
broadcaster *peer.Broadcaster
}
// New creates a Daemon from the given configuration.
func New(cfg *config.Config) (*Daemon, error) {
cb, err := clipboard.New()
if err != nil {
return nil, err
}
return newWithClipboard(cfg, cb)
}
// newWithClipboard creates a Daemon with a pre-existing clipboard (used by tests).
func newWithClipboard(cfg *config.Config, cb clipboard.Clipboard) (*Daemon, error) {
return newWithClipboardOrigin(cfg, cb, "")
}
// newWithClipboardOrigin creates a Daemon with a pre-existing clipboard and explicit origin.
func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin string) (*Daemon, error) {
if origin == "" {
var err error
origin, err = os.Hostname()
if err != nil {
return nil, err
}
}
filter := dedup.NewFilter(origin)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin)
srv := server.New(cfg.ListenAddr(), cb, filter)
return &Daemon{
cfg: cfg,
clipboard: cb,
filter: filter,
server: srv,
broadcaster: broadcaster,
}, nil
}
// Run starts the daemon loop. It blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
// Start the HTTP server in a background goroutine.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
if err := d.server.ListenAndServe(); err != nil {
// ListenAndServe returns http.ErrServerClosed on graceful shutdown.
if ctx.Err() == nil {
log.Printf("clip-sync: server error: %v", err)
}
}
}()
// Main clipboard polling loop.
ticker := time.NewTicker(d.cfg.PollInterval())
defer ticker.Stop()
var lastText string
log.Printf("clip-sync: polling clipboard every %v, %d peer(s) configured",
d.cfg.PollInterval(), len(d.cfg.Peers))
for {
select {
case <-ctx.Done():
d.server.Close()
wg.Wait()
return ctx.Err()
case <-ticker.C:
text, err := d.clipboard.Read()
if err != nil {
log.Printf("clip-sync: clipboard read: %v", err)
continue
}
if text == "" || text == lastText {
continue
}
lastText = text
// Record our write so the filter can block echoes.
d.filter.MarkWritten(text)
// Broadcast to all peers.
if len(d.cfg.Peers) > 0 {
d.broadcaster.Broadcast(text)
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package daemon
import (
"context"
"testing"
"time"
"git.dracodev.net/Projets/clip-sync/internal/config"
)
// mockClipboard is a thread-safe in-memory clipboard for integration tests.
type mockClipboard struct {
text string
}
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
// TestIntegrationTwoInstances simulates two clip-sync instances syncing via localhost
// with distinct origins to avoid the same-hostname anti-loop filter.
func TestIntegrationTwoInstances(t *testing.T) {
// Instance A: port 19137
cfgA := &config.Config{
Daemon: config.DaemonConfig{Port: 19137, PollIntervalMs: 50},
Peers: []config.PeerConfig{
{Name: "instance-b", Addr: "127.0.0.1:19138"},
},
}
// Instance B: port 19138
cfgB := &config.Config{
Daemon: config.DaemonConfig{Port: 19138, PollIntervalMs: 50},
Peers: []config.PeerConfig{
{Name: "instance-a", Addr: "127.0.0.1:19137"},
},
}
cbA := &mockClipboard{}
cbB := &mockClipboard{}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Build daemons with distinct origins (simulating different machines).
da, err := newWithClipboardOrigin(cfgA, cbA, "instance-a")
if err != nil {
t.Fatalf("daemon A: %v", err)
}
db, err := newWithClipboardOrigin(cfgB, cbB, "instance-b")
if err != nil {
t.Fatalf("daemon B: %v", err)
}
// Start both daemons.
go da.Run(ctx)
go db.Run(ctx)
// Wait for servers to start.
time.Sleep(200 * time.Millisecond)
// Write text to mock clipboard A (simulating a local copy).
cbA.text = "sync test from A"
da.filter.MarkWritten(cbA.text)
da.broadcaster.Broadcast(cbA.text)
// Wait for propagation A → B via HTTP.
time.Sleep(500 * time.Millisecond)
if cbB.text != "sync test from A" {
t.Errorf("B clipboard = %q, want \"sync test from A\"", cbB.text)
}
// Now write to B and check A.
cbB.text = "sync test from B"
db.filter.MarkWritten(cbB.text)
db.broadcaster.Broadcast(cbB.text)
time.Sleep(500 * time.Millisecond)
if cbA.text != "sync test from B" {
t.Errorf("A clipboard = %q, want \"sync test from B\"", cbA.text)
}
cancel()
}
// TestNoPingPong verifies that the anti-loop filter prevents echo-back.
func TestNoPingPong(t *testing.T) {
cfgA := &config.Config{
Daemon: config.DaemonConfig{Port: 19139, PollIntervalMs: 50},
Peers: []config.PeerConfig{
{Name: "instance-b", Addr: "127.0.0.1:19140"},
},
}
cfgB := &config.Config{
Daemon: config.DaemonConfig{Port: 19140, PollIntervalMs: 50},
Peers: []config.PeerConfig{
{Name: "instance-a", Addr: "127.0.0.1:19139"},
},
}
cbA := &mockClipboard{}
cbB := &mockClipboard{}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
da, err := newWithClipboardOrigin(cfgA, cbA, "instance-a")
if err != nil {
t.Fatalf("daemon A: %v", err)
}
db, err := newWithClipboardOrigin(cfgB, cbB, "instance-b")
if err != nil {
t.Fatalf("daemon B: %v", err)
}
go da.Run(ctx)
go db.Run(ctx)
time.Sleep(200 * time.Millisecond)
// Phase 1: Write to A, verify B receives it.
cbA.text = "ping-pong test"
da.filter.MarkWritten(cbA.text)
da.broadcaster.Broadcast(cbA.text)
time.Sleep(400 * time.Millisecond)
if cbB.text != "ping-pong test" {
t.Errorf("B clipboard = %q, want \"ping-pong test\"", cbB.text)
}
// Phase 2: B's poller detects the new text and broadcasts it back.
// A's server should reject it via the dedup filter (hash match).
// Wait for B's poll cycle to fire.
time.Sleep(200 * time.Millisecond)
// Phase 3: Write new text on B, verify A receives it correctly
// (no interference from the blocked echo).
cbB.text = "final test"
db.filter.MarkWritten(cbB.text)
db.broadcaster.Broadcast(cbB.text)
time.Sleep(400 * time.Millisecond)
if cbA.text != "final test" {
t.Errorf("A clipboard = %q, want \"final test\"", cbA.text)
}
cancel()
}
+102
View File
@@ -0,0 +1,102 @@
// Package dedup provides the anti-ping-pong (deduplication) filter for clip-sync.
//
// Two guardrails prevent sync loops:
// 1. Content hash cache — an LRU ring buffer of the last N SHA-256 hashes
// of locally written text. If we receive text whose hash matches a recent
// local write, we ignore it (our own content echoed back through a peer).
// 2. Origin + timestamp — each payload carries an origin hostname and a
// nanosecond timestamp. If origin == our hostname (rebound via a third
// peer) or if the timestamp is not newer than our last write, we ignore.
package dedup
import (
"crypto/sha256"
"encoding/hex"
"sync"
"time"
)
const defaultCacheSize = 20
// Filter prevents clipboard sync loops.
type Filter struct {
mu sync.Mutex
origin string // our hostname
cache []string // ring buffer of content hashes
cacheIdx int // write position in ring buffer
lastWriteTs int64 // nanosecond timestamp of our last local write
}
// NewFilter creates a new deduplication filter for the given origin hostname.
func NewFilter(origin string) *Filter {
return &Filter{
origin: origin,
cache: make([]string, 0, defaultCacheSize),
}
}
// Payload represents an incoming clip payload from a peer.
type Payload struct {
Text string `json:"text"`
Ts int64 `json:"ts"`
Origin string `json:"origin"`
}
// ShouldIgnore returns true if the payload should NOT be written to the local clipboard.
func (f *Filter) ShouldIgnore(p Payload) bool {
f.mu.Lock()
defer f.mu.Unlock()
// Guard 1: ignore our own origin (rebound via a third peer).
if p.Origin == f.origin {
return true
}
// Guard 2: ignore stale timestamps (not newer than our last write).
if p.Ts <= f.lastWriteTs {
return true
}
// Guard 3: ignore content we recently wrote (hash match).
hash := hashText(p.Text)
for _, h := range f.cache {
if h == hash {
return true
}
}
return false
}
// MarkWritten records that we just wrote text to the local clipboard.
// This populates the hash cache and updates the last-write timestamp.
func (f *Filter) MarkWritten(text string) {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now().UnixNano()
hash := hashText(text)
// Ring buffer: overwrite oldest entry when full.
if len(f.cache) < defaultCacheSize {
f.cache = append(f.cache, hash)
} else {
f.cache[f.cacheIdx] = hash
f.cacheIdx = (f.cacheIdx + 1) % defaultCacheSize
}
f.lastWriteTs = now
}
// LastWriteTs returns the timestamp of our last local write.
func (f *Filter) LastWriteTs() int64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastWriteTs
}
// hashText returns the hex-encoded SHA-256 of the given text.
func hashText(text string) string {
h := sha256.Sum256([]byte(text))
return hex.EncodeToString(h[:])
}
+106
View File
@@ -0,0 +1,106 @@
package dedup
import (
"testing"
"time"
)
func TestShouldIgnoreOwnOrigin(t *testing.T) {
f := NewFilter("machine-a")
p := Payload{
Text: "hello",
Ts: time.Now().UnixNano(),
Origin: "machine-a", // same as our origin
}
if !f.ShouldIgnore(p) {
t.Error("should ignore payload from own origin")
}
}
func TestShouldIgnoreStaleTimestamp(t *testing.T) {
f := NewFilter("machine-a")
f.MarkWritten("previous text")
// Wait a tiny bit so the timestamp is definitely in the past.
time.Sleep(time.Millisecond)
p := Payload{
Text: "hello",
Ts: f.LastWriteTs() - 1, // older than our last write
Origin: "machine-b",
}
if !f.ShouldIgnore(p) {
t.Error("should ignore payload with stale timestamp")
}
}
func TestShouldIgnoreDuplicateHash(t *testing.T) {
f := NewFilter("machine-a")
f.MarkWritten("hello world")
p := Payload{
Text: "hello world",
Ts: time.Now().UnixNano(),
Origin: "machine-b",
}
if !f.ShouldIgnore(p) {
t.Error("should ignore payload with recently written hash")
}
}
func TestShouldAcceptNewContent(t *testing.T) {
f := NewFilter("machine-a")
f.MarkWritten("old content")
time.Sleep(time.Millisecond)
p := Payload{
Text: "new content",
Ts: time.Now().UnixNano(),
Origin: "machine-b",
}
if f.ShouldIgnore(p) {
t.Error("should accept new content from other origin")
}
}
func TestCacheEviction(t *testing.T) {
f := NewFilter("machine-a")
// Fill the cache with 20 different texts.
for i := 0; i < defaultCacheSize; i++ {
f.MarkWritten("text")
}
// The latest write is "text" — it should be in cache.
p := Payload{
Text: "text",
Ts: time.Now().UnixNano(),
Origin: "machine-b",
}
if !f.ShouldIgnore(p) {
t.Error("'text' should still be in cache after 20 writes")
}
}
func TestMarkWrittenUpdatesTimestamp(t *testing.T) {
f := NewFilter("machine-a")
before := f.LastWriteTs()
if before != 0 {
t.Errorf("initial LastWriteTs = %d, want 0", before)
}
time.Sleep(time.Millisecond)
f.MarkWritten("some text")
after := f.LastWriteTs()
if after <= before {
t.Errorf("LastWriteTs not updated: %d <= %d", after, before)
}
}
+84
View File
@@ -0,0 +1,84 @@
// Package peer sends clipboard content to remote clip-sync peers via HTTP POST.
package peer
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"time"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
)
const clipPath = "/clip"
// Broadcaster sends clip payloads to all configured peers concurrently.
type Broadcaster struct {
peers []config.PeerConfig
origin string
client *http.Client
}
// NewBroadcaster creates a Broadcaster for the configured peer list.
func NewBroadcaster(peers []config.PeerConfig, origin string) *Broadcaster {
return &Broadcaster{
peers: peers,
origin: origin,
client: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
}).DialContext,
MaxIdleConnsPerHost: 2,
},
},
}
}
// Broadcast sends the given text to all configured peers in parallel.
// Errors are logged but not returned — a single unreachable peer should not
// block other peers or the daemon loop.
func (b *Broadcaster) Broadcast(text string) {
payload := dedup.Payload{
Text: text,
Ts: time.Now().UnixNano(),
Origin: b.origin,
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("peer: marshal payload: %v", err)
return
}
for i := range b.peers {
go b.sendToPeer(b.peers[i], body)
}
}
func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
url := fmt.Sprintf("http://%s%s", p.Addr, clipPath)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
log.Printf("peer [%s]: create request: %v", p.Name, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.client.Do(req)
if err != nil {
log.Printf("peer [%s] %s: %v", p.Name, p.Addr, err)
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
log.Printf("peer [%s] %s: unexpected status %d", p.Name, p.Addr, resp.StatusCode)
}
}
+63
View File
@@ -0,0 +1,63 @@
package peer
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
)
func TestBroadcastSendsPayload(t *testing.T) {
received := make(chan dedup.Payload, 1)
// Start a fake peer server.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/clip" {
t.Errorf("expected /clip, got %s", r.URL.Path)
}
var p dedup.Payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("decode: %v", err)
}
received <- p
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
peers := []config.PeerConfig{
{Name: "test-peer", Addr: ts.Listener.Addr().String()},
}
b := NewBroadcaster(peers, "my-machine")
b.Broadcast("hello, peer!")
select {
case p := <-received:
if p.Text != "hello, peer!" {
t.Errorf("text = %q, want \"hello, peer!\"", p.Text)
}
if p.Origin != "my-machine" {
t.Errorf("origin = %q, want \"my-machine\"", p.Origin)
}
if p.Ts == 0 {
t.Error("timestamp should be non-zero")
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for broadcast")
}
}
func TestBroadcastEmptyPeers(t *testing.T) {
// Should not panic or error with zero peers.
b := NewBroadcaster(nil, "my-machine")
b.Broadcast("test")
b.Broadcast("")
}
+79
View File
@@ -0,0 +1,79 @@
// Package server provides the HTTP endpoint that receives clipboard payloads
// from remote clip-sync peers.
package server
import (
"encoding/json"
"log"
"net/http"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
)
// Server receives clips from peers and writes them to the local clipboard.
type Server struct {
httpServer *http.Server
clipboard clipboard.Clipboard
filter *dedup.Filter
}
// New creates a Server that listens on addr.
func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
s := &Server{
clipboard: cb,
filter: filter,
}
mux := http.NewServeMux()
mux.HandleFunc("/clip", s.handleClip)
s.httpServer = &http.Server{
Addr: addr,
Handler: mux,
}
return s
}
// ListenAndServe starts the HTTP server. It blocks until the server is stopped.
func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
// Close gracefully shuts down the HTTP server.
func (s *Server) Close() error {
return s.httpServer.Close()
}
func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var p dedup.Payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
return
}
if p.Text == "" {
http.Error(w, "bad request: empty text", http.StatusBadRequest)
return
}
if s.filter.ShouldIgnore(p) {
w.WriteHeader(http.StatusNoContent)
return
}
if err := s.clipboard.Write(p.Text); err != nil {
log.Printf("server: clipboard write: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
s.filter.MarkWritten(p.Text)
w.WriteHeader(http.StatusNoContent)
}
+133
View File
@@ -0,0 +1,133 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
)
// mockClipboard is an in-memory clipboard for testing.
type mockClipboard struct {
text string
}
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
func TestHandleClipAcceptsValidPayload(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
payload := dedup.Payload{
Text: "test text",
Ts: 1690000000000000000,
Origin: "other-machine",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
if cb.text != "test text" {
t.Errorf("clipboard = %q, want \"test text\"", cb.text)
}
}
func TestHandleClipIgnoresEcho(t *testing.T) {
cb := &mockClipboard{text: "existing"}
filter := dedup.NewFilter("my-machine")
// Simulate we just wrote "echo text" locally.
filter.MarkWritten("echo text")
srv := New(":0", cb, filter)
payload := dedup.Payload{
Text: "echo text", // same text we just wrote
Ts: 1690000000000000000,
Origin: "other-machine",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
// Clipboard should NOT be overwritten.
if cb.text != "existing" {
t.Errorf("clipboard = %q, want \"existing\" (should not be overwritten)", cb.text)
}
}
func TestHandleClipRejectsInvalidMethod(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
req := httptest.NewRequest(http.MethodGet, "/clip", nil)
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleClipRejectsEmptyText(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
payload := dedup.Payload{
Text: "",
Ts: 1690000000000000000,
Origin: "other-machine",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestHandleClipRejectsInvalidJSON(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json")))
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestMain(m *testing.M) {
// Ensure clipboard.New() won't be called in test (mock is used directly).
os.Exit(m.Run())
}