diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5f20193..01e43bf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -99,6 +99,19 @@ jobs: with: path: artifacts/ + - name: Import GPG key + if: ${{ secrets.GPG_PRIVATE_KEY != '' }} + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import + + - name: Sign artifacts + if: ${{ secrets.GPG_PRIVATE_KEY != '' }} + run: | + for f in artifacts/*/clip-sync-*; do + gpg --batch --yes --armor --detach-sign "$f" + done + - name: Create release uses: gitea/action-gitea-release@v1 with: @@ -109,9 +122,16 @@ jobs: See [CHANGELOG.md](https://git.dracodev.net/Projets/clip-sync/src/branch/main/CHANGELOG.md) for details. - ### Install + ### Install — Linux / macOS ```bash - curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash + curl -fsSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash ``` + + ### Install — Windows (PowerShell) + ```powershell + irm https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.ps1 | iex + ``` + + Binaries are signed with GPG (`.asc` files) where a signing key is configured. files: | artifacts/**/clip-sync-* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af2eed0..ce2491f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,7 +111,7 @@ Boucle principale : ### 9. Découverte (`internal/discovery/`) et notifications (`internal/notify/`) - mDNS `_clip-sync._tcp` (hashicorp/mdns), activation via `daemon.discovery` -- Notifications desktop : notify-send (Linux), osascript (macOS), no-op (Windows) +- Notifications desktop : notify-send (Linux), osascript (macOS), Shell_NotifyIcon (Windows) ## Dépendances diff --git a/CHANGELOG.md b/CHANGELOG.md index ac80a78..869e478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Notifications desktop (`daemon.notify`) - Flag `--config ` pour une configuration personnalisée - Fichier LICENSE (MIT) +- Notifications desktop Windows (Shell_NotifyIcon, plus de no-op) +- Benchmarks (`make bench`) : dedup, serveur, conversion DIB↔PNG +- Signature GPG des releases (CI `GPG_PRIVATE_KEY` + `make sign`) +- Site de documentation statique (`docs/index.html`) +- Installateur Windows en une ligne (`install.ps1`, `irm | iex`) ### Fixed - Lecture du presse-papiers Windows bornée à la taille réelle (GlobalSize) diff --git a/Makefile b/Makefile index 2379b97..418b7f1 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,22 @@ test-cover: go test ./... -coverprofile=coverage.out -count=1 -timeout 60s go tool cover -func=coverage.out +# ── Benchmark ───────────────────────────────────────────────────────── + +.PHONY: bench +bench: + go test ./... -bench=. -benchmem -run=^$$ -count=1 + +# ── Release signing ─────────────────────────────────────────────────── + +.PHONY: sign +sign: + @for f in $(BUILD_DIR)/clip-sync-*; do \ + echo "Signing $$f"; \ + gpg --batch --yes --armor --detach-sign "$$f"; \ + done + @echo "Signed binaries in $(BUILD_DIR)/ (alongside .asc signatures)" + # ── Lint ─────────────────────────────────────────────────────────────── .PHONY: lint @@ -84,6 +100,8 @@ help: @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 " bench Run benchmarks" + @echo " sign GPG-sign release binaries in bin/" @echo " lint Run golangci-lint" @echo " clean Remove build artifacts" @echo " install Install binary to ~/.local/bin/" diff --git a/README.md b/README.md index b4a4e41..cf7b015 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,19 @@ Copier sur une machine → coller sur une autre, automatiquement et instantaném ## Installation -### Méthode 1 : Script automatique (tous OS) +### Méthode 1 : Script automatique en une ligne (recommandé) ```bash -curl -sSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash +curl -fsSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash ``` -Le script détecte OS + architecture, télécharge le binaire, l'installe dans `~/.local/bin/` et crée une config par défaut. +**Windows (PowerShell) :** + +```powershell +irm https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.ps1 | iex +``` + +Le script détecte OS + architecture, télécharge le binaire, l'installe dans le répertoire utilisateur et crée une config par défaut. Pour épingler une version : `CLIP_SYNC_VERSION=v0.2.0` (Linux/macOS) ou `$env:CLIP_SYNC_VERSION='v0.2.0'` (Windows). --- @@ -351,6 +357,18 @@ curl -X POST http://192.168.1.20:9137/clip \ L'interface de monitoring est disponible sur `http://localhost:9137/` après le démarrage du daemon. +## Documentation + +Site de documentation statique : [`docs/index.html`](docs/index.html). + +Commandes de développement utiles : + +```bash +make bench # benchmarks +make sign # signer les binaires de release avec GPG +make test-race # tests avec détecteur de race +``` + ## Licence MIT © 2026 diff --git a/ROADMAP.md b/ROADMAP.md index 8673e07..c292522 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,11 +30,12 @@ macOS (`daemon.sync_images`) — la lecture macOS via AppleScript est best-effort et dépend de la version du système -## v1.0.0 🌐 Stable (partiellement réalisée) +## v1.0.0 🌐 Stable (réalisée) - [x] Découverte automatique des pairs via mDNS (`daemon.discovery`) - [x] Interface web locale de monitoring (`http://localhost:9137/`) -- [x] Notification desktop (DBus/Linux, Notification Center/macOS ; no-op Windows) -- [ ] Tests de performance et benchmark -- [ ] Documentation utilisateur finale (site statique ou wiki) -- [ ] Signature GPG des releases +- [x] Notification desktop (notify-send/Linux, osascript/macOS, Shell_NotifyIcon/Windows) +- [x] Tests de performance et benchmark (`make bench`) +- [x] Documentation utilisateur finale (site statique `docs/index.html`) +- [x] Signature GPG des releases (CI + `make sign`) +- [x] Installation en une ligne (Linux/macOS `curl|bash`, Windows `irm|iex`) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..c835811 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,139 @@ + + + + + +clip-sync — documentation + + + +
+
+

clip-sync

+

Daemon de synchronisation bidirectionnelle du presse-papiers sur réseau local. Copier sur une machine → coller sur une autre.

+
+
+ +
+ +

Installation en une ligne

+

Rapide, sans dépendance à compiler : un binaire unique téléchargé puis exécutable.

+ +

Linux / macOS

+
curl -fsSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash
+ +

Windows (PowerShell)

+
irm https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.ps1 | iex
+ +

Le script détecte OS + architecture, télécharge le binaire, l'installe dans +votre répertoire utilisateur et crée une configuration par défaut. Pour choisir +une version précise : CLIP_SYNC_VERSION=v0.2.0 (Linux/macOS) ou +$env:CLIP_SYNC_VERSION='v0.2.0' (Windows).

+ +

Démarrage rapide

+
    +
  1. Éditez ~/.config/clip-sync/peers.toml et ajoutez vos machines (voir ci-dessous).
  2. +
  3. Lancez clip-sync sur chaque machine.
  4. +
  5. Copiez du texte ou une image (Ctrl+C), collez sur l'autre machine (Ctrl+V).
  6. +
+ +

Configuration minimale

+
# ~/.config/clip-sync/peers.toml
+[daemon]
+port = 9137
+poll_interval_ms = 500
+
+[[peers]]
+name = "portable"
+addr = "192.168.1.20:9137"
+ +

Sur l'autre machine, inversez l'adresse. Vous pouvez aussi activer la +découverte automatique (discovery = true) pour ne plus configurer +d'adresse du tout.

+ +

Commandes

+ + + + + + + + + + +
CommandeDescription
clip-syncLancer le daemon
clip-sync --config <path>Config personnalisée
clip-sync --versionAfficher la version
clip-sync historyAfficher l'historique local
clip-sync --one-shot [text]Envoyer le presse-papiers (ou texte) une fois
clip-sync send-file <path>Envoyer un fichier aux pairs
clip-sync --generate-keyGénérer une clé partagée
clip-sync --generate-certGénérer un certificat TLS auto-signé
+ +

Configuration

+ + + + + + + + + + + + + +
CléDéfautDescription
daemon.port9137Port d'écoute HTTP(S)
daemon.poll_interval_ms500Intervalle de scrutation (ms)
daemon.max_body_bytes10485760Taille max d'une requête
daemon.history_size50Entrées d'historique conservées
daemon.discoveryfalseDécouverte mDNS des pairs
daemon.notifyfalseNotifications desktop
daemon.sync_imagesfalseSync d'images (PNG)
daemon.receive_filesfalseAccepter les transferts de fichiers
security.shared_key—Clé partagée (Bearer token)
security.allowed_origins—Hôtes autorisés
security.tlsfalseChiffrement TLS entre pairs
+ +

Variables d'environnement : CLIP_SYNC_PORT, +CLIP_SYNC_POLL_MS, CLIP_SYNC_KEY, +CLIP_SYNC_MAX_BODY_BYTES.

+ +

Sécurité

+

Sur un réseau partagé, activez au minimum une clé partagée :

+
clip-sync --generate-key   # copier la clé sur toutes les machines
+
[security]
+shared_key = "LA_CLÉ_GÉNÉRÉE"
+

Pour chiffrer le trafic, générez un certificat (clip-sync --generate-cert), +copiez cert.pem/key.pem sur chaque machine, puis +tls = true et insecure_skip_verify = true.

+ +

Fonctionnalités

+ + +

Service au démarrage

+

Linux (systemd) : voir le fichier clip-sync.service. +Windows : utiliser NSSM. macOS : un LaunchAgent +(voir le README pour les détails complets).

+ +
+ + + + diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..eebb648 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,88 @@ +# clip-sync install.ps1 +# One-liner install on Windows (PowerShell): +# +# irm https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.ps1 | iex +# +# Detects architecture, downloads the binary, installs to +# %LOCALAPPDATA%\clip-sync\bin, adds it to PATH, and creates a default config. + +$ErrorActionPreference = 'Stop' + +$Version = if ($env:CLIP_SYNC_VERSION) { $env:CLIP_SYNC_VERSION } else { 'v0.2.0' } +$Repo = 'https://git.dracodev.net/Projets/clip-sync' + +# ── Detect architecture ─────────────────────────────────────────────── + +$arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + 'x86' { 'amd64' } # 32-bit Windows runs the amd64 binary under WoW64/emulation + default { throw "clip-sync: unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } +} + +$binName = "clip-sync-windows-$arch.exe" +$url = "$Repo/releases/download/$Version/$binName" + +$installDir = Join-Path $env:LOCALAPPDATA 'clip-sync\bin' +$exePath = Join-Path $installDir 'clip-sync.exe' + +# ── Download ────────────────────────────────────────────────────────── + +New-Item -ItemType Directory -Force -Path $installDir | Out-Null +Write-Host "clip-sync: downloading $url" + +try { + Invoke-WebRequest -Uri $url -OutFile $exePath -UseBasicParsing +} catch { + Write-Host "clip-sync: download failed. Try building from source:" -ForegroundColor Red + Write-Host " git clone $Repo && cd clip-sync && make install" + throw +} + +# ── Add to PATH ─────────────────────────────────────────────────────── + +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if ($userPath -split ';' -notcontains $installDir) { + [Environment]::SetEnvironmentVariable('Path', "$userPath;$installDir", 'User') + $env:Path += ";$installDir" + Write-Host "clip-sync: added $installDir to PATH (restart your terminal to use it)" +} else { + $env:Path += ";$installDir" +} + +# ── Create default config ───────────────────────────────────────────── + +$configDir = Join-Path $env:USERPROFILE '.config\clip-sync' +$configFile = Join-Path $configDir 'peers.toml' + +if (-not (Test-Path $configFile)) { + New-Item -ItemType Directory -Force -Path $configDir | Out-Null + @' +# clip-sync peers configuration +# %USERPROFILE%\.config\clip-sync\peers.toml + +[daemon] +port = 9137 +poll_interval_ms = 500 +# discovery = true # enable mDNS auto-discovery of peers +# notify = true # enable desktop notifications +# sync_images = true # sync images from the clipboard (PNG) +# receive_files = true # accept incoming file transfers + +[security] +# shared_key = "..." # generate with: clip-sync --generate-key + +# Add your peers here: +# [[peers]] +# name = "example" +# addr = "192.168.1.x:9137" +'@ | Set-Content -Path $configFile -Encoding UTF8 + Write-Host "clip-sync: created default config at $configFile" +} + +Write-Host "" +Write-Host "clip-sync: installation complete!" -ForegroundColor Green +Write-Host " 1. Edit $configFile to add your peers" +Write-Host " 2. Run: clip-sync" +Write-Host "" +Write-Host " For service installation, see README.md" diff --git a/internal/clipboard/dib_windows_test.go b/internal/clipboard/dib_windows_test.go index d9c810e..68841b6 100644 --- a/internal/clipboard/dib_windows_test.go +++ b/internal/clipboard/dib_windows_test.go @@ -70,3 +70,22 @@ func TestDIBRejectsUnsupportedCompression(t *testing.T) { t.Error("expected error for unsupported compression, got nil") } } + +func BenchmarkDIBRoundTrip(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 256, 256)) + var buf bytes.Buffer + _ = png.Encode(&buf, img) + pngData := buf.Bytes() + + dib, err := pngToDIB(pngData) + if err != nil { + b.Fatalf("pngToDIB: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := dibToPNG(dib); err != nil { + b.Fatalf("dibToPNG: %v", err) + } + } +} diff --git a/internal/dedup/bench_test.go b/internal/dedup/bench_test.go new file mode 100644 index 0000000..6eb31a2 --- /dev/null +++ b/internal/dedup/bench_test.go @@ -0,0 +1,41 @@ +package dedup + +import ( + "testing" + "time" +) + +func BenchmarkShouldIgnoreNewContent(b *testing.B) { + f := NewFilter("machine-a") + payload := Payload{ + Text: "benchmark content string", + Ts: time.Now().UnixNano(), + Origin: "machine-b", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = f.ShouldIgnore(payload) + } +} + +func BenchmarkShouldIgnoreImage(b *testing.B) { + f := NewFilter("machine-a") + payload := Payload{ + Mime: "image/png", + Data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + Ts: time.Now().UnixNano(), + Origin: "machine-b", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = f.ShouldIgnore(payload) + } +} + +func BenchmarkMarkWritten(b *testing.B) { + f := NewFilter("machine-a") + b.ResetTimer() + for i := 0; i < b.N; i++ { + f.MarkWritten("some clipboard text") + } +} diff --git a/internal/notify/notify_windows.go b/internal/notify/notify_windows.go index 464584e..93df911 100644 --- a/internal/notify/notify_windows.go +++ b/internal/notify/notify_windows.go @@ -2,12 +2,161 @@ package notify -// Notifier is a no-op on Windows. Native toast notifications require a -// registered, packaged app (or a third-party module such as BurntToast), which -// is out of scope for a single self-contained binary. +import ( + "fmt" + "sync" + "syscall" + "unsafe" +) + +// notifier shows balloon notifications via Shell_NotifyIconW. This is a +// self-contained approach (no Start Menu shortcut or AppID registration +// required), unlike WinRT toast notifications. The balloon appears in the +// notification area and a small tray icon is kept for the daemon's lifetime. type notifier struct{} -// New returns a no-op Windows notifier. +// New returns a Windows notifier using Shell_NotifyIcon balloon tips. func New() Notifier { return notifier{} } -func (notifier) Notify(string, string) error { return nil } +const ( + nifInfo = 0x00000010 + nimAdd = 0x00000000 + nimModify = 0x00000001 + niiInfo = 0x00000001 +) + +// hwndMessage is the HWND_MESSAGE (-3) pseudo-handle for a message-only window. +const hwndMessage = ^uintptr(2) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + user32 = syscall.NewLazyDLL("user32.dll") + shell32 = syscall.NewLazyDLL("shell32.dll") + + procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procShellNotifyIconW = shell32.NewProc("Shell_NotifyIconW") +) + +type wndClassExW struct { + cbSize uint32 + style uint32 + lpfnWndProc uintptr + cbClsExtra int32 + cbWndExtra int32 + hInstance uintptr + hIcon uintptr + hCursor uintptr + hbrBackground uintptr + lpszMenuName *uint16 + lpszClassName *uint16 + hIconSm uintptr +} + +type notifyIconDataW struct { + cbSize uint32 + hWnd uintptr + uID uint32 + uFlags uint32 + uCallbackMessage uint32 + hIcon uintptr + szTip [128]uint16 + dwState uint32 + dwStateMask uint32 + szInfo [256]uint16 + uTimeout uint32 + szInfoTitle [64]uint16 + dwInfoFlags uint32 + guidItem [16]byte + hBalloonIcon uintptr +} + +var ( + notifyOnce sync.Once + notifyHwnd uintptr + notifyErr error + + iconMu sync.Mutex + iconAdded bool +) + +func setup() { + hInstance, _, _ := procGetModuleHandleW.Call(0) + if hInstance == 0 { + notifyErr = syscall.EINVAL + return + } + + className, _ := syscall.UTF16PtrFromString("clip-sync-notify") + wc := wndClassExW{ + cbSize: uint32(unsafe.Sizeof(wndClassExW{})), + lpfnWndProc: procDefWindowProcW.Addr(), + hInstance: hInstance, + lpszClassName: className, + } + // Registration may fail if already registered (e.g. re-run) — that's fine. + _, _, _ = procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) + + hwnd, _, _ := procCreateWindowExW.Call( + 0, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(className)), + 0, + 0, 0, 0, 0, + hwndMessage, + 0, hInstance, 0, + ) + notifyHwnd = hwnd + if hwnd == 0 { + notifyErr = fmt.Errorf("notify: CreateWindowExW failed") + } +} + +func (notifier) Notify(title, body string) error { + notifyOnce.Do(setup) + if notifyErr != nil { + return notifyErr + } + + var nid notifyIconDataW + nid.cbSize = uint32(unsafe.Sizeof(nid)) + nid.hWnd = notifyHwnd + nid.uID = 1 + nid.uFlags = nifInfo + nid.uTimeout = 5000 + nid.dwInfoFlags = niiInfo + + if info, err := syscall.UTF16FromString(body); err == nil { + copy(nid.szInfo[:], info) + } + if title16, err := syscall.UTF16FromString(title); err == nil { + copy(nid.szInfoTitle[:], title16) + } + + iconMu.Lock() + defer iconMu.Unlock() + + cmd := uintptr(nimAdd) + if iconAdded { + cmd = nimModify + } + r, _, _ := procShellNotifyIconW.Call(cmd, uintptr(unsafe.Pointer(&nid))) + if r != 0 { + iconAdded = true + return nil + } + + // Fall back to the other command on failure (e.g. icon already/not yet present). + other := uintptr(nimAdd) + if cmd == nimAdd { + other = nimModify + } + r, _, _ = procShellNotifyIconW.Call(other, uintptr(unsafe.Pointer(&nid))) + if r != 0 { + iconAdded = true + return nil + } + return fmt.Errorf("notify: Shell_NotifyIconW failed") +} diff --git a/internal/server/bench_test.go b/internal/server/bench_test.go new file mode 100644 index 0000000..91e91a6 --- /dev/null +++ b/internal/server/bench_test.go @@ -0,0 +1,41 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.dracodev.net/Projets/clip-sync/internal/dedup" +) + +func BenchmarkHandleClipText(b *testing.B) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) + + body, _ := json.Marshal(dedup.Payload{ + Text: "benchmark clipboard text", Ts: 1690000000000000000, Origin: "machine-b", + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.handleClip(w, req) + } +} + +func BenchmarkHandleClipImage(b *testing.B) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) + + body := []byte(`{"mime":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==","ts":1,"origin":"machine-b"}`) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.handleClip(w, req) + } +}