10 Commits
Author SHA1 Message Date
bruno 5e8694495d fix(ci): pin deps to Go 1.21 and remove unused func (was breaking lint/test)
CI / Lint (push) Successful in 18m53s
CI / Test (push) Failing after 5m34s
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
2026-08-14 11:42:04 -04:00
bruno 52823109bc fix: resolve latest release dynamically in installers (was hardcoded v0.2.0)
CI / Lint (push) Failing after 9m35s
CI / Test (push) Failing after 6m4s
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
2026-08-14 11:32:19 -04:00
bruno 919c6f0040 docs: move one-liner install to prominent top section
CI / Lint (push) Failing after 9m53s
CI / Test (push) Failing after 5m56s
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
2026-08-14 11:12:03 -04:00
bruno 4289d2d0ba docs: Windows notification test checklist + GPG signing steps 2026-08-14 11:05:59 -04:00
bruno 35c6b98aec feat: Windows notifications, benchmarks, GPG signing, docs site, one-liner installers 2026-08-14 10:57:20 -04:00
bruno 448d2c4ead docs: mention send-file subcommand in usage 2026-08-14 09:59:38 -04:00
bruno f2a3e34f94 test: full config surface coverage; remove dead PeerUsesTLS helper 2026-08-14 09:57:39 -04:00
bruno 32eff914d5 feat: Windows (CF_DIB) and macOS (AppleScript) clipboard image sync 2026-08-14 09:55:32 -04:00
bruno 147cba6d2a feat: native clipboard image sync (Linux, opt-in sync_images) 2026-08-14 09:51:25 -04:00
bruno 884415c1c1 feat: implement security, discovery, history, file transfer, web UI (P0-P3) 2026-08-14 09:45:54 -04:00
44 changed files with 3317 additions and 236 deletions
+29 -2
View File
@@ -99,6 +99,26 @@ 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 != '' }}
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
for f in artifacts/*/clip-sync-*; do
if [ -n "$GPG_PASSPHRASE" ]; then
gpg --batch --yes --pinentry-mode loopback \
--passphrase "$GPG_PASSPHRASE" --armor --detach-sign "$f"
else
gpg --batch --yes --armor --detach-sign "$f"
fi
done
- name: Create release
uses: gitea/action-gitea-release@v1
with:
@@ -109,9 +129,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-*
+29 -2
View File
@@ -41,7 +41,10 @@ en mesh pair-à-pair sur réseau local. Chaque nœud est à la fois client et se
### 1. Clipboard (`internal/clipboard/`)
Interface unifiée `Read() / Write()` avec implémentations par plateforme :
Interface unifiée `Read() / Write()` avec implémentations par plateforme.
L'interface optionnelle `ImageClipboard` (`ReadImage` / `WriteImage`) ajoute la
sync d'images PNG (xclip/wl-clipboard sur Linux, CF_DIB sur Windows, AppleScript
sur macOS) :
- **Linux** : auto-détection X11/Wayland via `$XDG_SESSION_TYPE`
- X11 → `xclip -selection clipboard -o` / `xclip -selection clipboard`
@@ -88,13 +91,37 @@ Boucle principale :
2. Ticker 500ms : lit le presse-papiers, compare, broadcast si changé
3. Signal SIGINT/SIGTERM → arrêt gracieux
### 6. Sécurité (`internal/security/`)
- `--generate-key` : clé partagée hex (256 bits)
- `--generate-cert` : certificat ECDSA P-256 auto-signé
- Auth Bearer token (comparaison à temps constant), validation d'origine
### 7. Historique (`internal/history/`)
- Anneau mémoire borné (`history_size`), persisté en JSON
- Commandes `clip-sync history` et endpoint `/history`
### 8. Transfert de fichiers (`internal/transfer/`)
- Payload `{name, mime, data(base64), ts, origin}` via `POST /file`
- Réception opt-in (`receive_files`), écrit dans `receive_dir`
- Nom de fichier assaini (anti path-traversal)
### 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), Shell_NotifyIcon (Windows)
## Dépendances
| Dépendance | Version | Justification |
|-----------|---------|---------------|
| `github.com/BurntSushi/toml` | v1.3.2 | Parsing TOML (stdlib Go n'inclut pas TOML) |
| `github.com/hashicorp/mdns` | v1.0.7 | Découverte mDNS (optionnelle, `daemon.discovery`) |
**Total : 1 dépendance externe.** Tout le reste est stdlib Go.
**Total : 2 dépendances externes directes.** Tout le reste est stdlib Go.
mdns est optionnel (utilisé uniquement si `daemon.discovery = true`).
## Décisions architecturales
+28
View File
@@ -5,6 +5,34 @@ 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.2.0] — 2026-08-14
### Added
- Sécurité : clé partagée (Bearer token) + `--generate-key`
- Sécurité : validation d'origine (`security.allowed_origins`)
- Sécurité : chiffrement TLS optionnel entre pairs + `--generate-cert`
- Limite de taille du corps des requêtes (`daemon.max_body_bytes`)
- Historique du presse-papiers persistant + commande `clip-sync history`
- Mode one-shot : `clip-sync --one-shot [text]`
- Transfert de fichiers : `clip-sync send-file <path>` (réception optionnelle)
- Sync d'images via le presse-papiers (`daemon.sync_images`, Linux/Windows/macOS)
- Découverte automatique des pairs via mDNS (`daemon.discovery`)
- Interface web locale de monitoring (`/`, `/health`, `/history`)
- Notifications desktop (`daemon.notify`)
- Flag `--config <path>` 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)
- Version par défaut non vide (`--version`)
- Correction des URLs de téléchargement (tag versionné au lieu de `latest`)
- Suppression du fichier d'exemple dupliqué
## [0.1.0] — 2026-08-08
### Added
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 clip-sync contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19 -1
View File
@@ -2,7 +2,7 @@
# Cross-compilation, tests, release.
BINARY := clip-sync
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.1.0")
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.2.0")
LDFLAGS := -s -w -X main.version=$(VERSION)
BUILD_DIR := bin
@@ -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/"
+167 -13
View File
@@ -16,10 +16,34 @@ Copier sur une machine → coller sur une autre, automatiquement et instantaném
└──────────────┘ └──────────────┘
```
## Installation rapide
**Linux / macOS :**
```bash
curl -fsSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash
```
**Windows (PowerShell) :**
```powershell
irm https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.ps1 | iex
```
Le script détecte l'OS et l'architecture, télécharge le binaire, l'installe et
crée la configuration par défaut. Ensuite : éditez `~/.config/clip-sync/peers.toml`
et lancez `clip-sync`. Voir [Configuration](#configuration) et
[installation détaillée](#installation).
## 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
- **Sécurité** — clé partagée (Bearer token), validation d'origine, TLS optionnel
- **Découverte mDNS** — détection automatique des pairs (optionnelle)
- **Historique** — derniers clips conservés (`clip-sync history`)
- **Transfert de fichiers** — `clip-sync send-file` (réception optionnelle)
- **Interface web** — monitoring local sur `http://localhost:9137/`
- **Multi-plateforme** — Linux (X11/Wayland), Windows, macOS
- **Binaire unique** — zéro runtime externe, compilation Go native
- **Configuration simple** — fichier TOML + variables d'environnement
@@ -27,13 +51,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).
---
@@ -57,12 +87,12 @@ clip-sync détecte automatiquement X11 vs Wayland via `$XDG_SESSION_TYPE`.
```bash
# amd64
curl -sSL -o ~/.local/bin/clip-sync \
https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-linux-amd64
https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-linux-amd64
chmod +x ~/.local/bin/clip-sync
# arm64 (Raspberry Pi, etc.)
curl -sSL -o ~/.local/bin/clip-sync \
https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-linux-arm64
https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-linux-arm64
chmod +x ~/.local/bin/clip-sync
```
@@ -78,7 +108,7 @@ source ~/.bashrc
```bash
clip-sync --version
# → clip-sync v0.1.0
# → clip-sync v0.2.0
```
**5. Installer comme service (systemd --user)**
@@ -103,12 +133,12 @@ systemctl --user status clip-sync
```bash
# Apple Silicon (M1/M2/M3)
curl -sSL -o ~/.local/bin/clip-sync \
https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-darwin-arm64
https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-darwin-arm64
chmod +x ~/.local/bin/clip-sync
# Intel Mac
curl -sSL -o ~/.local/bin/clip-sync \
https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-darwin-amd64
https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-darwin-amd64
chmod +x ~/.local/bin/clip-sync
```
@@ -123,7 +153,7 @@ source ~/.zshrc
```bash
clip-sync --version
# → clip-sync v0.1.0
# → clip-sync v0.2.0
```
**5. Installer comme service (launchd)**
@@ -168,12 +198,12 @@ launchctl list | grep clip-sync
```powershell
# amd64 (standard)
Invoke-WebRequest -Uri `
"https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-windows-amd64.exe" `
"https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-windows-amd64.exe" `
-OutFile "$env:USERPROFILE\bin\clip-sync.exe"
# arm64 (Surface Pro X, etc.)
Invoke-WebRequest -Uri `
"https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-windows-arm64.exe" `
"https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-windows-arm64.exe" `
-OutFile "$env:USERPROFILE\bin\clip-sync.exe"
```
@@ -192,7 +222,7 @@ Invoke-WebRequest -Uri `
```powershell
clip-sync --version
# → clip-sync v0.1.0
# → clip-sync v0.2.0
```
**5. Installer comme service Windows (NSSM)**
@@ -274,10 +304,22 @@ Copiez du texte sur la machine A (Ctrl+C), collez sur la machine B (Ctrl+V).
| Clé | Défaut | Description |
|-----|--------|-------------|
| `daemon.port` | `9137` | Port d'écoute HTTP |
| `daemon.port` | `9137` | Port d'écoute HTTP(S) |
| `daemon.poll_interval_ms` | `500` | Intervalle de scrutation du presse-papiers (ms) |
| `daemon.max_body_bytes` | `10485760` | Taille max d'une requête (10 MiB) |
| `daemon.history_size` | `50` | Nombre d'entrées conservées dans l'historique |
| `daemon.discovery` | `false` | Découverte automatique des pairs via mDNS |
| `daemon.notify` | `false` | Notifications desktop à chaque clip reçu |
| `daemon.sync_images` | `false` | Sync d'images (PNG) entre pairs |
| `daemon.receive_files` | `false` | Accepter les transferts de fichiers |
| `daemon.receive_dir` | `~/Downloads/clip-sync` | Dossier de réception des fichiers |
| `security.shared_key` | — | Clé partagée (`clip-sync --generate-key`) |
| `security.allowed_origins` | — | Liste des hôtes autorisés |
| `security.tls` | `false` | Chiffrement TLS entre pairs |
| `security.cert_file` / `key_file` | `~/.config/clip-sync/{cert,key}.pem` | Certificat auto-signé |
| `peers[].name` | — | Nom descriptif du pair |
| `peers[].addr` | — | Adresse `host:port` du pair |
| `peers[].tls` | `false` | TLS pour ce pair (override global) |
### Variables d'environnement (override)
@@ -285,6 +327,43 @@ Copiez du texte sur la machine A (Ctrl+C), collez sur la machine B (Ctrl+V).
|----------|-----------------|
| `CLIP_SYNC_PORT` | `daemon.port` |
| `CLIP_SYNC_POLL_MS` | `daemon.poll_interval_ms` |
| `CLIP_SYNC_KEY` | `security.shared_key` |
| `CLIP_SYNC_MAX_BODY_BYTES` | `daemon.max_body_bytes` |
## Commandes
```bash
clip-sync # lancer le daemon
clip-sync --config <path> # config personnalisée
clip-sync --version # afficher la version
clip-sync history # afficher l'historique local
clip-sync --one-shot [text] # envoyer le presse-papiers (ou text) une fois
clip-sync send-file <path> # envoyer un fichier aux pairs
clip-sync --generate-key # générer une clé partagée
clip-sync --generate-cert # générer un certificat TLS auto-signé
```
## Sécurité
Sur un réseau partagé, activez au minimum une clé partagée :
```bash
clip-sync --generate-key # → copier la clé sur toutes les machines
```
```toml
[security]
shared_key = "LA_CLÉ_GÉNÉRÉE"
```
Pour chiffrer le trafic, générez un certificat sur une machine, copiez les
fichiers `cert.pem`/`key.pem` vers toutes les autres, puis :
```toml
[security]
tls = true
insecure_skip_verify = true # confiance au certificat auto-signé partagé
```
## Déboguer
@@ -295,6 +374,81 @@ curl -X POST http://192.168.1.20:9137/clip \
-d '{"text":"test","ts":0,"origin":"debug"}'
```
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
```
## Validation manuelle — notifications Windows
Le code des notifications Windows (bulle via `Shell_NotifyIconW`) compile, mais
n'a pas été validé sur un vrai poste. Voici exactement quoi tester :
1. **Installer et lancer** : sur Windows, `irm …/install.ps1 | iex`, ou `go build`
puis exécuter `clip-sync`.
2. **Activer les notifications** dans `~/.config/clip-sync/peers.toml` :
```toml
[daemon]
notify = true
```
3. **Envoyer un clip** depuis une autre machine du LAN, ou en local :
```powershell
curl -X POST http://127.0.0.1:9137/clip -H "Content-Type: application/json" `
-d '{"text":"test notification","ts":1700000000000000000,"origin":"autre-machine"}'
```
4. **Vérifier** qu'une bulle intitulée `clip-sync` apparaît dans la zone de
notification (coin bas-droit), avec le message « Clip reçu de autre-machine ».
5. **Tester une image** : activer `sync_images = true`, envoyer une image
(copier/coller d'une autre machine), vérifier la bulle « Image reçue de … ».
6. **Vérifier l'icône** : une icône clip-sync doit rester dans la barre des
tâches pendant que le daemon tourne (comportement attendu de `Shell_NotifyIcon`).
7. **Robustesse** : envoyer plusieurs clips rapprochés — pas de crash, bulles successives.
8. **Noter la différence Windows 10 vs 11** : bulle classique vs conversion en toast
(selon les réglages système), et signaler tout écart observé.
## Signer les releases (GPG)
La CI signe automatiquement les binaires si une clé est configurée. Étapes :
1. **Générer une clé** (si absente) :
```bash
gpg --full-generate-key
# choisir RSA/RSA 4096, saisir nom + email, et une passphrase
```
2. **Récupérer l'ID de la clé** (la partie après `rsa4096/`) :
```bash
gpg --list-secret-keys --keyid-format LONG
# sec rsa4096/AAAAAAAAAAAAAAAA 2026-08-14 [SC]
```
3. **Exporter la clé privée** (bloc armoré) :
```bash
gpg --armor --export-secret-keys AAAAAAAAAAAAAAAA
```
4. **Ajouter les secrets Gitea** : dépôt → *Settings* → *Actions* → *Secrets* :
- `GPG_PRIVATE_KEY` = le contenu exporté (le bloc `BEGIN PGP PRIVATE KEY BLOCK`)
- `GPG_PASSPHRASE` = la passphrase (seulement si la clé en a une)
5. **Publier une release** :
```bash
git tag v0.2.0
git push origin v0.2.0
```
La CI importe la clé, signe chaque binaire et attache les fichiers `.asc`.
6. **Vérifier** une signature :
```bash
gpg --verify clip-sync-linux-amd64.asc
```
(nécessite la clé publique : `gpg --recv-keys AAAAAAAAAAAAAAAA`, ou publiez-la
sur un serveur de clés / dans le README).
## Licence
MIT © 2026
+25 -20
View File
@@ -1,6 +1,6 @@
# ROADMAP.md — clip-sync
## v0.1.0 ✅ MVP — Sync texte bidirectionnel (actuelle)
## v0.1.0 ✅ MVP — Sync texte bidirectionnel (réalisée)
- [x] Synchronisation texte entre pairs LAN
- [x] Anti-boucle (hash + timestamp + origine)
@@ -10,27 +10,32 @@
- [x] Build cross-plateforme
- [x] Service systemd --user
## v0.2.0 🔒 Sécurité
## v0.2.0 ✅ Sécurité (réalisée)
- [ ] 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
- [x] Authentification par clé partagée (header `Authorization: Bearer <key>`)
- [x] Validation de l'origine (rejet des pairs inconnus)
- [x] Option `--generate-key` pour générer une clé partagée
- [x] Config : section `[security]` dans peers.toml
- [x] Chiffrement TLS entre pairs (certificats auto-signés + `--generate-cert`)
- [x] Limite de taille des requêtes (`daemon.max_body_bytes`)
- [x] Flag `--config <path>`
## v0.3.0 📎 Contenu enrichi
## v0.3.0 📎 Contenu enrichi (partiellement réalisée)
- [ ] 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
- [x] Transfert de fichiers (`clip-sync send-file`, payload Base64, taille max configurable)
- [x] Historique du presse-papiers local (derniers N éléments)
- [x] Commande `clip-sync history` pour afficher l'historique
- [x] Option `--one-shot` : envoi unique sans daemon
- [x] Images copiées/collées via le presse-papiers (PNG) sur Linux, Windows et
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
## v1.0.0 🌐 Stable (réalisée)
- [ ] 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
- [x] Découverte automatique des pairs via mDNS (`daemon.discovery`)
- [x] Interface web locale de monitoring (`http://localhost:9137/`)
- [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`)
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>clip-sync — documentation</title>
<style>
:root {
--bg: #ffffff; --fg: #1a202c; --muted: #64748b;
--accent: #0969da; --code-bg: #f6f8fa; --border: #d0d7de;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); line-height: 1.6; }
header { background: #0d1117; color: #fff; padding: 3rem 1rem; }
header .wrap, main, footer { max-width: 860px; margin: 0 auto; padding: 0 1rem; }
header h1 { margin: 0 0 .5rem; font-size: 2rem; }
header p { margin: 0; color: #8b949e; }
main { padding-bottom: 3rem; }
h2 { border-bottom: 1px solid var(--border); padding-bottom: .3rem; margin-top: 2.5rem; }
code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 4px; padding: .1rem .35rem; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: .9em; }
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: .8rem 1rem; overflow-x: auto; }
pre code { background: none; border: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
th, td { border: 1px solid var(--border); padding: .4rem .6rem; text-align: left; font-size: .92rem; }
th { background: var(--code-bg); }
.badge { display: inline-block; background: var(--accent); color: #fff; border-radius: 4px; padding: .1rem .5rem; font-size: .75rem; font-weight: 600; }
footer { color: var(--muted); font-size: .85rem; padding: 1rem; border-top: 1px solid var(--border); }
a { color: var(--accent); }
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>clip-sync</h1>
<p>Daemon de synchronisation bidirectionnelle du presse-papiers sur réseau local. Copier sur une machine → coller sur une autre.</p>
</div>
</header>
<main>
<h2>Installation en une ligne</h2>
<p>Rapide, sans dépendance à compiler : un binaire unique téléchargé puis exécutable.</p>
<h3>Linux / macOS</h3>
<pre><code>curl -fsSL https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.sh | bash</code></pre>
<h3>Windows (PowerShell)</h3>
<pre><code>irm https://git.dracodev.net/Projets/clip-sync/raw/branch/main/install.ps1 | iex</code></pre>
<p>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 : <code>CLIP_SYNC_VERSION=v0.2.0</code> (Linux/macOS) ou
<code>$env:CLIP_SYNC_VERSION='v0.2.0'</code> (Windows).</p>
<h2>Démarrage rapide</h2>
<ol>
<li>Éditez <code>~/.config/clip-sync/peers.toml</code> et ajoutez vos machines (voir ci-dessous).</li>
<li>Lancez <code>clip-sync</code> sur chaque machine.</li>
<li>Copiez du texte ou une image (Ctrl+C), collez sur l'autre machine (Ctrl+V).</li>
</ol>
<h3>Configuration minimale</h3>
<pre><code># ~/.config/clip-sync/peers.toml
[daemon]
port = 9137
poll_interval_ms = 500
[[peers]]
name = "portable"
addr = "192.168.1.20:9137"</code></pre>
<p>Sur l'autre machine, inversez l'adresse. Vous pouvez aussi activer la
découverte automatique (<code>discovery = true</code>) pour ne plus configurer
d'adresse du tout.</p>
<h2>Commandes</h2>
<table>
<tr><th>Commande</th><th>Description</th></tr>
<tr><td><code>clip-sync</code></td><td>Lancer le daemon</td></tr>
<tr><td><code>clip-sync --config &lt;path&gt;</code></td><td>Config personnalisée</td></tr>
<tr><td><code>clip-sync --version</code></td><td>Afficher la version</td></tr>
<tr><td><code>clip-sync history</code></td><td>Afficher l'historique local</td></tr>
<tr><td><code>clip-sync --one-shot [text]</code></td><td>Envoyer le presse-papiers (ou texte) une fois</td></tr>
<tr><td><code>clip-sync send-file &lt;path&gt;</code></td><td>Envoyer un fichier aux pairs</td></tr>
<tr><td><code>clip-sync --generate-key</code></td><td>Générer une clé partagée</td></tr>
<tr><td><code>clip-sync --generate-cert</code></td><td>Générer un certificat TLS auto-signé</td></tr>
</table>
<h2>Configuration</h2>
<table>
<tr><th>Clé</th><th>Défaut</th><th>Description</th></tr>
<tr><td><code>daemon.port</code></td><td><code>9137</code></td><td>Port d'écoute HTTP(S)</td></tr>
<tr><td><code>daemon.poll_interval_ms</code></td><td><code>500</code></td><td>Intervalle de scrutation (ms)</td></tr>
<tr><td><code>daemon.max_body_bytes</code></td><td><code>10485760</code></td><td>Taille max d'une requête</td></tr>
<tr><td><code>daemon.history_size</code></td><td><code>50</code></td><td>Entrées d'historique conservées</td></tr>
<tr><td><code>daemon.discovery</code></td><td><code>false</code></td><td>Découverte mDNS des pairs</td></tr>
<tr><td><code>daemon.notify</code></td><td><code>false</code></td><td>Notifications desktop</td></tr>
<tr><td><code>daemon.sync_images</code></td><td><code>false</code></td><td>Sync d'images (PNG)</td></tr>
<tr><td><code>daemon.receive_files</code></td><td><code>false</code></td><td>Accepter les transferts de fichiers</td></tr>
<tr><td><code>security.shared_key</code></td><td>—</td><td>Clé partagée (Bearer token)</td></tr>
<tr><td><code>security.allowed_origins</code></td><td>—</td><td>Hôtes autorisés</td></tr>
<tr><td><code>security.tls</code></td><td><code>false</code></td><td>Chiffrement TLS entre pairs</td></tr>
</table>
<p>Variables d'environnement : <code>CLIP_SYNC_PORT</code>,
<code>CLIP_SYNC_POLL_MS</code>, <code>CLIP_SYNC_KEY</code>,
<code>CLIP_SYNC_MAX_BODY_BYTES</code>.</p>
<h2>Sécurité</h2>
<p>Sur un réseau partagé, activez au minimum une clé partagée :</p>
<pre><code>clip-sync --generate-key # copier la clé sur toutes les machines</code></pre>
<pre><code>[security]
shared_key = "LA_CLÉ_GÉNÉRÉE"</code></pre>
<p>Pour chiffrer le trafic, générez un certificat (<code>clip-sync --generate-cert</code>),
copiez <code>cert.pem</code>/<code>key.pem</code> sur chaque machine, puis
<code>tls = true</code> et <code>insecure_skip_verify = true</code>.</p>
<h2>Fonctionnalités</h2>
<ul>
<li>Sync bidirectionnelle (texte + images PNG)</li>
<li>Anti-boucle (hash SHA-256 + timestamp + origine)</li>
<li>Clé partagée, validation d'origine, TLS optionnel</li>
<li>Découverte mDNS, historique, transfert de fichiers</li>
<li>Interface web locale : <code>http://localhost:9137/</code></li>
<li>Linux (X11/Wayland), Windows, macOS — binaire unique, zéro runtime</li>
</ul>
<h2>Service au démarrage</h2>
<p><strong>Linux (systemd)</strong> : voir le fichier <code>clip-sync.service</code>.
<strong>Windows</strong> : utiliser NSSM. <strong>macOS</strong> : un LaunchAgent
(voir le README pour les détails complets).</p>
</main>
<footer>
<div class="wrap">clip-sync — MIT · <a href="https://git.dracodev.net/Projets/clip-sync">Code source</a></div>
</footer>
</body>
</html>
+13 -1
View File
@@ -2,4 +2,16 @@ module git.dracodev.net/Projets/clip-sync
go 1.21
require github.com/BurntSushi/toml v1.3.2
require (
github.com/BurntSushi/toml v1.3.2
github.com/hashicorp/mdns v1.0.6
)
require (
github.com/miekg/dns v1.1.55 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
)
+80
View File
@@ -1,2 +1,82 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+101
View File
@@ -0,0 +1,101 @@
# 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 { 'latest' }
$Repo = 'https://git.dracodev.net/Projets/clip-sync'
$ApiBase = 'https://git.dracodev.net/api/v1/repos/Projets/clip-sync'
# ── Resolve version ───────────────────────────────────────────────────
if ($Version -eq 'latest') {
try {
$release = Invoke-RestMethod "$ApiBase/releases/latest" -UseBasicParsing
$Version = $release.tag_name
if (-not $Version) { throw 'no tag_name' }
} catch {
throw "clip-sync: no release found. Publish one (git tag v0.2.0 && git push origin v0.2.0), or build from source: git clone $Repo && cd clip-sync && make install"
}
}
# ── 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"
+34 -13
View File
@@ -8,12 +8,27 @@
set -euo pipefail
REPO="https://git.dracodev.net/Projets/clip-sync"
API_BASE="https://git.dracodev.net/api/v1/repos/Projets/clip-sync"
VERSION="${CLIP_SYNC_VERSION:-latest}"
BINARY="clip-sync"
INSTALL_DIR="${HOME}/.local/bin"
CONFIG_DIR="${HOME}/.config/clip-sync"
CONFIG_FILE="${CONFIG_DIR}/peers.toml"
# ── Resolve version ────────────────────────────────────────────────────
resolve_version() {
if [ "$VERSION" = "latest" ]; then
VERSION="$(curl -fsSL "${API_BASE}/releases/latest" \
| sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' | head -n1)"
if [ -z "$VERSION" ]; then
echo "clip-sync: no release found. Publish one (git tag v0.2.0 && git push origin v0.2.0)" >&2
echo " or build from source: git clone ${REPO} && cd clip-sync && make install" >&2
exit 1
fi
fi
}
# ── Detect platform ────────────────────────────────────────────────────
detect_platform() {
@@ -46,14 +61,7 @@ detect_platform() {
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
download_url="${REPO}/releases/download/${VERSION}/${bin_name}"
fi
local download_url="${REPO}/releases/download/${VERSION}/${bin_name}"
# Windows binary has .exe extension
if [[ "$platform" == windows-* ]]; then
@@ -61,16 +69,18 @@ download_binary() {
download_url="${download_url}.exe"
fi
local out_path="${INSTALL_DIR}/${bin_name}"
echo "clip-sync: downloading ${download_url}..."
if command -v curl &>/dev/null; then
curl -fSL --progress-bar -o "${INSTALL_DIR}/${BINARY}" "${download_url}" || {
curl -fSL --progress-bar -o "${out_path}" "${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}" || {
wget -q --show-progress -O "${out_path}" "${download_url}" || {
echo "clip-sync: download failed. Try building from source:"
echo " git clone ${REPO} && cd clip-sync && make install"
exit 1
@@ -80,8 +90,8 @@ download_binary() {
exit 1
fi
chmod +x "${INSTALL_DIR}/${BINARY}"
echo "clip-sync: installed to ${INSTALL_DIR}/${BINARY}"
chmod +x "${out_path}"
echo "clip-sync: installed to ${out_path}"
}
# ── Create default config ──────────────────────────────────────────────
@@ -100,10 +110,18 @@ create_config() {
# Environment overrides:
# CLIP_SYNC_PORT=9137 override daemon.port
# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms
# CLIP_SYNC_KEY=... override security.shared_key
[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]]
@@ -124,9 +142,12 @@ main() {
mkdir -p "${INSTALL_DIR}"
resolve_version
local platform
platform=$(detect_platform)
echo "clip-sync: detected platform: ${platform}"
echo "clip-sync: version: ${VERSION}"
download_binary "${platform}"
create_config
+18
View File
@@ -8,6 +8,12 @@
// Each platform file defines its own New() constructor using //go:build tags.
package clipboard
import "errors"
// ErrNoImage is returned by ImageClipboard.ReadImage when the clipboard holds
// no image data. It is a normal condition, not an error.
var ErrNoImage = errors.New("clipboard: no image")
// Clipboard is the cross-platform clipboard interface.
type Clipboard interface {
// Read returns the current text content of the clipboard.
@@ -17,3 +23,15 @@ type Clipboard interface {
// Write sets the clipboard text content.
Write(text string) error
}
// ImageClipboard is an optional interface implemented by platforms that can
// read and write image data from the clipboard. Use a type assertion to detect
// support: `if ic, ok := cb.(clipboard.ImageClipboard); ok { ... }`.
type ImageClipboard interface {
// ReadImage returns the MIME type and raw bytes of the clipboard image.
// It returns ErrNoImage if the clipboard holds no image.
ReadImage() (mime string, data []byte, err error)
// WriteImage sets the clipboard image content.
WriteImage(mime string, data []byte) error
}
+53
View File
@@ -4,6 +4,7 @@ package clipboard
import (
"fmt"
"os"
"os/exec"
"strings"
)
@@ -43,3 +44,55 @@ func (c *darwinClipboard) Write(text string) error {
}
return nil
}
// ReadImage reads a PNG image from the clipboard via AppleScript, writing it to
// a temporary file and reading it back.
func (c *darwinClipboard) ReadImage() (string, []byte, error) {
tmp, err := os.CreateTemp("", "clip-sync-*.png")
if err != nil {
return "", nil, err
}
tmpPath := tmp.Name()
tmp.Close()
defer os.Remove(tmpPath)
// AppleScript type code PNGf, wrapped in the chevron quotes AppleScript uses.
script := `set f to (open for access POSIX file "` + tmpPath + `" with write permission)
set eof f to 0
write (the clipboard as «class PNGf») to f
close access f`
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
return "", nil, ErrNoImage
}
data, err := os.ReadFile(tmpPath)
if err != nil || len(data) == 0 {
return "", nil, ErrNoImage
}
return "image/png", data, nil
}
// WriteImage writes a PNG image to the clipboard via AppleScript.
func (c *darwinClipboard) WriteImage(mime string, data []byte) error {
if mime != "" && mime != "image/png" {
return fmt.Errorf("clipboard: unsupported image mime %q (only PNG)", mime)
}
tmp, err := os.CreateTemp("", "clip-sync-*.png")
if err != nil {
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
tmpPath := tmp.Name()
tmp.Close()
defer os.Remove(tmpPath)
script := `set the clipboard to (read (POSIX file "` + tmpPath + `") as «class PNGf»)`
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
return fmt.Errorf("clipboard write image (osascript): %w", err)
}
return nil
}
+35
View File
@@ -3,6 +3,7 @@
package clipboard
import (
"bytes"
"fmt"
"os"
"os/exec"
@@ -70,3 +71,37 @@ func (c *linuxClipboard) Write(text string) error {
}
return nil
}
// ReadImage returns the PNG image from the clipboard, if any.
func (c *linuxClipboard) ReadImage() (string, []byte, error) {
var args []string
if c.pasteCmd == "wl-paste" {
args = []string{"--type", "image/png"}
} else {
args = []string{"-selection", "clipboard", "-t", "image/png", "-o"}
}
cmd := exec.Command(c.pasteCmd, args...)
out, err := cmd.Output()
if err != nil || len(out) == 0 {
return "", nil, ErrNoImage
}
return "image/png", out, nil
}
// WriteImage writes image bytes to the clipboard using the given MIME type.
func (c *linuxClipboard) WriteImage(mime string, data []byte) error {
var args []string
if c.copyCmd == "wl-copy" {
args = []string{"--type", mime}
} else {
args = []string{"-selection", "clipboard", "-t", mime}
}
cmd := exec.Command(c.copyCmd, args...)
cmd.Stdin = bytes.NewReader(data)
if err := cmd.Run(); err != nil {
return fmt.Errorf("clipboard write image (%s): %w", c.copyCmd, err)
}
return nil
}
+94 -5
View File
@@ -24,6 +24,7 @@ var (
procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procGlobalFree = kernel32.NewProc("GlobalFree")
procGlobalSize = kernel32.NewProc("GlobalSize")
)
type windowsClipboard struct{}
@@ -52,9 +53,16 @@ func (c *windowsClipboard) Read() (string, error) {
defer func() { _, _, _ = procGlobalUnlock.Call(h) }()
// Query the actual size of the global memory block so we never read past it.
size, _, _ := procGlobalSize.Call(h)
if size == 0 {
return "", nil
}
units := size / 2 // bytes → UTF-16 code units
// Lock and read the global memory block.
// Use a helper to keep the uintptr→unsafe.Pointer conversion close to the syscall.
mem := lockAndRead(h)
mem := lockAndRead(h, units)
if mem == nil {
return "", fmt.Errorf("clipboard read: GlobalLock failed")
}
@@ -103,9 +111,10 @@ func (c *windowsClipboard) Write(text string) error {
return nil
}
// lockAndRead locks a global memory handle and returns its UTF-16 data.
// Returns nil on failure. Caller must call GlobalUnlock after use.
func lockAndRead(h uintptr) []uint16 {
// lockAndRead locks a global memory handle and returns its UTF-16 data of the
// given length (in code units). Returns nil on failure. Caller must call
// GlobalUnlock after use.
func lockAndRead(h uintptr, units uintptr) []uint16 {
p, _, _ := procGlobalLock.Call(h)
if p == 0 {
return nil
@@ -113,7 +122,7 @@ func lockAndRead(h uintptr) []uint16 {
// Use unsafe.Add to derive a pointer from nil; the uintptr came from
// GlobalLock (GMEM_FIXED) and is a valid virtual address, not a Go pointer.
ptr := unsafe.Add(unsafe.Pointer(nil), p)
return unsafe.Slice((*uint16)(ptr), 1<<20)
return unsafe.Slice((*uint16)(ptr), units)
}
// lockAndWrite locks a global memory handle and copies UTF-16 data into it.
@@ -128,3 +137,83 @@ func lockAndWrite(h uintptr, data []uint16) bool {
copy(dst, data)
return true
}
// ReadImage returns the clipboard image (CF_DIB) encoded as PNG.
func (c *windowsClipboard) ReadImage() (string, []byte, error) {
r1, _, _ := procOpenClipboard.Call(0)
if r1 == 0 {
return "", nil, ErrNoImage
}
defer func() { _, _, _ = procCloseClipboard.Call() }()
h, _, _ := procGetClipboardData.Call(uintptr(cfDIB))
if h == 0 {
return "", nil, ErrNoImage
}
size, _, _ := procGlobalSize.Call(h)
if size == 0 {
return "", nil, ErrNoImage
}
p, _, _ := procGlobalLock.Call(h)
if p == 0 {
return "", nil, ErrNoImage
}
defer func() { _, _, _ = procGlobalUnlock.Call(h) }()
// Copy the DIB bytes out of the global block before parsing.
ptr := unsafe.Add(unsafe.Pointer(nil), p)
raw := unsafe.Slice((*byte)(ptr), size)
dib := make([]byte, size)
copy(dib, raw)
pngBytes, err := dibToPNG(dib)
if err != nil {
return "", nil, err
}
return "image/png", pngBytes, nil
}
// WriteImage writes a PNG image to the clipboard as a CF_DIB.
func (c *windowsClipboard) WriteImage(mime string, data []byte) error {
if mime != "" && mime != "image/png" {
return fmt.Errorf("clipboard: unsupported image mime %q (only PNG)", mime)
}
dib, err := pngToDIB(data)
if err != nil {
return fmt.Errorf("clipboard: decode image: %w", err)
}
r1, _, err2 := procOpenClipboard.Call(0)
if r1 == 0 {
return fmt.Errorf("clipboard write image: OpenClipboard: %w", err2)
}
defer func() { _, _, _ = procCloseClipboard.Call() }()
if r1, _, err2 = procEmptyClipboard.Call(); r1 == 0 {
return fmt.Errorf("clipboard write image: EmptyClipboard: %w", err2)
}
h, _, err2 := procGlobalAlloc.Call(0, uintptr(len(dib)))
if h == 0 {
return fmt.Errorf("clipboard write image: GlobalAlloc: %w", err2)
}
p, _, _ := procGlobalLock.Call(h)
if p == 0 {
_, _, _ = procGlobalFree.Call(h)
return fmt.Errorf("clipboard write image: GlobalLock failed")
}
ptr := unsafe.Add(unsafe.Pointer(nil), p)
dst := unsafe.Slice((*byte)(ptr), len(dib))
copy(dst, dib)
_, _, _ = procGlobalUnlock.Call(h)
if r1, _, err2 = procSetClipboardData.Call(uintptr(cfDIB), h); r1 == 0 {
_, _, _ = procGlobalFree.Call(h)
return fmt.Errorf("clipboard write image: SetClipboardData: %w", err2)
}
// Windows now owns the handle.
return nil
}
+118
View File
@@ -0,0 +1,118 @@
//go:build windows
package clipboard
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
)
const (
biRGB = 0
cfDIB = 8
dibHeaderSize = 40
)
// pngToDIB converts a PNG image to a 32bpp bottom-up BI_RGB DIB. The DIB layout
// is BITMAPINFOHEADER (40 bytes) followed by tightly packed BGRA rows, which is
// the form Windows expects for the CF_DIB clipboard format.
func pngToDIB(pngData []byte) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(pngData))
if err != nil {
return nil, err
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dib := make([]byte, dibHeaderSize+w*4*h)
le := binary.LittleEndian
le.PutUint32(dib[0:], dibHeaderSize) // biSize
le.PutUint32(dib[4:], uint32(w)) // biWidth
le.PutUint32(dib[8:], uint32(h)) // biHeight (positive = bottom-up)
le.PutUint16(dib[12:], 1) // biPlanes
le.PutUint16(dib[14:], 32) // biBitCount
le.PutUint32(dib[16:], biRGB) // biCompression
// biSizeImage (20) left 0 for BI_RGB; metrics/clrUsed/clrImportant left 0.
px := dib[dibHeaderSize:]
for y := 0; y < h; y++ {
srcY := h - 1 - y // bottom-up
row := px[y*w*4 : (y+1)*w*4]
for x := 0; x < w; x++ {
r, g, b_, a := img.At(b.Min.X+x, b.Min.Y+srcY).RGBA()
row[x*4+0] = uint8(b_ >> 8) // B
row[x*4+1] = uint8(g >> 8) // G
row[x*4+2] = uint8(r >> 8) // R
row[x*4+3] = uint8(a >> 8) // A
}
}
return dib, nil
}
// dibToPNG converts a BI_RGB DIB (32bpp or 24bpp) to a PNG image.
func dibToPNG(dib []byte) ([]byte, error) {
if len(dib) < dibHeaderSize {
return nil, ErrNoImage
}
le := binary.LittleEndian
w := int(int32(le.Uint32(dib[4:])))
h := int32(le.Uint32(dib[8:]))
bitCount := le.Uint16(dib[14:])
compression := le.Uint32(dib[16:])
if w <= 0 || h == 0 {
return nil, ErrNoImage
}
if compression != biRGB {
return nil, fmt.Errorf("clipboard: unsupported DIB compression %d", compression)
}
height := int(h)
if h < 0 {
height = -height
}
bottomUp := h > 0
img := image.NewRGBA(image.Rect(0, 0, w, height))
src := dib[dibHeaderSize:]
switch bitCount {
case 32:
stride := w * 4
for y := 0; y < height; y++ {
srcY := y
if bottomUp {
srcY = height - 1 - y
}
row := src[srcY*stride : (srcY+1)*stride]
for x := 0; x < w; x++ {
img.SetRGBA(x, y, color.RGBA{row[x*4+2], row[x*4+1], row[x*4+0], row[x*4+3]})
}
}
case 24:
stride := (w*3 + 3) &^ 3 // rows padded to 4-byte boundary
for y := 0; y < height; y++ {
srcY := y
if bottomUp {
srcY = height - 1 - y
}
row := src[srcY*stride : srcY*stride+w*3]
for x := 0; x < w; x++ {
img.SetRGBA(x, y, color.RGBA{row[x*3+2], row[x*3+1], row[x*3+0], 255})
}
}
default:
return nil, fmt.Errorf("clipboard: unsupported DIB bit count %d", bitCount)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+91
View File
@@ -0,0 +1,91 @@
//go:build windows
package clipboard
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
func TestDIBRoundTrip(t *testing.T) {
// Build a small 4x3 RGBA image with distinct corner colors.
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
img.Set(0, 0, color.RGBA{255, 0, 0, 255}) // red
img.Set(3, 0, color.RGBA{0, 255, 0, 255}) // green
img.Set(0, 2, color.RGBA{0, 0, 255, 255}) // blue
img.Set(3, 2, color.RGBA{255, 255, 0, 128}) // semi-transparent yellow
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png.Encode: %v", err)
}
pngData := buf.Bytes()
dib, err := pngToDIB(pngData)
if err != nil {
t.Fatalf("pngToDIB: %v", err)
}
got, err := dibToPNG(dib)
if err != nil {
t.Fatalf("dibToPNG: %v", err)
}
decoded, err := png.Decode(bytes.NewReader(got))
if err != nil {
t.Fatalf("decode round-trip: %v", err)
}
if decoded.Bounds() != img.Bounds() {
t.Errorf("bounds = %v, want %v", decoded.Bounds(), img.Bounds())
}
// Spot-check the corner colors survived the round-trip.
for _, tc := range []struct {
x, y int
want color.RGBA
}{
{0, 0, color.RGBA{255, 0, 0, 255}},
{3, 0, color.RGBA{0, 255, 0, 255}},
{0, 2, color.RGBA{0, 0, 255, 255}},
} {
r, g, b, a := decoded.At(tc.x, tc.y).RGBA()
got := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
if got != tc.want {
t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
}
}
}
func TestDIBRejectsUnsupportedCompression(t *testing.T) {
dib := make([]byte, 44)
dib[14] = 32 // biBitCount
// biCompression = BI_RLE8 (1) at offset 16
dib[16] = 1
dib[4] = 1 // width 1
dib[8] = 1 // height 1
if _, err := dibToPNG(dib); err == nil {
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)
}
}
}
+116 -19
View File
@@ -1,10 +1,13 @@
// 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
// Config file location: ~/.config/clip-sync/peers.toml (overridable with
// the --config flag). Environment overrides:
//
// CLIP_SYNC_PORT — override daemon.port
// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms
// CLIP_SYNC_KEY — override security.shared_key
// CLIP_SYNC_MAX_BODY_BYTES — override daemon.max_body_bytes
package config
import (
@@ -19,59 +22,119 @@ import (
// Config represents the full clip-sync configuration.
type Config struct {
Daemon DaemonConfig `toml:"daemon"`
Peers []PeerConfig `toml:"peers"`
Daemon DaemonConfig `toml:"daemon"`
Security SecurityConfig `toml:"security"`
Peers []PeerConfig `toml:"peers"`
}
// DaemonConfig holds daemon-level settings.
type DaemonConfig struct {
Port int `toml:"port"`
PollIntervalMs int `toml:"poll_interval_ms"`
Port int `toml:"port"`
PollIntervalMs int `toml:"poll_interval_ms"`
MaxBodyBytes int64 `toml:"max_body_bytes"`
HistorySize int `toml:"history_size"`
Discovery bool `toml:"discovery"`
Notify bool `toml:"notify"`
SyncImages bool `toml:"sync_images"`
ReceiveFiles bool `toml:"receive_files"`
ReceiveDir string `toml:"receive_dir"`
}
// SecurityConfig holds peer authentication and transport security settings.
type SecurityConfig struct {
SharedKey string `toml:"shared_key"`
AllowedOrigins []string `toml:"allowed_origins"`
TLS bool `toml:"tls"`
CertFile string `toml:"cert_file"`
KeyFile string `toml:"key_file"`
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
}
// PeerConfig represents one remote clip-sync peer.
type PeerConfig struct {
Name string `toml:"name"`
Addr string `toml:"addr"`
TLS bool `toml:"tls"` // per-peer override of security.tls
}
// Default values.
const (
DefaultPort = 9137
DefaultPollIntervalMs = 500
DefaultMaxBodyBytes = 10 << 20 // 10 MiB
DefaultHistorySize = 50
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) {
// DefaultConfigPath returns the default config file path (~/.config/clip-sync/peers.toml).
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("config: cannot find home directory: %w", err)
return "", fmt.Errorf("config: cannot find home directory: %w", err)
}
return filepath.Join(home, ConfigDir, ConfigFile), nil
}
cfgPath := filepath.Join(home, ConfigDir, ConfigFile)
// DefaultCertFile returns the default TLS certificate path.
func DefaultCertFile() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: cannot find home directory: %w", err)
}
return filepath.Join(home, ConfigDir, "cert.pem"), nil
}
// DefaultKeyFile returns the default TLS private key path.
func DefaultKeyFile() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: cannot find home directory: %w", err)
}
return filepath.Join(home, ConfigDir, "key.pem"), nil
}
// Load reads the config file from the default location and applies
// environment variable overrides.
func Load() (*Config, error) {
path, err := DefaultConfigPath()
if err != nil {
return nil, err
}
return LoadFrom(path)
}
// LoadFrom reads the config file at the given path and applies environment
// variable overrides. If the file does not exist, defaults are used.
func LoadFrom(path string) (*Config, error) {
cfg := &Config{
Daemon: DaemonConfig{
Port: DefaultPort,
PollIntervalMs: DefaultPollIntervalMs,
MaxBodyBytes: DefaultMaxBodyBytes,
HistorySize: DefaultHistorySize,
},
}
if _, err := toml.DecodeFile(cfgPath, cfg); err != nil {
if _, err := toml.DecodeFile(path, cfg); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("config: cannot parse %s: %w", cfgPath, err)
return nil, fmt.Errorf("config: cannot parse %s: %w", path, err)
}
// File doesn't exist — use defaults (no peers configured).
}
// Environment variable overrides.
if err := applyEnvOverrides(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func applyEnvOverrides(cfg *Config) error {
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)
return fmt.Errorf("config: invalid CLIP_SYNC_PORT: %w", err)
}
cfg.Daemon.Port = port
}
@@ -79,12 +142,24 @@ func Load() (*Config, error) {
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)
return fmt.Errorf("config: invalid CLIP_SYNC_POLL_MS: %w", err)
}
cfg.Daemon.PollIntervalMs = ms
}
return cfg, nil
if v := os.Getenv("CLIP_SYNC_KEY"); v != "" {
cfg.Security.SharedKey = v
}
if v := os.Getenv("CLIP_SYNC_MAX_BODY_BYTES"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("config: invalid CLIP_SYNC_MAX_BODY_BYTES: %w", err)
}
cfg.Daemon.MaxBodyBytes = n
}
return nil
}
// PollInterval returns the poll interval as a time.Duration.
@@ -96,3 +171,25 @@ func (c *Config) PollInterval() time.Duration {
func (c *Config) ListenAddr() string {
return fmt.Sprintf(":%d", c.Daemon.Port)
}
// MaxBodyBytes returns the effective request body size limit, clamped to a
// sane minimum so a zero/negative config value never disables the limit.
func (c *Config) MaxBodyBytes() int64 {
if c.Daemon.MaxBodyBytes > 0 {
return c.Daemon.MaxBodyBytes
}
return DefaultMaxBodyBytes
}
// ReceiveDir returns the directory where received files are written, resolving
// the default (~/Downloads/clip-sync) when not configured.
func (c *Config) ReceiveDir() string {
if c.Daemon.ReceiveDir != "" {
return c.Daemon.ReceiveDir
}
home, err := os.UserHomeDir()
if err != nil {
return "clip-sync-received"
}
return filepath.Join(home, "Downloads", "clip-sync")
}
+97
View File
@@ -125,3 +125,100 @@ func TestListenAddr(t *testing.T) {
t.Errorf("ListenAddr = %q, want \":9137\"", addr)
}
}
func TestLoadFullConfig(t *testing.T) {
tmp := t.TempDir()
cfgDir := filepath.Join(tmp, ConfigDir)
if err := os.MkdirAll(cfgDir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
content := `
[daemon]
port = 9999
poll_interval_ms = 250
max_body_bytes = 2097152
history_size = 100
discovery = true
notify = true
sync_images = true
receive_files = true
receive_dir = "/tmp/received"
[security]
shared_key = "secret-key"
allowed_origins = ["host-a", "host-b"]
tls = true
cert_file = "/tmp/cert.pem"
key_file = "/tmp/key.pem"
insecure_skip_verify = true
[[peers]]
name = "bureau"
addr = "192.168.1.10:9137"
tls = true
`
if err := os.WriteFile(filepath.Join(cfgDir, ConfigFile), []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Daemon.MaxBodyBytes != 2097152 {
t.Errorf("max_body_bytes = %d, want 2097152", cfg.Daemon.MaxBodyBytes)
}
if cfg.Daemon.HistorySize != 100 {
t.Errorf("history_size = %d, want 100", cfg.Daemon.HistorySize)
}
if !cfg.Daemon.Discovery || !cfg.Daemon.Notify || !cfg.Daemon.SyncImages || !cfg.Daemon.ReceiveFiles {
t.Errorf("boolean flags not parsed: %+v", cfg.Daemon)
}
if cfg.Daemon.ReceiveDir != "/tmp/received" {
t.Errorf("receive_dir = %q", cfg.Daemon.ReceiveDir)
}
if cfg.Security.SharedKey != "secret-key" {
t.Errorf("shared_key = %q", cfg.Security.SharedKey)
}
if len(cfg.Security.AllowedOrigins) != 2 {
t.Errorf("allowed_origins = %v, want 2", cfg.Security.AllowedOrigins)
}
if !cfg.Security.TLS || !cfg.Security.InsecureSkipVerify {
t.Errorf("security flags not parsed: %+v", cfg.Security)
}
if len(cfg.Peers) != 1 || !cfg.Peers[0].TLS {
t.Errorf("peer TLS override not parsed: %+v", cfg.Peers)
}
}
func TestMaxBodyBytesDefault(t *testing.T) {
cfg := &Config{}
if got := cfg.MaxBodyBytes(); got != DefaultMaxBodyBytes {
t.Errorf("MaxBodyBytes default = %d, want %d", got, DefaultMaxBodyBytes)
}
cfg.Daemon.MaxBodyBytes = 100
if got := cfg.MaxBodyBytes(); got != 100 {
t.Errorf("MaxBodyBytes = %d, want 100", got)
}
}
func TestReceiveDirDefault(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp)
cfg := &Config{}
got := cfg.ReceiveDir()
if got != filepath.Join(tmp, "Downloads", "clip-sync") {
t.Errorf("ReceiveDir default = %q, want %q", got, filepath.Join(tmp, "Downloads", "clip-sync"))
}
cfg.Daemon.ReceiveDir = "/custom"
if cfg.ReceiveDir() != "/custom" {
t.Errorf("ReceiveDir override = %q, want \"/custom\"", cfg.ReceiveDir())
}
}
+128 -31
View File
@@ -1,9 +1,12 @@
// Package daemon orchestrates the main clip-sync loop: poll the local clipboard,
// broadcast changes to peers, and receive incoming clips via the HTTP server.
// broadcast changes to peers, and receive incoming clips via the HTTP(S) server.
package daemon
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"log"
"os"
"sync"
@@ -12,17 +15,23 @@ import (
"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/discovery"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/notify"
"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
cfg *config.Config
clipboard clipboard.Clipboard
filter *dedup.Filter
server *server.Server
broadcaster *peer.Broadcaster
history *history.Store
origin string
discovery discovery.Discovery
}
// New creates a Daemon from the given configuration.
@@ -32,16 +41,26 @@ func New(cfg *config.Config) (*Daemon, error) {
return nil, err
}
return newWithClipboard(cfg, cb)
}
origin, err := os.Hostname()
if err != nil {
return nil, err
}
// 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, "")
// History is best-effort: if we cannot resolve a path, run without it.
hist := (*history.Store)(nil)
if p, err := history.DefaultPath(); err == nil {
hist = history.New(p, cfg.Daemon.HistorySize)
}
return newDaemon(cfg, cb, origin, hist)
}
// newWithClipboardOrigin creates a Daemon with a pre-existing clipboard and explicit origin.
func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin string) (*Daemon, error) {
return newDaemon(cfg, cb, origin, nil)
}
func newDaemon(cfg *config.Config, cb clipboard.Clipboard, origin string, hist *history.Store) (*Daemon, error) {
if origin == "" {
var err error
origin, err = os.Hostname()
@@ -51,39 +70,91 @@ func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin s
}
filter := dedup.NewFilter(origin)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin)
srv := server.New(cfg.ListenAddr(), cb, filter)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
return &Daemon{
var notifier notify.Notifier
if cfg.Daemon.Notify {
notifier = notify.New()
}
srv := server.New(server.Options{
Addr: cfg.ListenAddr(),
SharedKey: cfg.Security.SharedKey,
AllowedOrigins: cfg.Security.AllowedOrigins,
MaxBodyBytes: cfg.MaxBodyBytes(),
History: hist,
Notifier: notifier,
ReceiveFiles: cfg.Daemon.ReceiveFiles,
ReceiveDir: cfg.ReceiveDir(),
}, cb, filter)
d := &Daemon{
cfg: cfg,
clipboard: cb,
filter: filter,
server: srv,
broadcaster: broadcaster,
}, nil
history: hist,
origin: origin,
}
if cfg.Daemon.Discovery {
d.discovery = discovery.New(origin, cfg.Daemon.Port, broadcaster)
}
return d, 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.
// Start the HTTP(S) 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)
var err error
if d.cfg.Security.TLS {
certFile, keyFile := d.cfg.Security.CertFile, d.cfg.Security.KeyFile
if certFile == "" {
certFile, _ = config.DefaultCertFile()
}
if keyFile == "" {
keyFile, _ = config.DefaultKeyFile()
}
log.Printf("clip-sync: listening on %s (TLS)", d.cfg.ListenAddr())
err = d.server.ListenAndServeTLS(certFile, keyFile)
} else {
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
err = d.server.ListenAndServe()
}
if err != nil && ctx.Err() == nil {
log.Printf("clip-sync: server error: %v", err)
}
}()
// Start mDNS discovery if enabled.
if d.discovery != nil {
if err := d.discovery.Start(); err != nil {
log.Printf("clip-sync: discovery: %v", err)
} else {
defer d.discovery.Stop()
}
}
// Main clipboard polling loop.
ticker := time.NewTicker(d.cfg.PollInterval())
defer ticker.Stop()
var lastText string
var lastImageHash string
// Optional image sync (Linux only today); requires platform support.
imageCB, _ := d.clipboard.(clipboard.ImageClipboard)
log.Printf("clip-sync: polling clipboard every %v, %d peer(s) configured",
d.cfg.PollInterval(), len(d.cfg.Peers))
@@ -99,22 +170,48 @@ func (d *Daemon) Run(ctx context.Context) error {
text, err := d.clipboard.Read()
if err != nil {
log.Printf("clip-sync: clipboard read: %v", err)
continue
} else if text != "" && text != lastText {
lastText = text
// Record our write so the filter can block echoes.
d.filter.MarkWritten(text)
if d.history != nil {
d.history.Append(history.Entry{Text: text, Origin: d.origin, Ts: time.Now().UnixNano()})
}
if len(d.cfg.Peers) > 0 || d.discovery != nil {
d.broadcaster.Broadcast(text)
}
}
if text == "" || text == lastText {
continue
}
// Image sync (opt-in).
if d.cfg.Daemon.SyncImages && imageCB != nil {
mime, data, err := imageCB.ReadImage()
if err != nil || len(data) == 0 {
continue
}
h := hashBytes(data)
if h == lastImageHash {
continue
}
lastImageHash = h
lastText = text
d.filter.MarkWrittenPayload(dedup.Payload{
Mime: mime,
Data: base64.StdEncoding.EncodeToString(data),
})
// 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)
if len(d.cfg.Peers) > 0 || d.discovery != nil {
d.broadcaster.BroadcastImage(mime, data)
}
}
}
}
}
// hashBytes returns a short hex digest of b, used to detect clipboard changes.
func hashBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
+41
View File
@@ -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")
}
}
+20 -7
View File
@@ -21,10 +21,10 @@ 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
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.
@@ -35,9 +35,12 @@ func NewFilter(origin string) *Filter {
}
}
// Payload represents an incoming clip payload from a peer.
// Payload represents an incoming clip payload from a peer. It carries either
// text (Text) or a base64-encoded image (Mime + Data).
type Payload struct {
Text string `json:"text"`
Mime string `json:"mime,omitempty"`
Data string `json:"data,omitempty"` // base64-encoded image bytes
Ts int64 `json:"ts"`
Origin string `json:"origin"`
}
@@ -58,7 +61,7 @@ func (f *Filter) ShouldIgnore(p Payload) bool {
}
// Guard 3: ignore content we recently wrote (hash match).
hash := hashText(p.Text)
hash := hashText(p.Text + "\x00" + p.Data)
for _, h := range f.cache {
if h == hash {
return true
@@ -71,11 +74,21 @@ func (f *Filter) ShouldIgnore(p Payload) bool {
// 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.markWritten(text)
}
// MarkWrittenPayload records that we just wrote a payload (text or image) to
// the local clipboard, so echoes of it are ignored.
func (f *Filter) MarkWrittenPayload(p Payload) {
f.markWritten(p.Text + "\x00" + p.Data)
}
func (f *Filter) markWritten(content string) {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now().UnixNano()
hash := hashText(text)
hash := hashText(content)
// Ring buffer: overwrite oldest entry when full.
if len(f.cache) < defaultCacheSize {
+16
View File
@@ -69,6 +69,22 @@ func TestShouldAcceptNewContent(t *testing.T) {
}
}
func TestShouldIgnoreDuplicateImageHash(t *testing.T) {
f := NewFilter("machine-a")
f.MarkWrittenPayload(Payload{Mime: "image/png", Data: "aGVsbG8="})
p := Payload{
Mime: "image/png",
Data: "aGVsbG8=",
Ts: time.Now().UnixNano(),
Origin: "machine-b",
}
if !f.ShouldIgnore(p) {
t.Error("should ignore image payload with recently written data hash")
}
}
func TestCacheEviction(t *testing.T) {
f := NewFilter("machine-a")
+185
View File
@@ -0,0 +1,185 @@
// Package discovery provides optional mDNS-based peer auto-discovery.
//
// Each instance advertises a `_clip-sync._tcp` service and periodically browses
// for other instances, adding them to (and removing them from) the broadcaster.
package discovery
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/hashicorp/mdns"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/peer"
)
const (
serviceName = "_clip-sync._tcp"
domain = "local"
)
// Discovery manages mDNS advertisement and browsing.
type Discovery interface {
Start() error
Stop()
}
type mdnsDiscovery struct {
origin string
port int
broadcaster *peer.Broadcaster
mu sync.Mutex
server *mdns.Server
stopCh chan struct{}
doneCh chan struct{}
peers map[string]string // addr -> host
}
// New creates an mDNS discovery component that updates the given broadcaster.
func New(origin string, port int, b *peer.Broadcaster) Discovery {
return &mdnsDiscovery{
origin: origin,
port: port,
broadcaster: b,
peers: make(map[string]string),
}
}
func (d *mdnsDiscovery) Start() error {
host, _ := os.Hostname()
instance := "clip-sync-" + sanitize(host)
// Advertise this instance. Zero-value domain/host/ips are inferred by the
// library from the operating system.
service, err := mdns.NewMDNSService(instance, serviceName, "", "", d.port, nil, nil)
if err != nil {
return err
}
server, err := mdns.NewServer(&mdns.Config{Zone: service})
if err != nil {
return err
}
d.mu.Lock()
d.server = server
d.stopCh = make(chan struct{})
d.doneCh = make(chan struct{})
d.mu.Unlock()
go d.browseLoop()
log.Printf("discovery: mDNS advertised as %q, browsing for peers", instance)
return nil
}
func (d *mdnsDiscovery) Stop() {
d.mu.Lock()
stopCh := d.stopCh
server := d.server
d.mu.Unlock()
if stopCh != nil {
close(stopCh)
}
if server != nil {
_ = server.Shutdown()
}
if d.doneCh != nil {
<-d.doneCh
}
}
func (d *mdnsDiscovery) browseLoop() {
defer close(d.doneCh)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
d.browse()
for {
select {
case <-d.stopCh:
return
case <-ticker.C:
d.browse()
}
}
}
func (d *mdnsDiscovery) browse() {
entries := make(chan *mdns.ServiceEntry, 16)
params := &mdns.QueryParam{
Service: serviceName,
Domain: domain,
Timeout: 5 * time.Second,
Entries: entries,
}
seen := make(map[string]string)
go func() {
_ = mdns.Query(params)
close(entries)
}()
for entry := range entries {
host := strings.TrimSuffix(entry.Host, ".local.")
host = strings.TrimSuffix(host, ".")
// Skip ourselves (and our sanitized advertisement form).
if host == d.origin || host == sanitize(d.origin) {
continue
}
ip := entry.AddrV4
if ip == nil {
ip = entry.AddrV6
}
if ip == nil {
continue
}
addr := net.JoinHostPort(ip.String(), itoa(entry.Port))
seen[addr] = host
d.broadcaster.AddPeer(config.PeerConfig{Name: host, Addr: addr})
}
// Remove peers that disappeared since the last browse.
d.mu.Lock()
for addr := range d.peers {
if _, ok := seen[addr]; !ok {
d.broadcaster.RemovePeer(addr)
}
}
d.peers = seen
d.mu.Unlock()
}
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
return r
default:
return '-'
}
}, s)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+103
View File
@@ -0,0 +1,103 @@
// Package history provides a persistent, size-bounded clipboard history store.
//
// Entries are kept in memory as a ring buffer and flushed to a JSON file so the
// `clip-sync history` command can read them even when the daemon is not running.
package history
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// Entry is a single clipboard history record.
type Entry struct {
Text string `json:"text"`
Origin string `json:"origin"`
Ts int64 `json:"ts"` // nanosecond timestamp
}
// Store is a thread-safe, persistent history store.
type Store struct {
mu sync.Mutex
path string
max int
entries []Entry
}
// New creates a history store that persists to the given file path, keeping at
// most max entries. If max <= 0, a sensible default is used.
func New(path string, max int) *Store {
if max <= 0 {
max = 50
}
s := &Store{path: path, max: max}
s.load()
return s
}
// Append adds an entry, trimming the store to max entries and persisting it.
func (s *Store) Append(e Entry) {
if e.Text == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// Skip exact duplicates of the most recent entry.
if len(s.entries) > 0 && s.entries[len(s.entries)-1].Text == e.Text {
return
}
s.entries = append(s.entries, e)
if len(s.entries) > s.max {
s.entries = s.entries[len(s.entries)-s.max:]
}
s.save()
}
// List returns a copy of the history, oldest first.
func (s *Store) List() []Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Entry, len(s.entries))
copy(out, s.entries)
return out
}
func (s *Store) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
_ = json.Unmarshal(data, &s.entries)
if len(s.entries) > s.max {
s.entries = s.entries[len(s.entries)-s.max:]
}
}
func (s *Store) save() {
data, err := json.MarshalIndent(s.entries, "", " ")
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
// DefaultPath returns the default history file location.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("history: cannot find home directory: %w", err)
}
return filepath.Join(home, ".config", "clip-sync", "history.json"), nil
}
+74
View File
@@ -0,0 +1,74 @@
package history
import (
"path/filepath"
"testing"
)
func TestAppendAndList(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 10)
s.Append(Entry{Text: "first", Origin: "a", Ts: 1})
s.Append(Entry{Text: "second", Origin: "b", Ts: 2})
got := s.List()
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].Text != "first" || got[1].Text != "second" {
t.Errorf("order wrong: %+v", got)
}
}
func TestAppendSkipsDuplicate(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 10)
s.Append(Entry{Text: "dup", Origin: "a", Ts: 1})
s.Append(Entry{Text: "dup", Origin: "a", Ts: 2})
if len(s.List()) != 1 {
t.Errorf("len = %d, want 1 (duplicate skipped)", len(s.List()))
}
}
func TestTrimToMax(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 3)
for i := 0; i < 10; i++ {
s.Append(Entry{Text: string(rune('a' + i)), Origin: "a", Ts: int64(i)})
}
got := s.List()
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0].Text != "h" {
t.Errorf("oldest kept = %q, want \"h\"", got[0].Text)
}
}
func TestPersistence(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 10)
s.Append(Entry{Text: "persisted", Origin: "a", Ts: 42})
// A new store reading the same path should reload the entry.
s2 := New(path, 10)
got := s2.List()
if len(got) != 1 || got[0].Text != "persisted" {
t.Errorf("reloaded = %+v, want persisted entry", got)
}
}
func TestDefaultPath(t *testing.T) {
p, err := DefaultPath()
if err != nil {
t.Fatalf("DefaultPath: %v", err)
}
if p == "" {
t.Error("DefaultPath returned empty string")
}
}
+11
View File
@@ -0,0 +1,11 @@
// Package notify provides best-effort desktop notifications.
//
// Each platform file defines its own implementation using //go:build tags:
// Linux uses notify-send, macOS uses osascript, Windows is a no-op (Windows
// toast notifications require a registered app or a third-party module).
package notify
// Notifier sends a desktop notification.
type Notifier interface {
Notify(title, body string) error
}
+29
View File
@@ -0,0 +1,29 @@
//go:build darwin
package notify
import "os/exec"
// Notifier uses osascript (Notification Center) on macOS.
type notifier struct{}
// New returns a macOS notifier using osascript.
func New() Notifier { return notifier{} }
func (notifier) Notify(title, body string) error {
script := "display notification " + quote(body) + " with title " + quote(title)
return exec.Command("osascript", "-e", script).Run()
}
func quote(s string) string {
out := `"`
for _, r := range s {
switch r {
case '\\', '"':
out += "\\" + string(r)
default:
out += string(r)
}
}
return out + `"`
}
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package notify
import "os/exec"
// Notifier uses notify-send (libnotify) on Linux.
type notifier struct{}
// New returns a Linux notifier using notify-send.
func New() Notifier { return notifier{} }
func (notifier) Notify(title, body string) error {
return exec.Command("notify-send", title, body).Run()
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !linux && !darwin && !windows
package notify
// Notifier is a no-op on unsupported platforms.
type notifier struct{}
// New returns a no-op notifier.
func New() Notifier { return notifier{} }
func (notifier) Notify(string, string) error { return nil }
+162
View File
@@ -0,0 +1,162 @@
//go:build windows
package notify
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 Windows notifier using Shell_NotifyIcon balloon tips.
func New() Notifier { return notifier{} }
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")
}
+181 -21
View File
@@ -1,68 +1,225 @@
// Package peer sends clipboard content to remote clip-sync peers via HTTP POST.
// Package peer sends clipboard content to remote clip-sync peers via HTTP(S) POST.
package peer
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"sync"
"time"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/transfer"
)
const clipPath = "/clip"
const (
clipPath = "/clip"
filePath = "/file"
)
// Options configures peer transport security.
type Options struct {
SharedKey string
TLS bool // global TLS default
CertFile string // CA (self-signed cert) to trust
InsecureSkipVerify bool
}
// Broadcaster sends clip payloads to all configured peers concurrently.
type Broadcaster struct {
mu sync.RWMutex
peers []config.PeerConfig
origin string
client *http.Client
opts Options
}
// NewBroadcaster creates a Broadcaster for the configured peer list.
func NewBroadcaster(peers []config.PeerConfig, origin string) *Broadcaster {
func NewBroadcaster(peers []config.PeerConfig, origin string, opts Options) *Broadcaster {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
}).DialContext,
MaxIdleConnsPerHost: 2,
TLSClientConfig: buildTLSConfig(opts),
},
}
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,
},
},
client: client,
opts: opts,
}
}
func buildTLSConfig(opts Options) *tls.Config {
if !opts.TLS {
return nil
}
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: opts.InsecureSkipVerify, // #nosec G402 — opt-in LAN trust
}
if !opts.InsecureSkipVerify && opts.CertFile != "" {
if pem, err := os.ReadFile(opts.CertFile); err == nil {
pool := x509.NewCertPool()
if pool.AppendCertsFromPEM(pem) {
cfg.RootCAs = pool
}
}
}
return cfg
}
// 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,
}
b.BroadcastPayload(dedup.Payload{Text: text})
}
body, err := json.Marshal(payload)
// BroadcastImage sends an image (base64-encoded) to all peers in parallel.
func (b *Broadcaster) BroadcastImage(mime string, data []byte) {
b.BroadcastPayload(dedup.Payload{
Mime: mime,
Data: base64.StdEncoding.EncodeToString(data),
})
}
// BroadcastPayload sends an arbitrary payload to all peers in parallel.
func (b *Broadcaster) BroadcastPayload(p dedup.Payload) {
p.Ts = time.Now().UnixNano()
p.Origin = b.origin
body, err := json.Marshal(p)
if err != nil {
log.Printf("peer: marshal payload: %v", err)
return
}
for i := range b.peers {
go b.sendToPeer(b.peers[i], body)
snap := b.snapshot()
for i := range snap {
go b.sendToPeer(snap[i], body)
}
}
// BroadcastSync sends the given text to all peers and blocks until every send
// has completed (or failed). Used by the one-shot mode so the process does not
// exit before the payload is on the wire.
func (b *Broadcaster) BroadcastSync(text string) {
body, err := json.Marshal(dedup.Payload{
Text: text,
Ts: time.Now().UnixNano(),
Origin: b.origin,
})
if err != nil {
log.Printf("peer: marshal payload: %v", err)
return
}
snap := b.snapshot()
var wg sync.WaitGroup
for i := range snap {
wg.Add(1)
go func(p config.PeerConfig) {
defer wg.Done()
b.sendToPeer(p, body)
}(snap[i])
}
wg.Wait()
}
// snapshot returns a copy of the current peer list under read lock.
func (b *Broadcaster) snapshot() []config.PeerConfig {
b.mu.RLock()
defer b.mu.RUnlock()
return append([]config.PeerConfig(nil), b.peers...)
}
// PeerCount returns the number of currently configured peers.
func (b *Broadcaster) PeerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.peers)
}
// AddPeer adds a peer if its address is not already present.
func (b *Broadcaster) AddPeer(p config.PeerConfig) {
b.mu.Lock()
defer b.mu.Unlock()
for _, existing := range b.peers {
if existing.Addr == p.Addr {
return
}
}
b.peers = append(b.peers, p)
}
// RemovePeer removes a peer by address.
func (b *Broadcaster) RemovePeer(addr string) {
b.mu.Lock()
defer b.mu.Unlock()
out := b.peers[:0]
for _, p := range b.peers {
if p.Addr != addr {
out = append(out, p)
}
}
b.peers = out
}
// SendFile sends a binary payload to all configured peers in parallel.
func (b *Broadcaster) SendFile(f transfer.File) {
body, err := json.Marshal(f)
if err != nil {
log.Printf("peer: marshal file: %v", err)
return
}
snap := b.snapshot()
for i := range snap {
go b.sendToPath(snap[i], filePath, body)
}
}
// SendFileSync sends a binary payload to all peers and blocks until done.
func (b *Broadcaster) SendFileSync(f transfer.File) {
body, err := json.Marshal(f)
if err != nil {
log.Printf("peer: marshal file: %v", err)
return
}
snap := b.snapshot()
var wg sync.WaitGroup
for i := range snap {
wg.Add(1)
go func(p config.PeerConfig) {
defer wg.Done()
b.sendToPath(p, filePath, body)
}(snap[i])
}
wg.Wait()
}
func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
url := fmt.Sprintf("http://%s%s", p.Addr, clipPath)
b.sendToPath(p, clipPath, body)
}
func (b *Broadcaster) sendToPath(p config.PeerConfig, path string, body []byte) {
scheme := "http"
if p.TLS || b.opts.TLS {
scheme = "https"
}
url := fmt.Sprintf("%s://%s%s", scheme, p.Addr, path)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -70,6 +227,9 @@ func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
return
}
req.Header.Set("Content-Type", "application/json")
if b.opts.SharedKey != "" {
req.Header.Set("Authorization", "Bearer "+b.opts.SharedKey)
}
resp, err := b.client.Do(req)
if err != nil {
+34 -2
View File
@@ -36,7 +36,7 @@ func TestBroadcastSendsPayload(t *testing.T) {
{Name: "test-peer", Addr: ts.Listener.Addr().String()},
}
b := NewBroadcaster(peers, "my-machine")
b := NewBroadcaster(peers, "my-machine", Options{})
b.Broadcast("hello, peer!")
select {
@@ -57,7 +57,39 @@ func TestBroadcastSendsPayload(t *testing.T) {
func TestBroadcastEmptyPeers(t *testing.T) {
// Should not panic or error with zero peers.
b := NewBroadcaster(nil, "my-machine")
b := NewBroadcaster(nil, "my-machine", Options{})
b.Broadcast("test")
b.Broadcast("")
}
func TestBroadcastImage(t *testing.T) {
received := make(chan dedup.Payload, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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()
b := NewBroadcaster([]config.PeerConfig{{Name: "p", Addr: ts.Listener.Addr().String()}}, "my-machine", Options{})
b.BroadcastImage("image/png", []byte{1, 2, 3})
select {
case p := <-received:
if p.Mime != "image/png" {
t.Errorf("mime = %q, want \"image/png\"", p.Mime)
}
if p.Data != "AQID" { // base64 of {1,2,3}
t.Errorf("data = %q, want \"AQID\"", p.Data)
}
if p.Text != "" {
t.Errorf("text = %q, want empty", p.Text)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for image broadcast")
}
}
+94
View File
@@ -0,0 +1,94 @@
// Package security provides shared-key and TLS certificate generation for
// clip-sync peers. It uses only the Go standard library.
package security
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
)
// GenerateKey returns a random 256-bit shared key, hex-encoded. It is suitable
// for use as the security.shared_key bearer token shared by all peers.
func GenerateKey() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate key: %w", err)
}
return hex.EncodeToString(buf), nil
}
// GenerateSelfSignedCert creates a self-signed ECDSA certificate and writes it
// (PEM-encoded) to certFile and keyFile. It is valid for the given hosts/IPs
// and 10 years, which is acceptable for a LAN trust model where all peers
// share the same certificate and key files.
func GenerateSelfSignedCert(certFile, keyFile string, hosts []string) error {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate cert: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("generate cert serial: %w", err)
}
now := time.Now()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "clip-sync"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
} else {
tmpl.DNSNames = append(tmpl.DNSNames, h)
}
}
if len(tmpl.IPAddresses) == 0 && len(tmpl.DNSNames) == 0 {
tmpl.DNSNames = []string{"localhost"}
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return fmt.Errorf("generate cert: create: %w", err)
}
certOut, err := os.Create(certFile)
if err != nil {
return fmt.Errorf("generate cert: %w", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
return fmt.Errorf("generate cert: encode: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return fmt.Errorf("generate cert: marshal key: %w", err)
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("generate cert: %w", err)
}
defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
return fmt.Errorf("generate cert: encode key: %w", err)
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package security
import (
"os"
"path/filepath"
"testing"
)
func TestGenerateKey(t *testing.T) {
k1, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
k2, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if len(k1) != 64 {
t.Errorf("key length = %d, want 64 hex chars", len(k1))
}
if k1 == k2 {
t.Error("two generated keys are identical")
}
}
func TestGenerateSelfSignedCert(t *testing.T) {
dir := t.TempDir()
certFile := filepath.Join(dir, "cert.pem")
keyFile := filepath.Join(dir, "key.pem")
if err := GenerateSelfSignedCert(certFile, keyFile, []string{"localhost", "127.0.0.1"}); err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
for _, f := range []string{certFile, keyFile} {
info, err := os.Stat(f)
if err != nil {
t.Fatalf("Stat %s: %v", f, err)
}
if info.Size() == 0 {
t.Errorf("%s is empty", f)
}
}
}
+41
View File
@@ -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)
}
}
+298 -10
View File
@@ -1,35 +1,79 @@
// Package server provides the HTTP endpoint that receives clipboard payloads
// from remote clip-sync peers.
// from remote clip-sync peers, plus a small local monitoring UI.
package server
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/notify"
"git.dracodev.net/Projets/clip-sync/internal/transfer"
)
// Options configures a Server.
type Options struct {
Addr string
SharedKey string
AllowedOrigins []string
MaxBodyBytes int64
History *history.Store
Notifier notify.Notifier
ReceiveFiles bool
ReceiveDir string
}
// Stats holds lightweight counters exposed via the monitoring UI.
type Stats struct {
StartedAt time.Time `json:"started_at"`
ReceivedCount int64 `json:"received_count"`
LastReceivedAt time.Time `json:"last_received_at"`
LastReceivedBy string `json:"last_received_by"`
LastReceivedLen int `json:"last_received_len"`
}
// Server receives clips from peers and writes them to the local clipboard.
type Server struct {
httpServer *http.Server
clipboard clipboard.Clipboard
filter *dedup.Filter
opts Options
allowed map[string]struct{}
mu sync.Mutex
stats Stats
}
// New creates a Server that listens on addr.
func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
// New creates a Server that listens on opts.Addr.
func New(opts Options, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
s := &Server{
clipboard: cb,
filter: filter,
opts: opts,
allowed: make(map[string]struct{}),
stats: Stats{StartedAt: time.Now()},
}
for _, o := range opts.AllowedOrigins {
s.allowed[o] = struct{}{}
}
mux := http.NewServeMux()
mux.HandleFunc("/clip", s.handleClip)
mux.HandleFunc("/file", s.handleFile)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/history", s.handleHistory)
mux.HandleFunc("/", s.handleIndex)
s.httpServer = &http.Server{
Addr: addr,
Addr: opts.Addr,
Handler: mux,
}
@@ -41,39 +85,283 @@ func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
// ListenAndServeTLS starts the HTTPS server with the given cert/key files.
func (s *Server) ListenAndServeTLS(certFile, keyFile string) error {
return s.httpServer.ListenAndServeTLS(certFile, keyFile)
}
// Close gracefully shuts down the HTTP server.
func (s *Server) Close() error {
return s.httpServer.Close()
}
// Stats returns a snapshot of the server statistics.
func (s *Server) Stats() Stats {
s.mu.Lock()
defer s.mu.Unlock()
return s.stats
}
func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authorized(w, r) {
return
}
// Bound the request body to prevent memory-exhaustion / DoS.
r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes)
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)
isImage := p.Data != ""
if p.Text == "" && !isImage {
http.Error(w, "bad request: empty content", http.StatusBadRequest)
return
}
// Origin validation: reject payloads from unknown origins if configured.
if len(s.allowed) > 0 {
if _, ok := s.allowed[p.Origin]; !ok {
http.Error(w, "forbidden: unknown origin", http.StatusForbidden)
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)
if isImage {
if err := s.writeImage(p); err != nil {
log.Printf("server: image write: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
} else {
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.MarkWrittenPayload(p)
s.recordReceive(p)
if s.opts.History != nil {
s.opts.History.Append(history.Entry{Text: p.Text, Origin: p.Origin, Ts: p.Ts})
}
if s.opts.Notifier != nil {
msg := "Clip reçu de " + p.Origin
if isImage {
msg = "Image reçue de " + p.Origin
}
if err := s.opts.Notifier.Notify("clip-sync", msg); err != nil {
log.Printf("server: notify: %v", err)
}
}
w.WriteHeader(http.StatusNoContent)
}
// writeImage decodes and writes an image payload to the clipboard. It requires
// the clipboard implementation to support ImageClipboard.
func (s *Server) writeImage(p dedup.Payload) error {
ic, ok := s.clipboard.(clipboard.ImageClipboard)
if !ok {
return fmt.Errorf("image clipboard not supported on this platform")
}
data, err := base64.StdEncoding.DecodeString(p.Data)
if err != nil {
return fmt.Errorf("decode image: %w", err)
}
mime := p.Mime
if mime == "" {
mime = "image/png"
}
return ic.WriteImage(mime, data)
}
func (s *Server) recordReceive(p dedup.Payload) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats.ReceivedCount++
s.stats.LastReceivedAt = time.Now()
s.stats.LastReceivedBy = p.Origin
s.stats.LastReceivedLen = len(p.Text)
}
// authorized checks the shared-key bearer token (constant-time comparison).
// It writes the error response and returns false when authentication fails.
func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
if s.opts.SharedKey == "" {
return true
}
got := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(got) < len(prefix) || !equalFoldSubtle(got[:len(prefix)], prefix) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
token := got[len(prefix):]
if subtle.ConstantTimeCompare([]byte(token), []byte(s.opts.SharedKey)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
return true
}
// handleFile receives a binary payload and, if enabled, writes it to disk.
func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authorized(w, r) {
return
}
if !s.opts.ReceiveFiles {
http.Error(w, "file reception disabled", http.StatusForbidden)
return
}
s.filter.MarkWritten(p.Text)
r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes)
var f transfer.File
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
return
}
if f.Data == "" || f.Name == "" {
http.Error(w, "bad request: missing name or data", http.StatusBadRequest)
return
}
if len(s.allowed) > 0 {
if _, ok := s.allowed[f.Origin]; !ok {
http.Error(w, "forbidden: unknown origin", http.StatusForbidden)
return
}
}
dest, err := f.SaveTo(s.opts.ReceiveDir)
if err != nil {
log.Printf("server: file save: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
log.Printf("server: received file %q from %q -> %s", f.Name, f.Origin, dest)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"stats": s.Stats(),
})
}
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if s.opts.History == nil {
_ = json.NewEncoder(w).Encode([]history.Entry{})
return
}
_ = json.NewEncoder(w).Encode(s.opts.History.List())
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(indexHTML))
}
// equalFoldSubtle is a constant-time ASCII case-insensitive comparison. It is
// only used to match the "Bearer " scheme prefix, so a length difference is
// acceptable to leak.
func equalFoldSubtle(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += 'a' - 'A'
}
if cb >= 'A' && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}
const indexHTML = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>clip-sync</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
.card { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.kv { display: flex; justify-content: space-between; padding: .2rem 0; }
ul { list-style: none; padding: 0; margin: 0; }
li { padding: .35rem .5rem; border-bottom: 1px solid #eaeef2; font-family: monospace; font-size: .85rem; word-break: break-all; }
.origin { color: #0969da; font-weight: 600; }
</style>
</head>
<body>
<h1>clip-sync — monitoring</h1>
<div class="card">
<div class="kv"><span>État</span><strong id="status">…</strong></div>
<div class="kv"><span>Clips reçus</span><span id="received">…</span></div>
<div class="kv"><span>Dernier clip</span><span id="last">…</span></div>
</div>
<div class="card">
<h2>Historique récent</h2>
<ul id="history"><li>Chargement…</li></ul>
</div>
<script>
async function refresh() {
try {
const h = await (await fetch('/health')).json();
document.getElementById('status').textContent = h.ok ? 'OK' : 'ERREUR';
document.getElementById('received').textContent = h.stats.received_count;
document.getElementById('last').textContent = h.stats.last_received_by
? (h.stats.last_received_by + ' (' + h.stats.last_received_len + ' octets)') : '—';
} catch (e) { document.getElementById('status').textContent = 'indisponible'; }
try {
const entries = await (await fetch('/history')).json();
const ul = document.getElementById('history');
ul.innerHTML = '';
[...entries].reverse().slice(0, 20).forEach(e => {
const li = document.createElement('li');
li.innerHTML = '<span class="origin">' + escapeHtml(e.origin || 'local') + '</span> ' + escapeHtml(e.text);
ul.appendChild(li);
});
} catch (e) {}
}
function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>`
+160 -54
View File
@@ -6,39 +6,63 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
)
// mockClipboard is an in-memory clipboard for testing.
type mockClipboard struct {
text string
text string
mime string
imgBytes []byte
}
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
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",
func (m *mockClipboard) ReadImage() (string, []byte, error) {
if len(m.imgBytes) == 0 {
return "", nil, clipboard.ErrNoImage
}
return m.mime, m.imgBytes, nil
}
func (m *mockClipboard) WriteImage(mime string, data []byte) error {
m.mime = mime
m.imgBytes = append([]byte(nil), data...)
return nil
}
func newTestServer(cb *mockClipboard, opts Options) *Server {
filter := dedup.NewFilter("my-machine")
return New(opts, cb, filter)
}
func doClip(t *testing.T, s *Server, payload dedup.Payload, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
s.handleClip(w, req)
return w
}
srv.handleClip(w, req)
func TestHandleClipAcceptsValidPayload(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
w := doClip(t, srv, dedup.Payload{
Text: "test text", Ts: 1690000000000000000, Origin: "other-machine",
}, nil)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
@@ -50,32 +74,18 @@ func TestHandleClipAcceptsValidPayload(t *testing.T) {
func TestHandleClipIgnoresEcho(t *testing.T) {
cb := &mockClipboard{text: "existing"}
filter := dedup.NewFilter("my-machine")
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
// Simulate we just wrote "echo text" locally.
filter.MarkWritten("echo text")
srv.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, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
w := doClip(t, srv, dedup.Payload{
Text: "echo text", Ts: 1690000000000000000, Origin: "other-machine",
}, nil)
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)
}
@@ -83,8 +93,7 @@ func TestHandleClipIgnoresEcho(t *testing.T) {
func TestHandleClipRejectsInvalidMethod(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
req := httptest.NewRequest(http.MethodGet, "/clip", nil)
w := httptest.NewRecorder()
@@ -98,23 +107,11 @@ func TestHandleClipRejectsInvalidMethod(t *testing.T) {
func TestHandleClipRejectsEmptyText(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
payload := dedup.Payload{
Text: "",
Ts: 1690000000000000000,
Origin: "other-machine",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
w := doClip(t, srv, dedup.Payload{
Text: "", Ts: 1690000000000000000, Origin: "other-machine",
}, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
@@ -123,8 +120,7 @@ func TestHandleClipRejectsEmptyText(t *testing.T) {
func TestHandleClipRejectsInvalidJSON(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json")))
w := httptest.NewRecorder()
@@ -136,7 +132,117 @@ func TestHandleClipRejectsInvalidJSON(t *testing.T) {
}
}
func TestHandleClipRequiresSharedKey(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, SharedKey: "secret"})
// No auth header → unauthorized.
w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"}, nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d (missing key)", w.Code, http.StatusUnauthorized)
}
// Wrong key → unauthorized.
w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"},
map[string]string{"Authorization": "Bearer wrong"})
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d (wrong key)", w.Code, http.StatusUnauthorized)
}
// Correct key → accepted.
w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 2, Origin: "other-machine"},
map[string]string{"Authorization": "Bearer secret"})
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d (correct key)", w.Code, http.StatusNoContent)
}
}
func TestHandleClipRejectsUnknownOrigin(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, AllowedOrigins: []string{"trusted-host"}})
w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "evil-host"}, nil)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestHandleClipEnforcesBodyLimit(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 16})
req := httptest.NewRequest(http.MethodPost, "/clip", strings.NewReader(`{"text":"`+strings.Repeat("a", 1024)+`"}`))
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d (oversized body)", w.Code, http.StatusBadRequest)
}
}
func TestHandleFileReception(t *testing.T) {
cb := &mockClipboard{}
dir := t.TempDir()
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, ReceiveFiles: true, ReceiveDir: dir})
body := `{"name":"hello.txt","mime":"text/plain","data":"aGVsbG8=","ts":1,"origin":"other-machine"}`
req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleFile(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) == 0 {
t.Fatalf("expected a file in %s, got %d entries (err=%v)", dir, len(entries), err)
}
data, _ := os.ReadFile(dir + "/" + entries[0].Name())
if string(data) != "hello" {
t.Errorf("file content = %q, want \"hello\"", data)
}
}
func TestHandleFileDisabled(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) // ReceiveFiles defaults false
body := `{"name":"x","data":"eA==","ts":1,"origin":"other-machine"}`
req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleFile(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestHandleClipWritesImage(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
// base64 "iVBORw0KGgo=" is a truncated PNG header; content is arbitrary for the test.
body := `{"mime":"image/png","data":"iVBORw0KGgo=","ts":1,"origin":"other-machine"}`
req := httptest.NewRequest(http.MethodPost, "/clip", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
if cb.mime != "image/png" {
t.Errorf("mime = %q, want \"image/png\"", cb.mime)
}
if len(cb.imgBytes) == 0 {
t.Error("image bytes not written to clipboard")
}
}
func TestMain(m *testing.M) {
// Ensure clipboard.New() won't be called in test (mock is used directly).
os.Exit(m.Run())
}
+83
View File
@@ -0,0 +1,83 @@
// Package transfer handles binary content (images, files) transfer between
// clip-sync peers. It is kept separate from the text clipboard path so that
// text sync remains simple and safe; binary content is sent explicitly and is
// written to disk on the receiving side only when enabled.
package transfer
import (
"encoding/base64"
"fmt"
"mime"
"os"
"path/filepath"
"time"
)
// File is the wire payload for a transferred binary object.
type File struct {
Name string `json:"name"`
Mime string `json:"mime"`
Data string `json:"data"` // base64-encoded content
Ts int64 `json:"ts"`
Origin string `json:"origin"`
}
// ReadFile reads a file from disk and encodes it as a transfer.File payload.
func ReadFile(path, origin string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
mimeType := mime.TypeByExtension(filepath.Ext(path))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &File{
Name: filepath.Base(path),
Mime: mimeType,
Data: base64.StdEncoding.EncodeToString(data),
Ts: time.Now().UnixNano(),
Origin: origin,
}, nil
}
// Decode returns the raw bytes of the file.
func (f *File) Decode() ([]byte, error) {
if f.Data == "" {
return nil, fmt.Errorf("transfer: empty data")
}
return base64.StdEncoding.DecodeString(f.Data)
}
// SaveTo writes the decoded content to the given directory, sanitizing the
// file name to avoid path traversal. It returns the written path.
func (f *File) SaveTo(dir string) (string, error) {
data, err := f.Decode()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
name := filepath.Base(f.Name)
if name == "." || name == "" || name == string(filepath.Separator) {
name = "received.bin"
}
// Avoid overwriting existing files: append a numeric suffix if needed.
dest := filepath.Join(dir, name)
for i := 1; ; i++ {
if _, err := os.Stat(dest); os.IsNotExist(err) {
break
}
ext := filepath.Ext(name)
base := name[:len(name)-len(ext)]
dest = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
}
return dest, os.WriteFile(dest, data, 0644)
}
+83
View File
@@ -0,0 +1,83 @@
package transfer
import (
"os"
"path/filepath"
"testing"
)
func TestReadFileRoundTrip(t *testing.T) {
src := filepath.Join(t.TempDir(), "hello.txt")
content := []byte("hello, clip-sync!")
if err := os.WriteFile(src, content, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
f, err := ReadFile(src, "machine-a")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if f.Name != "hello.txt" {
t.Errorf("Name = %q, want \"hello.txt\"", f.Name)
}
if f.Mime != "text/plain; charset=utf-8" {
t.Errorf("Mime = %q, want \"text/plain; charset=utf-8\"", f.Mime)
}
decoded, err := f.Decode()
if err != nil {
t.Fatalf("Decode: %v", err)
}
if string(decoded) != string(content) {
t.Errorf("decoded = %q, want %q", decoded, content)
}
}
func TestSaveTo(t *testing.T) {
dir := t.TempDir()
f := &File{Name: "out.bin", Mime: "application/octet-stream", Data: ""}
// Data must be base64-encoded; use a known encoding of "abc".
f.Data = "YWJj"
dest, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("SaveTo: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != "abc" {
t.Errorf("saved = %q, want \"abc\"", got)
}
}
func TestSaveToAvoidsOverwrite(t *testing.T) {
dir := t.TempDir()
f := &File{Name: "out.bin", Data: "eA=="} // base64 "x"
first, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("first SaveTo: %v", err)
}
second, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("second SaveTo: %v", err)
}
if first == second {
t.Errorf("second save overwrote first: %q == %q", first, second)
}
}
func TestSaveToSanitizesPath(t *testing.T) {
dir := t.TempDir()
f := &File{Name: "../../etc/passwd", Data: "eA=="}
dest, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("SaveTo: %v", err)
}
if filepath.Dir(dest) != dir {
t.Errorf("dest dir = %q, want %q (path traversal not sanitized)", filepath.Dir(dest), dir)
}
}
+175 -15
View File
@@ -2,16 +2,17 @@
//
// Usage:
//
// clip-sync [--config path]
// clip-sync [flags] # run the daemon
// clip-sync history # print local clipboard history
// clip-sync send-file <path> # send a file to all peers
// clip-sync --one-shot [text] # send the clipboard (or text) once, then exit
//
// 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.
// Flags:
//
// 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.
// --config path config file (default ~/.config/clip-sync/peers.toml)
// --version print version and exit
// --generate-key generate a shared key and exit
// --generate-cert generate a self-signed TLS cert/key and exit
package main
import (
@@ -21,31 +22,94 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/daemon"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/peer"
"git.dracodev.net/Projets/clip-sync/internal/security"
"git.dracodev.net/Projets/clip-sync/internal/transfer"
)
var version = "dev"
// version is overridden at build time via -ldflags "-X main.version=...".
var version = "0.2.0"
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("")
fs := flag.NewFlagSet("clip-sync", flag.ExitOnError)
configPath := fs.String("config", "", "config file path")
showVersion := fs.Bool("version", false, "print version and exit")
generateKey := fs.Bool("generate-key", false, "generate a shared key and exit")
generateCert := fs.Bool("generate-cert", false, "generate a self-signed TLS certificate and exit")
oneShot := fs.Bool("one-shot", false, "send the clipboard once and exit")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage:\n clip-sync [flags]\n clip-sync history\n\nFlags:\n")
fs.PrintDefaults()
}
_ = fs.Parse(os.Args[1:])
// Subcommand: history
if fs.NArg() > 0 && fs.Arg(0) == "history" {
runHistory()
return
}
if *showVersion {
fmt.Printf("clip-sync %s\n", version)
return
}
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("")
if *generateKey {
key, err := security.GenerateKey()
if err != nil {
log.Fatalf("clip-sync: generate key: %v", err)
}
fmt.Println(key)
return
}
cfg, err := config.Load()
if *generateCert {
runGenerateCert()
return
}
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("clip-sync: config: %v", err)
}
if *oneShot {
runOneShot(cfg, fs.Arg(0))
return
}
// Subcommand: send-file <path>
if fs.NArg() > 0 && fs.Arg(0) == "send-file" {
path := ""
if fs.NArg() > 1 {
path = fs.Arg(1)
}
runSendFile(cfg, path)
return
}
runDaemon(cfg)
}
func loadConfig(path string) (*config.Config, error) {
if path != "" {
return config.LoadFrom(path)
}
return config.Load()
}
func runDaemon(cfg *config.Config) {
d, err := daemon.New(cfg)
if err != nil {
log.Fatalf("clip-sync: init: %v", err)
@@ -54,7 +118,6 @@ func main() {
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)
@@ -70,3 +133,100 @@ func main() {
log.Println("clip-sync: stopped")
}
func runOneShot(cfg *config.Config, textArg string) {
origin, err := os.Hostname()
if err != nil {
log.Fatalf("clip-sync: hostname: %v", err)
}
b := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
text := textArg
if text == "" {
cb, err := clipboard.New()
if err != nil {
log.Fatalf("clip-sync: clipboard: %v", err)
}
text, err = cb.Read()
if err != nil {
log.Fatalf("clip-sync: clipboard read: %v", err)
}
}
if text == "" {
log.Fatal("clip-sync: nothing to send (clipboard is empty)")
}
b.BroadcastSync(text)
fmt.Printf("clip-sync: sent %d bytes to %d peer(s)\n", len(text), b.PeerCount())
}
func runSendFile(cfg *config.Config, path string) {
if path == "" {
log.Fatal("clip-sync: usage: clip-sync send-file <path>")
}
origin, err := os.Hostname()
if err != nil {
log.Fatalf("clip-sync: hostname: %v", err)
}
f, err := transfer.ReadFile(path, origin)
if err != nil {
log.Fatalf("clip-sync: read file: %v", err)
}
b := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
b.SendFileSync(*f)
fmt.Printf("clip-sync: sent %q (%s) to %d peer(s)\n", f.Name, f.Mime, b.PeerCount())
}
func runHistory() {
path, err := history.DefaultPath()
if err != nil {
log.Fatalf("clip-sync: history: %v", err)
}
entries := history.New(path, 0).List()
if len(entries) == 0 {
fmt.Println("clip-sync: no history yet")
return
}
for _, e := range entries {
ts := time.Unix(0, e.Ts).Format("2006-01-02 15:04:05")
text := strings.ReplaceAll(e.Text, "\n", "\\n")
if len(text) > 80 {
text = text[:80] + "…"
}
fmt.Printf("%s [%s] %s\n", ts, e.Origin, text)
}
}
func runGenerateCert() {
certFile, err := config.DefaultCertFile()
if err != nil {
log.Fatalf("clip-sync: cert path: %v", err)
}
keyFile, err := config.DefaultKeyFile()
if err != nil {
log.Fatalf("clip-sync: key path: %v", err)
}
host, _ := os.Hostname()
if err := security.GenerateSelfSignedCert(certFile, keyFile, []string{host, "localhost"}); err != nil {
log.Fatalf("clip-sync: generate cert: %v", err)
}
fmt.Printf("clip-sync: certificate written to %s and %s\n", certFile, keyFile)
fmt.Println("Set [security] tls = true and cert_file/key_file in peers.toml, and share these files with your peers.")
}
+22 -2
View File
@@ -2,13 +2,33 @@
# Copy this to ~/.config/clip-sync/peers.toml and edit your machines.
#
# Environment overrides:
# CLIP_SYNC_PORT=9137 override daemon.port
# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms
# CLIP_SYNC_PORT=9137 override daemon.port
# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms
# CLIP_SYNC_KEY=... override security.shared_key
# CLIP_SYNC_MAX_BODY_BYTES=... override daemon.max_body_bytes
[daemon]
port = 9137
poll_interval_ms = 500
# Optional features (uncomment to enable):
# max_body_bytes = 10485760 # 10 MiB request body limit (default)
# history_size = 50 # number of clipboard history entries
# discovery = true # mDNS auto-discovery of peers (no manual peers needed)
# notify = true # desktop notifications on incoming clips
# sync_images = true # sync images from the clipboard (PNG)
# receive_files = true # accept incoming file transfers (saved to ~/Downloads/clip-sync)
# receive_dir = "/path/to/dir" # override where received files are written
# Optional security (strongly recommended on shared networks):
# [security]
# shared_key = "..." # generate with: clip-sync --generate-key
# allowed_origins = ["bureau", "portable"] # reject clips from unknown hosts
# tls = true # encrypt traffic (see clip-sync --generate-cert)
# cert_file = "~/.config/clip-sync/cert.pem"
# key_file = "~/.config/clip-sync/key.pem"
# insecure_skip_verify = true # trust the shared self-signed cert without a CA
[[peers]]
name = "bureau"
addr = "192.168.1.10:9137"
-18
View File
@@ -1,18 +0,0 @@
# 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"