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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user