feat: implémentation complete v0.1.0 — daemon de sync clipboard bidirectionnel
CI / Lint (push) Failing after 14m46s
CI / Test (push) Failing after 4m23s
CI / Build (darwin/amd64) (push) Skipped
CI / Build (linux/amd64) (push) Skipped
CI / Build (windows/amd64) (push) Skipped
CI / Build (darwin/arm64) (push) Skipped
CI / Build (linux/arm64) (push) Skipped
CI / Build (windows/arm64) (push) Skipped
CI / Release (push) Skipped

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