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