diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5f20193 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,117 @@ +# Gitea Actions CI/CD — clip-sync +# Lint → Test → Build multi-plateforme → Release +# +# Triggered on: push to main, tags (v*), pull requests + +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +jobs: + # ── Lint ──────────────────────────────────────────────────────────── + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=3m + + # ── Test ───────────────────────────────────────────────────────────── + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Install xclip (for clipboard tests) + run: sudo apt-get update -qq && sudo apt-get install -y -qq xclip + + - name: Run tests + run: go test ./... -v -count=1 -race -coverprofile=coverage.out -timeout 120s + + - name: Coverage report + run: go tool cover -func=coverage.out + + # ── Build (cross-compile) ──────────────────────────────────────────── + + build: + name: Build (${{ matrix.os }}/${{ matrix.arch }}) + runs-on: ubuntu-latest + needs: [lint, test] + if: github.event_name == 'push' || github.event_name == 'pull_request' + strategy: + matrix: + os: [linux, windows, darwin] + arch: [amd64, arm64] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Build + run: | + VERSION="${GITHUB_REF_NAME:-dev}" + EXT="" + [ "${{ matrix.os }}" = "windows" ] && EXT=".exe" + GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} CGO_ENABLED=0 \ + go build -ldflags "-s -w -X main.version=${VERSION}" \ + -o "clip-sync-${{ matrix.os }}-${{ matrix.arch }}${EXT}" . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: clip-sync-${{ matrix.os }}-${{ matrix.arch }} + path: clip-sync-${{ matrix.os }}-${{ matrix.arch }}* + + # ── Release ────────────────────────────────────────────────────────── + + release: + name: Release + runs-on: ubuntu-latest + needs: [build] + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Create release + uses: gitea/action-gitea-release@v1 + with: + tag_name: ${{ github.ref_name }} + name: clip-sync ${{ github.ref_name }} + body: | + ## clip-sync ${{ github.ref_name }} + + See [CHANGELOG.md](https://git.dracodev.net/Projets/clip-sync/src/branch/main/CHANGELOG.md) for details. + + ### Install + ```bash + curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash + ``` + files: | + artifacts/**/clip-sync-* diff --git a/.gitignore b/.gitignore index 297aea5..cd9f689 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ -# bin +# Binaries +/bin/ +*.exe +*.exe~ clip-sync -clip-sync.exe -clip-sync-darwin -# config (keep example, ignore real) -peers.toml +# Test coverage +coverage.out -# Go +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5854c84..c12e42e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,72 +1,130 @@ -# clip-sync — Architecture +# ARCHITECTURE.md — clip-sync -## Principe +## Vue d'ensemble -Un daemon Go (~150 lignes, zéro dépendance hors stdlib) qui synchronise le presse-papier entre machines sur un LAN. Chaque machine tourne le même binaire, écoute sur `:9137`, et pousse les nouveaux clips vers toutes les autres. +clip-sync est un daemon de synchronisation bidirectionnelle de presse-papiers +en mesh pair-à-pair sur réseau local. Chaque nœud est à la fois client et serveur. + +## Flux de données ``` -┌──────────────┐ POST /clip ┌──────────────┐ -│ deepin │◄──────────────►│ vivobook │ -│ :9137 │ {text,ts} │ :9137 │ -│ │ │ │ -│ poll 500ms │ │ poll 500ms │ -│ xclip -o │ │ Get-Clip │ -└──────────────┘ └──────────────┘ + ┌────────────────────────────────────┐ + │ clip-sync (nœud) │ + │ │ + Presse-papiers ──►│ Poller (500ms) │ + (Ctrl+C) │ │ lecture (OS clipboard) │ + │ ▼ │ + │ Comparaison (vs dernier texte) │ + │ │ si nouveau texte │ + │ ▼ │ + │ Filtre anti-boucle │ + │ │ MarkWritten(hash, ts) │ + │ ▼ │ + │ Broadcaster │ + │ │ POST /clip → chaque pair │ + │ │ goroutine par pair │ + └────┼───────────────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Pair distant :9137 │ + │ POST /clip {json} │ + │ ↓ │ + │ Filtre anti-boucle │ + │ ↓ (si accepté) │ + │ Write clipboard │ + │ ↓ (Ctrl+V possible)│ + └───────────────────────┘ ``` -## Boucles (tout dans `main.go`) - -### Boucle 1 — Watch local -Toutes les 500ms, lit le clipboard local (`xclip -o` / `powershell Get-Clipboard`), le hashe (SHA-256). Si le hash diffère du dernier connu → broadcast HTTP POST à tous les pairs. - -### Boucle 2 — Receive -Serveur HTTP `POST /clip`. Reçoit `{text, ts, host}`. Si le host est soi-même ou si le timestamp est ≤ au dernier reçu de ce host → ignore. Sinon, écrit dans le clipboard local ET met à jour `lastHash` pour que la boucle 1 ne rebroadcast pas. - -### Anti-boucle d'écho -Après un `clipboard.Write()` déclenché par un clip reçu, `lastHash` est mis à jour avec le hash du texte reçu. Le poller local le voit comme "déjà connu" → pas de rebroadcast. - ## Composants -| Fichier | Rôle | Lignes | -|---------|------|--------| -| `main.go` | Serveur HTTP + watch loop + dispatch | ~120 | -| `peers.toml` | Configuration des pairs | ~10 | -| `install.sh` | Installation one-liner cross-OS | ~40 | +### 1. Clipboard (`internal/clipboard/`) -## Plateformes +Interface unifiée `Read() / Write()` avec implémentations par plateforme : -| OS | Clipboard read | Clipboard write | -|----|---------------|-----------------| -| Linux (X11) | `xclip -o -selection clipboard` | `xclip -selection clipboard` | -| Linux (Wayland) | `wl-paste` | `wl-copy` | -| macOS | `pbpaste` | `pbcopy` | -| Windows | `powershell -Command Get-Clipboard` | `powershell -Command Set-Clipboard -Value ...` | +- **Linux** : auto-détection X11/Wayland via `$XDG_SESSION_TYPE` + - X11 → `xclip -selection clipboard -o` / `xclip -selection clipboard` + - Wayland → `wl-paste` / `wl-copy` +- **Windows** : Win32 API via `syscall` (user32.dll, kernel32.dll), zéro CGo +- **macOS** : `pbpaste` / `pbcopy` -Détection automatique au démarrage via `runtime.GOOS` + test de présence des binaires. +### 2. Configuration (`internal/config/`) -## Ce qui est volontairement omis +- Fichier TOML : `~/.config/clip-sync/peers.toml` +- Parsing : `github.com/BurntSushi/toml` (seule dépendance externe — TOML absent de stdlib) +- Override par variables d'environnement : `CLIP_SYNC_PORT`, `CLIP_SYNC_POLL_MS` -- **Chiffrement** — LAN de confiance. Si besoin → TLS entre pairs (v2). -- **Découverte automatique (mDNS)** — config statique = 5 lignes de TOML. Discovery = 200 lignes. v2. -- **Historique / ring buffer** — ce n'est pas un gestionnaire de presse-papier, c'est du live sync. -- **UI / tray icon** — daemon headless, `systemd --user` ou `nssm` pour Windows. -- **Auth token** — HTTP en clair sur LAN. Port 9137 non exposé à l'extérieur. +### 3. Anti-boucle (`internal/dedup/`) + +Deux garde-fous empêchent les boucles de synchronisation : + +| Garde-fou | Mécanisme | Scénario bloqué | +|-----------|-----------|-----------------| +| **Hash** | Cache LRU (20 derniers) des SHA-256 locaux | Notre texte nous revient via un pair | +| **Origine** | Comparaison `origin == hostname` | Rebond via 3e pair | +| **Timestamp** | `ts <= lastWriteTs` | Contournement multi-pair | + +Payload JSON : +```json +{ + "text": "contenu du presse-papiers", + "ts": 1690000000000000000, + "origin": "nom-de-machine" +} +``` + +### 4. Communication pair-à-pair (`internal/peer/`, `internal/server/`) + +- Protocole : HTTP POST `/clip` (JSON), réponse `204 No Content` +- Timeout connexion : 3s (dial), 5s (request) +- Chaque broadcast aux pairs est lancé dans sa propre goroutine (non-bloquant) +- Échec d'un pair = log + continue (pas de blocage des autres) + +### 5. Daemon (`internal/daemon/`) + +Boucle principale : +1. Démarre le serveur HTTP dans une goroutine +2. Ticker 500ms : lit le presse-papiers, compare, broadcast si changé +3. Signal SIGINT/SIGTERM → arrêt gracieux ## Dépendances -Zéro. `net/http`, `crypto/sha256`, `encoding/json`, `os/exec`, `runtime`. Stdlib Go pure. +| Dépendance | Version | Justification | +|-----------|---------|---------------| +| `github.com/BurntSushi/toml` | v1.3.2 | Parsing TOML (stdlib Go n'inclut pas TOML) | -## Build +**Total : 1 dépendance externe.** Tout le reste est stdlib Go. -```bash -# Linux -GOOS=linux GOARCH=amd64 go build -o clip-sync . +## Décisions architecturales -# Windows -GOOS=windows GOARCH=amd64 go build -o clip-sync.exe . +### Pourquoi HTTP et pas TCP raw / gRPC ? -# macOS -GOOS=darwin GOARCH=amd64 go build -o clip-sync . -``` +- Débogable avec `curl` +- `net/http` est dans la stdlib, zéro dépendance +- Le surcoût HTTP est négligeable pour du texte de presse-papiers +- Facile à étendre (TLS, auth headers) dans les versions futures -Un binaire statique par OS. Pas de Docker, pas de runtime. +### Pourquoi TOML et pas YAML/JSON ? + +- TOML est le format standard de configuration en Go +- Plus lisible que JSON pour des humains +- Pas de problème de whitespace-significant comme YAML + +### Pourquoi pas mDNS en v1.0 ? + +- Philosophie du projet : configuration explicite, zéro magie +- mDNS ajoute une complexité significative (multicast, découverte, timeouts) +- Prévu pour la v1.0 dans la roadmap + +### Pourquoi du polling (500ms) et pas des events OS ? + +- Les APIs de notification de presse-papiers sont non-portables (Win32 `AddClipboardFormatListener`, X11 `XFixes`, etc.) +- Le polling 500ms est un compromis acceptable entre réactivité et CPU +- Un seul `Read()` par tick, coût négligeable + +### Pourquoi un cache LRU de 20 hash et pas plus ? + +- 20 entrées couvrent ~10 secondes de copies rapides à 500ms/poll +- Au-delà, le risque de collision est négligeable (SHA-256) +- Garde la mémoire constante et prévisible diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..638cba4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] — 2026-08-08 + +### Added +- Synchronisation bidirectionnelle de presse-papiers texte sur LAN +- Communication HTTP POST /clip entre pairs (JSON) +- Anti-boucle : double filtre (hash SHA-256 LRU + timestamp/origine) +- Configuration TOML (`~/.config/clip-sync/peers.toml`) +- Override par variables d'environnement (`CLIP_SYNC_PORT`, `CLIP_SYNC_POLL_MS`) +- Support Linux : X11 (xclip) et Wayland (wl-clipboard) avec auto-détection +- Support Windows : Win32 API via syscall, zéro CGo +- Support macOS : pbpaste/pbcopy +- Cross-compilation : linux/windows/darwin, amd64/arm64 +- Service systemd --user (Linux) +- Installation automatisée via install.sh +- CI/CD Gitea Actions : lint, test, build multi-plateforme +- Tests unitaires et intégration (simulation LAN 2 instances) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1d0960 --- /dev/null +++ b/Makefile @@ -0,0 +1,90 @@ +# clip-sync Makefile +# Cross-compilation, tests, release. + +BINARY := clip-sync +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.1.0") +LDFLAGS := -s -w -X main.version=$(VERSION) +BUILD_DIR := bin + +GOOSES := linux windows darwin +GOARCHES := amd64 arm64 + +.DEFAULT_GOAL := build + +# ── Build ────────────────────────────────────────────────────────────── + +.PHONY: build +build: + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) . + +# ── Cross-compile ────────────────────────────────────────────────────── + +.PHONY: release +release: + @mkdir -p $(BUILD_DIR) + @for os in $(GOOSES); do \ + for arch in $(GOARCHES); do \ + echo "Building $$os/$$arch..."; \ + GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 go build \ + -ldflags "$(LDFLAGS)" \ + -o $(BUILD_DIR)/$(BINARY)-$$os-$$arch$(if $(filter windows,$$os),.exe,) \ + .; \ + done; \ + done + @echo "Release builds in $(BUILD_DIR)/:" + @ls -lh $(BUILD_DIR) + +# ── Test ─────────────────────────────────────────────────────────────── + +.PHONY: test +test: + go test ./... -count=1 -timeout 60s + +.PHONY: test-verbose +test-verbose: + go test ./... -v -count=1 -timeout 60s + +.PHONY: test-race +test-race: + go test ./... -race -count=1 -timeout 120s + +.PHONY: test-cover +test-cover: + go test ./... -coverprofile=coverage.out -count=1 -timeout 60s + go tool cover -func=coverage.out + +# ── Lint ─────────────────────────────────────────────────────────────── + +.PHONY: lint +lint: + golangci-lint run ./... + +# ── Clean ────────────────────────────────────────────────────────────── + +.PHONY: clean +clean: + rm -rf $(BUILD_DIR) coverage.out + +# ── Install (local dev) ──────────────────────────────────────────────── + +.PHONY: install +install: build + install -m 755 $(BUILD_DIR)/$(BINARY) $(HOME)/.local/bin/$(BINARY) + +# ── Help ──────────────────────────────────────────────────────────────── + +.PHONY: help +help: + @echo "clip-sync Makefile" + @echo "" + @echo " build Build for current platform" + @echo " release Cross-compile all platforms (linux/windows/darwin, amd64/arm64)" + @echo " test Run all tests" + @echo " test-verbose Run tests with verbose output" + @echo " test-race Run tests with race detector" + @echo " test-cover Run tests with coverage report" + @echo " lint Run golangci-lint" + @echo " clean Remove build artifacts" + @echo " install Install binary to ~/.local/bin/" + @echo " help Show this help" diff --git a/README.md b/README.md index e0111d0..eac9cae 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,149 @@ # clip-sync -Clipboard bidirectional sync daemon for LAN. One binary, zero deps. -Copy on one machine → paste on another. Automatic, instant. +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?logo=go)](https://go.dev) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Tests](https://img.shields.io/badge/tests-100%25-brightgreen)](.) -## One-liner install +**Daemon de synchronisation bidirectionnelle du presse-papiers sur réseau local.** + +Copier sur une machine → coller sur une autre, automatiquement et instantanément. + +``` +┌──────────────┐ POST /clip ┌──────────────┐ +│ Machine A │◄──────────────────────────►│ Machine B │ +│ (bureau) │ {text, ts, origin} │ (portable) │ +│ :9137 │ │ :9137 │ +└──────────────┘ └──────────────┘ +``` + +## Fonctionnalités + +- **Bidirectionnel** — copie A → B et B → A, chaque nœud est client et serveur +- **Anti-boucle** — double filtre (hash SHA-256 + timestamp/origine) empêche le ping-pong +- **Multi-plateforme** — Linux (X11/Wayland), Windows, macOS +- **Binaire unique** — zéro runtime externe, compilation Go native +- **Configuration simple** — fichier TOML + variables d'environnement +- **LAN uniquement** — aucune donnée ne quitte votre réseau local + +## Installation rapide + +### Via install.sh (recommandé) ```bash curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash ``` -This detects your OS, downloads the right binary, and creates a default config. +Le script détecte automatiquement votre OS et architecture. -## Quick start - -1. Install on every machine with the one-liner above -2. Edit `~/.config/clip-sync/peers.toml` — add all your machines -3. Run `clip-sync` (or set up as a service) - -```bash -clip-sync -# 2026/08/08 clip-sync: deepin listening on :9137, 3 peers -# 2026/08/08 recv: vivobook → 42 chars -``` - -## Config - -```toml -# ~/.config/clip-sync/peers.toml -listen = ":9137" - -[peers] -deepin = "192.168.30.101:9137" -vivobook = "192.168.20.161:9137" -openclaw1 = "192.168.30.27:9137" -``` - -## Service (optional) - -### Linux (systemd --user) -```bash -mkdir -p ~/.config/systemd/user -curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/clip-sync.service -o ~/.config/systemd/user/clip-sync.service -systemctl --user enable --now clip-sync -``` - -### Windows (nssm) -```powershell -nssm install clip-sync C:\Users\bruno\bin\clip-sync.exe -nssm set clip-sync AppDirectory C:\Users\bruno\.config\clip-sync -nssm start clip-sync -``` - -## How it works - -``` -┌──────────────┐ POST /clip ┌──────────────┐ -│ deepin │◄──────────────►│ vivobook │ -│ :9137 │ {text, ts} │ :9137 │ -│ │ │ │ -│ poll 500ms │ │ poll 500ms │ -│ xclip -o │ │ Get-Clip │ -└──────────────┘ └──────────────┘ -``` - -See [ARCHITECTURE.md](ARCHITECTURE.md) for details. - -## Build from source +### Compilation manuelle ```bash git clone https://git.dracodev.net/Projets/clip-sync.git cd clip-sync -go build -o clip-sync . - -# cross-compile -GOOS=windows GOARCH=amd64 go build -o clip-sync.exe . -GOOS=darwin GOARCH=amd64 go build -o clip-sync-darwin . +make build # binaire dans ./bin/ ``` + +### Prérequis par plateforme + +| Plateforme | Dépendance | Installation | +|-----------|-------------------|-------------------------------------------| +| Linux X11 | `xclip` | `apt install xclip` / `pacman -S xclip` | +| Linux Wayland | `wl-clipboard` | `apt install wl-clipboard` | +| macOS | intégré (pbpaste/pbcopy) | — | +| Windows | intégré (Win32 API, pas de dépendance externe) | — | + +## Démarrage rapide + +### 1. Créer la configuration + +```bash +mkdir -p ~/.config/clip-sync +``` + +**Machine A** (bureau, 192.168.1.10) : + +```toml +# ~/.config/clip-sync/peers.toml +[daemon] +port = 9137 +poll_interval_ms = 500 + +[[peers]] +name = "portable" +addr = "192.168.1.20:9137" +``` + +**Machine B** (portable, 192.168.1.20) : + +```toml +# ~/.config/clip-sync/peers.toml +[daemon] +port = 9137 +poll_interval_ms = 500 + +[[peers]] +name = "bureau" +addr = "192.168.1.10:9137" +``` + +### 2. Lancer le daemon + +```bash +clip-sync +``` + +### 3. Tester + +Copiez du texte sur la machine A (Ctrl+C), collez sur la machine B (Ctrl+V). +Ça marche ! + +## Configuration + +### Fichier TOML + +| Clé | Défaut | Description | +|-----|--------|-------------| +| `daemon.port` | `9137` | Port d'écoute HTTP | +| `daemon.poll_interval_ms` | `500` | Intervalle de scrutation du presse-papiers (ms) | +| `peers[].name` | — | Nom descriptif du pair | +| `peers[].addr` | — | Adresse `host:port` du pair | + +### Variables d'environnement (override) + +| Variable | Équivalent TOML | +|----------|-----------------| +| `CLIP_SYNC_PORT` | `daemon.port` | +| `CLIP_SYNC_POLL_MS` | `daemon.poll_interval_ms` | + +## Exécution en tant que service + +### Linux (systemd --user) + +```bash +mkdir -p ~/.config/systemd/user/ +cp clip-sync.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now clip-sync +``` + +### Windows (NSSM) + +```powershell +nssm install clip-sync C:\Users\\bin\clip-sync.exe +nssm set clip-sync AppStdout C:\Users\\AppData\Local\clip-sync\stdout.log +nssm set clip-sync AppStderr C:\Users\\AppData\Local\clip-sync\stderr.log +nssm start clip-sync +``` + +## Déboguer + +```bash +# Tester la connectivité entre pairs +curl -X POST http://192.168.1.20:9137/clip \ + -H 'Content-Type: application/json' \ + -d '{"text":"test","ts":0,"origin":"debug"}' +``` + +## Licence + +MIT © 2026 diff --git a/ROADMAP.md b/ROADMAP.md index 84b271f..1f6feba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,53 +1,36 @@ -# clip-sync — Roadmap +# ROADMAP.md — clip-sync -## v1.0 — Le strict nécessaire (main) +## v0.1.0 ✅ MVP — Sync texte bidirectionnel (actuelle) -- [x] Watch clipboard local (poll 500ms, hash-based dedup) -- [x] Serveur HTTP `POST /clip` pour recevoir les clips des pairs -- [x] Anti-boucle d'écho (lastHash + host+TS filtering) -- [x] Cross-platform clipboard: Linux (xclip/wl-paste), macOS (pbpaste), Windows (PowerShell) -- [x] Configuration TOML statique des pairs -- [x] `install.sh` one-liner multi-OS -- [x] Build cross-platform (`GOOS=linux|windows|darwin`) +- [x] Synchronisation texte entre pairs LAN +- [x] Anti-boucle (hash + timestamp + origine) +- [x] Configuration TOML + env override +- [x] Linux (X11 + Wayland), Windows, macOS +- [x] Tests unitaires + intégration +- [x] Build cross-plateforme +- [x] Service systemd --user -## v1.1 — Robustesse +## v0.2.0 🔒 Sécurité -- [ ] Retry HTTP avec backoff exponentiel (pair down → réessayer) -- [ ] Health check `/health` endpoint -- [ ] Logging structuré (timestamp, niveau, pair) -- [ ] Timeout HTTP (5s) — éviter de bloquer si un pair est down -- [ ] `systemd --user` unit file + `nssm` config Windows -- [ ] GoReleaser pour publier les binaires sur les releases Gitea +- [ ] Chiffrement TLS entre pairs (certificats auto-signés ou clé partagée) +- [ ] Authentification par clé partagée (header `Authorization: Bearer `) +- [ ] Validation de l'origine (rejet des pairs inconnus) +- [ ] Option `--generate-key` pour générer une clé partagée +- [ ] Config : section `[security]` dans peers.toml -## v2.0 — Fonctionnalités +## v0.3.0 📎 Contenu enrichi -- [ ] mDNS discovery automatique (alternative à la config statique) -- [ ] `cb history` — ring buffer local des 50 derniers clips -- [ ] `cb push ` / `cb pull ` — mode explicite en plus du sync auto -- [ ] TLS entre pairs (certificats auto-signés) -- [ ] Token d'authentification partagé -- [ ] Filtrage par type (texte seulement, pas d'images/binaire en v1) -- [ ] Support images (base64 dans le payload JSON, avec taille max configurable) +- [ ] Support images (JPEG/PNG) — payload Base64 +- [ ] Support fichiers (chemin + contenu Base64, taille max configurable) +- [ ] Historique du presse-papiers local (derniers N éléments) +- [ ] Commande `clip-sync history` pour afficher l'historique +- [ ] Option `--one-shot` : envoi unique sans daemon -## v3.0 — Intégrations +## v1.0.0 🌐 Stable -- [ ] Intégration Ditto (Windows) — lecture de `Ditto.db` SQLite, push automatique -- [ ] Bridge MQTT pour intégration domotique (Home Assistant) -- [ ] Web UI minimale pour voir l'état des pairs -- [ ] API WebSocket pour push temps réel (alternative au polling HTTP) - -## Non-goals (volontairement exclus) - -- Cloud relay / sync hors-LAN — utiliser Syncthing ou un autre outil -- Chiffrement bout-en-bout — le LAN est le périmètre de confiance -- Gestionnaire de presse-papier complet — Ditto, CopyQ, Ringboard existent déjà -- App mobile — hors scope - -## Calendrier indicatif - -| Version | Effort | Cible | -|---------|--------|-------| -| v1.0 | 1 session | Août 2026 | -| v1.1 | 1 session | Septembre 2026 | -| v2.0 | 2-3 sessions | 2027 | -| v3.0 | Au besoin | Quand Ditto devient prioritaire | +- [ ] Découverte automatique des pairs via mDNS (optionnel, activable) +- [ ] Interface web locale de monitoring (`localhost:9137/` : pairs connectés, stats) +- [ ] Notification desktop (DBus/Linux, toast/Windows, Notification Center/macOS) +- [ ] Tests de performance et benchmark +- [ ] Documentation utilisateur finale (site statique ou wiki) +- [ ] Signature GPG des releases diff --git a/clip-sync.service b/clip-sync.service new file mode 100644 index 0000000..9e9ffbf --- /dev/null +++ b/clip-sync.service @@ -0,0 +1,22 @@ +[Unit] +Description=clip-sync — LAN clipboard synchronization daemon +Documentation=https://git.dracodev.net/Projets/clip-sync +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/clip-sync +Restart=on-failure +RestartSec=5 +Environment=HOME=%h + +# Sandboxing (optional, uncomment for extra security) +# NoNewPrivileges=yes +# PrivateTmp=yes +# ProtectSystem=strict +# ProtectHome=read-only +# ReadWritePaths=%h/.config/clip-sync + +[Install] +WantedBy=default.target diff --git a/install.sh b/install.sh index b4bd180..696f502 100644 --- a/install.sh +++ b/install.sh @@ -1,70 +1,150 @@ #!/usr/bin/env bash -# clip-sync one-liner installer -# curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash +# clip-sync install.sh +# One-liner install: curl -sSL URL | bash +# +# Detects OS and architecture, downloads the correct binary, +# installs to ~/.local/bin/, and creates a default config. + set -euo pipefail REPO="https://git.dracodev.net/Projets/clip-sync" +VERSION="${CLIP_SYNC_VERSION:-latest}" +BINARY="clip-sync" +INSTALL_DIR="${HOME}/.local/bin" CONFIG_DIR="${HOME}/.config/clip-sync" -BIN_DIR="${HOME}/.local/bin" +CONFIG_FILE="${CONFIG_DIR}/peers.toml" -# --- detect OS/arch --- -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) -case "$ARCH" in - x86_64) ARCH="amd64" ;; - aarch64) ARCH="arm64" ;; - armv7l) ARCH="arm" ;; -esac +# ── Detect platform ──────────────────────────────────────────────────── -case "$OS" in - linux) BINARY="clip-sync-linux-${ARCH}" ;; - darwin) BINARY="clip-sync-darwin-${ARCH}" ;; - mingw*|msys*|cygwin*) OS="windows"; BINARY="clip-sync-windows-${ARCH}.exe" ;; -esac +detect_platform() { + local os arch -echo "→ clip-sync installer: ${OS}/${ARCH}" + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) + echo "clip-sync: unsupported OS: $(uname -s)" + exit 1 + ;; + esac -# --- download or build --- -mkdir -p "$BIN_DIR" "$CONFIG_DIR" + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) + echo "clip-sync: unsupported architecture: $(uname -m)" + exit 1 + ;; + esac -# Try release download first (v1.0.0+) -DOWNLOAD_URL="${REPO}/releases/download/latest/${BINARY}" -if command -v curl &>/dev/null; then - if curl -fSL --max-time 30 "$DOWNLOAD_URL" -o "${BIN_DIR}/clip-sync" 2>/dev/null; then - echo "✓ Downloaded ${BINARY}" - chmod +x "${BIN_DIR}/clip-sync" + echo "${os}-${arch}" +} + +# ── Download binary ──────────────────────────────────────────────────── + +download_binary() { + local platform="$1" + local bin_name="${BINARY}-${platform}" + local download_url + + if [ "$VERSION" = "latest" ]; then + # Try latest release + download_url="${REPO}/releases/download/latest/${bin_name}" else - echo "⚠ Release not found, building from source (need Go)..." - # Fallback: build from source - TMPDIR=$(mktemp -d) - git clone "$REPO" "$TMPDIR" 2>/dev/null || { echo "✗ git clone failed. Install Go and git, or wait for v1.0.0 release."; exit 1; } - cd "$TMPDIR" - go build -o "${BIN_DIR}/clip-sync" . - rm -rf "$TMPDIR" - echo "✓ Built from source" + download_url="${REPO}/releases/download/${VERSION}/${bin_name}" fi -elif command -v wget &>/dev/null; then - wget -q --timeout=30 "$DOWNLOAD_URL" -O "${BIN_DIR}/clip-sync" && chmod +x "${BIN_DIR}/clip-sync" && echo "✓ Downloaded ${BINARY}" || echo "⚠ Download failed" -fi -# --- config --- -if [ ! -f "${CONFIG_DIR}/peers.toml" ]; then - curl -sSL "${REPO}/raw/branch/main/peers.example.toml" -o "${CONFIG_DIR}/peers.toml" 2>/dev/null || true - echo "✓ Created ${CONFIG_DIR}/peers.toml (edit your peers!)" -else - echo "• Config already exists: ${CONFIG_DIR}/peers.toml" -fi + # Windows binary has .exe extension + if [[ "$platform" == windows-* ]]; then + bin_name="${bin_name}.exe" + download_url="${download_url}.exe" + fi -# --- PATH hint --- -if [[ ":$PATH:" != *":${BIN_DIR}:"* ]]; then + echo "clip-sync: downloading ${download_url}..." + + if command -v curl &>/dev/null; then + curl -fSL --progress-bar -o "${INSTALL_DIR}/${BINARY}" "${download_url}" || { + echo "clip-sync: download failed. Try building from source:" + echo " git clone ${REPO} && cd clip-sync && make install" + exit 1 + } + elif command -v wget &>/dev/null; then + wget -q --show-progress -O "${INSTALL_DIR}/${BINARY}" "${download_url}" || { + echo "clip-sync: download failed. Try building from source:" + echo " git clone ${REPO} && cd clip-sync && make install" + exit 1 + } + else + echo "clip-sync: curl or wget required for download" + exit 1 + fi + + chmod +x "${INSTALL_DIR}/${BINARY}" + echo "clip-sync: installed to ${INSTALL_DIR}/${BINARY}" +} + +# ── Create default config ────────────────────────────────────────────── + +create_config() { + if [ -f "${CONFIG_FILE}" ]; then + echo "clip-sync: config already exists at ${CONFIG_FILE} (skipping)" + return + fi + + mkdir -p "${CONFIG_DIR}" + cat > "${CONFIG_FILE}" <<'EOF' +# clip-sync peers configuration +# ~/.config/clip-sync/peers.toml +# +# Environment overrides: +# CLIP_SYNC_PORT=9137 override daemon.port +# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms + +[daemon] +port = 9137 +poll_interval_ms = 500 + +# Add your peers here: +# [[peers]] +# name = "example" +# addr = "192.168.1.x:9137" +EOF + + echo "clip-sync: created default config at ${CONFIG_FILE}" + echo "clip-sync: edit it to add your peers, then run: ${BINARY}" +} + +# ── Main ──────────────────────────────────────────────────────────────── + +main() { + echo "clip-sync installer" + echo "===================" echo "" - echo "⚠ Add to your PATH:" - echo " export PATH=\"${BIN_DIR}:\$PATH\"" - echo " (add this to ~/.bashrc or ~/.zshrc)" -fi -echo "" -echo "✓ clip-sync installed!" -echo " 1. Edit ${CONFIG_DIR}/peers.toml — add your machines" -echo " 2. Run: clip-sync" -echo " 3. (Optional) Set up as a service for auto-start" + mkdir -p "${INSTALL_DIR}" + + local platform + platform=$(detect_platform) + echo "clip-sync: detected platform: ${platform}" + + download_binary "${platform}" + create_config + + # Check if install dir is in PATH + if ! echo "${PATH}" | tr ':' '\n' | grep -qF "${INSTALL_DIR}"; then + echo "" + echo "clip-sync: NOTE: ${INSTALL_DIR} is not in your PATH." + echo " Add this to your shell config:" + echo " export PATH=\"\${HOME}/.local/bin:\${PATH}\"" + fi + + echo "" + echo "clip-sync: installation complete!" + echo " 1. Edit ${CONFIG_FILE} to add your peers" + echo " 2. Run: ${BINARY}" + echo "" + echo " For service installation, see README.md" +} + +main diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..b5b9fe6 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -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 +} diff --git a/internal/clipboard/clipboard_darwin.go b/internal/clipboard/clipboard_darwin.go new file mode 100644 index 0000000..77407d6 --- /dev/null +++ b/internal/clipboard/clipboard_darwin.go @@ -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 +} diff --git a/internal/clipboard/clipboard_linux.go b/internal/clipboard/clipboard_linux.go new file mode 100644 index 0000000..d4c91d2 --- /dev/null +++ b/internal/clipboard/clipboard_linux.go @@ -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 +} diff --git a/internal/clipboard/clipboard_windows.go b/internal/clipboard/clipboard_windows.go new file mode 100644 index 0000000..347349e --- /dev/null +++ b/internal/clipboard/clipboard_windows.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c9eed76 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..32815ed --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..0dd5af9 --- /dev/null +++ b/internal/daemon/daemon.go @@ -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) + } + } + } +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..c50c5e6 --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -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() +} diff --git a/internal/dedup/dedup.go b/internal/dedup/dedup.go new file mode 100644 index 0000000..8360749 --- /dev/null +++ b/internal/dedup/dedup.go @@ -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[:]) +} diff --git a/internal/dedup/dedup_test.go b/internal/dedup/dedup_test.go new file mode 100644 index 0000000..ce8baf8 --- /dev/null +++ b/internal/dedup/dedup_test.go @@ -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) + } +} diff --git a/internal/peer/peer.go b/internal/peer/peer.go new file mode 100644 index 0000000..8252651 --- /dev/null +++ b/internal/peer/peer.go @@ -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) + } +} diff --git a/internal/peer/peer_test.go b/internal/peer/peer_test.go new file mode 100644 index 0000000..ddb36cd --- /dev/null +++ b/internal/peer/peer_test.go @@ -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("") +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..16b77cd --- /dev/null +++ b/internal/server/server.go @@ -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) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..1b660b2 --- /dev/null +++ b/internal/server/server_test.go @@ -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()) +} diff --git a/main.go b/main.go index 73ec436..3735cb0 100644 --- a/main.go +++ b/main.go @@ -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") } diff --git a/peers.toml.example b/peers.toml.example new file mode 100644 index 0000000..77cafc9 --- /dev/null +++ b/peers.toml.example @@ -0,0 +1,18 @@ +# clip-sync peers configuration +# ~/.config/clip-sync/peers.toml +# +# Environment overrides: +# CLIP_SYNC_PORT=9137 override daemon.port +# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms + +[daemon] +port = 9137 +poll_interval_ms = 500 + +[[peers]] +name = "bureau" +addr = "192.168.1.10:9137" + +[[peers]] +name = "portable" +addr = "192.168.1.20:9137"