Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8694495d | ||
|
|
52823109bc | ||
|
|
919c6f0040 | ||
|
|
4289d2d0ba | ||
|
|
35c6b98aec | ||
|
|
448d2c4ead | ||
|
|
f2a3e34f94 | ||
|
|
32eff914d5 | ||
|
|
147cba6d2a | ||
|
|
884415c1c1 |
+29
-2
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 <path></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 <path></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>
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 + `"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
+160
-54
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user