Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8694495d | ||
|
|
52823109bc | ||
|
|
919c6f0040 | ||
|
|
4289d2d0ba | ||
|
|
35c6b98aec | ||
|
|
448d2c4ead | ||
|
|
f2a3e34f94 | ||
|
|
32eff914d5 | ||
|
|
147cba6d2a | ||
|
|
884415c1c1 |
+29
-2
@@ -99,6 +99,26 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: artifacts/
|
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
|
- name: Create release
|
||||||
uses: gitea/action-gitea-release@v1
|
uses: gitea/action-gitea-release@v1
|
||||||
with:
|
with:
|
||||||
@@ -109,9 +129,16 @@ jobs:
|
|||||||
|
|
||||||
See [CHANGELOG.md](https://git.dracodev.net/Projets/clip-sync/src/branch/main/CHANGELOG.md) for details.
|
See [CHANGELOG.md](https://git.dracodev.net/Projets/clip-sync/src/branch/main/CHANGELOG.md) for details.
|
||||||
|
|
||||||
### Install
|
### Install — Linux / macOS
|
||||||
```bash
|
```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: |
|
files: |
|
||||||
artifacts/**/clip-sync-*
|
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/`)
|
### 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`
|
- **Linux** : auto-détection X11/Wayland via `$XDG_SESSION_TYPE`
|
||||||
- X11 → `xclip -selection clipboard -o` / `xclip -selection clipboard`
|
- 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é
|
2. Ticker 500ms : lit le presse-papiers, compare, broadcast si changé
|
||||||
3. Signal SIGINT/SIGTERM → arrêt gracieux
|
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épendances
|
||||||
|
|
||||||
| Dépendance | Version | Justification |
|
| Dépendance | Version | Justification |
|
||||||
|-----------|---------|---------------|
|
|-----------|---------|---------------|
|
||||||
| `github.com/BurntSushi/toml` | v1.3.2 | Parsing TOML (stdlib Go n'inclut pas TOML) |
|
| `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
|
## 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/),
|
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).
|
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
|
## [0.1.0] — 2026-08-08
|
||||||
|
|
||||||
### Added
|
### 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.
|
# Cross-compilation, tests, release.
|
||||||
|
|
||||||
BINARY := clip-sync
|
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)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
BUILD_DIR := bin
|
BUILD_DIR := bin
|
||||||
|
|
||||||
@@ -54,6 +54,22 @@ test-cover:
|
|||||||
go test ./... -coverprofile=coverage.out -count=1 -timeout 60s
|
go test ./... -coverprofile=coverage.out -count=1 -timeout 60s
|
||||||
go tool cover -func=coverage.out
|
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 ───────────────────────────────────────────────────────────────
|
# ── Lint ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
@@ -84,6 +100,8 @@ help:
|
|||||||
@echo " test-verbose Run tests with verbose output"
|
@echo " test-verbose Run tests with verbose output"
|
||||||
@echo " test-race Run tests with race detector"
|
@echo " test-race Run tests with race detector"
|
||||||
@echo " test-cover Run tests with coverage report"
|
@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 " lint Run golangci-lint"
|
||||||
@echo " clean Remove build artifacts"
|
@echo " clean Remove build artifacts"
|
||||||
@echo " install Install binary to ~/.local/bin/"
|
@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
|
## Fonctionnalités
|
||||||
|
|
||||||
- **Bidirectionnel** — copie A → B et B → A, chaque nœud est client et serveur
|
- **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
|
- **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
|
- **Multi-plateforme** — Linux (X11/Wayland), Windows, macOS
|
||||||
- **Binaire unique** — zéro runtime externe, compilation Go native
|
- **Binaire unique** — zéro runtime externe, compilation Go native
|
||||||
- **Configuration simple** — fichier TOML + variables d'environnement
|
- **Configuration simple** — fichier TOML + variables d'environnement
|
||||||
@@ -27,13 +51,19 @@ Copier sur une machine → coller sur une autre, automatiquement et instantaném
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Méthode 1 : Script automatique (tous OS)
|
### Méthode 1 : Script automatique en une ligne (recommandé)
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
# amd64
|
# amd64
|
||||||
curl -sSL -o ~/.local/bin/clip-sync \
|
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
|
chmod +x ~/.local/bin/clip-sync
|
||||||
|
|
||||||
# arm64 (Raspberry Pi, etc.)
|
# arm64 (Raspberry Pi, etc.)
|
||||||
curl -sSL -o ~/.local/bin/clip-sync \
|
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
|
chmod +x ~/.local/bin/clip-sync
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -78,7 +108,7 @@ source ~/.bashrc
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clip-sync --version
|
clip-sync --version
|
||||||
# → clip-sync v0.1.0
|
# → clip-sync v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**5. Installer comme service (systemd --user)**
|
**5. Installer comme service (systemd --user)**
|
||||||
@@ -103,12 +133,12 @@ systemctl --user status clip-sync
|
|||||||
```bash
|
```bash
|
||||||
# Apple Silicon (M1/M2/M3)
|
# Apple Silicon (M1/M2/M3)
|
||||||
curl -sSL -o ~/.local/bin/clip-sync \
|
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
|
chmod +x ~/.local/bin/clip-sync
|
||||||
|
|
||||||
# Intel Mac
|
# Intel Mac
|
||||||
curl -sSL -o ~/.local/bin/clip-sync \
|
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
|
chmod +x ~/.local/bin/clip-sync
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -123,7 +153,7 @@ source ~/.zshrc
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clip-sync --version
|
clip-sync --version
|
||||||
# → clip-sync v0.1.0
|
# → clip-sync v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**5. Installer comme service (launchd)**
|
**5. Installer comme service (launchd)**
|
||||||
@@ -168,12 +198,12 @@ launchctl list | grep clip-sync
|
|||||||
```powershell
|
```powershell
|
||||||
# amd64 (standard)
|
# amd64 (standard)
|
||||||
Invoke-WebRequest -Uri `
|
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"
|
-OutFile "$env:USERPROFILE\bin\clip-sync.exe"
|
||||||
|
|
||||||
# arm64 (Surface Pro X, etc.)
|
# arm64 (Surface Pro X, etc.)
|
||||||
Invoke-WebRequest -Uri `
|
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"
|
-OutFile "$env:USERPROFILE\bin\clip-sync.exe"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -192,7 +222,7 @@ Invoke-WebRequest -Uri `
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
clip-sync --version
|
clip-sync --version
|
||||||
# → clip-sync v0.1.0
|
# → clip-sync v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**5. Installer comme service Windows (NSSM)**
|
**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 |
|
| 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.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[].name` | — | Nom descriptif du pair |
|
||||||
| `peers[].addr` | — | Adresse `host:port` du pair |
|
| `peers[].addr` | — | Adresse `host:port` du pair |
|
||||||
|
| `peers[].tls` | `false` | TLS pour ce pair (override global) |
|
||||||
|
|
||||||
### Variables d'environnement (override)
|
### 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_PORT` | `daemon.port` |
|
||||||
| `CLIP_SYNC_POLL_MS` | `daemon.poll_interval_ms` |
|
| `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
|
## Déboguer
|
||||||
|
|
||||||
@@ -295,6 +374,81 @@ curl -X POST http://192.168.1.20:9137/clip \
|
|||||||
-d '{"text":"test","ts":0,"origin":"debug"}'
|
-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
|
## Licence
|
||||||
|
|
||||||
MIT © 2026
|
MIT © 2026
|
||||||
|
|||||||
+25
-20
@@ -1,6 +1,6 @@
|
|||||||
# ROADMAP.md — clip-sync
|
# 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] Synchronisation texte entre pairs LAN
|
||||||
- [x] Anti-boucle (hash + timestamp + origine)
|
- [x] Anti-boucle (hash + timestamp + origine)
|
||||||
@@ -10,27 +10,32 @@
|
|||||||
- [x] Build cross-plateforme
|
- [x] Build cross-plateforme
|
||||||
- [x] Service systemd --user
|
- [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)
|
- [x] Authentification par clé partagée (header `Authorization: Bearer <key>`)
|
||||||
- [ ] Authentification par clé partagée (header `Authorization: Bearer <key>`)
|
- [x] Validation de l'origine (rejet des pairs inconnus)
|
||||||
- [ ] Validation de l'origine (rejet des pairs inconnus)
|
- [x] Option `--generate-key` pour générer une clé partagée
|
||||||
- [ ] Option `--generate-key` pour générer une clé partagée
|
- [x] Config : section `[security]` dans peers.toml
|
||||||
- [ ] 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
|
- [x] Transfert de fichiers (`clip-sync send-file`, payload Base64, taille max configurable)
|
||||||
- [ ] Support fichiers (chemin + contenu Base64, taille max configurable)
|
- [x] Historique du presse-papiers local (derniers N éléments)
|
||||||
- [ ] Historique du presse-papiers local (derniers N éléments)
|
- [x] Commande `clip-sync history` pour afficher l'historique
|
||||||
- [ ] Commande `clip-sync history` pour afficher l'historique
|
- [x] Option `--one-shot` : envoi unique sans daemon
|
||||||
- [ ] 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)
|
- [x] Découverte automatique des pairs via mDNS (`daemon.discovery`)
|
||||||
- [ ] Interface web locale de monitoring (`localhost:9137/` : pairs connectés, stats)
|
- [x] Interface web locale de monitoring (`http://localhost:9137/`)
|
||||||
- [ ] Notification desktop (DBus/Linux, toast/Windows, Notification Center/macOS)
|
- [x] Notification desktop (notify-send/Linux, osascript/macOS, Shell_NotifyIcon/Windows)
|
||||||
- [ ] Tests de performance et benchmark
|
- [x] Tests de performance et benchmark (`make bench`)
|
||||||
- [ ] Documentation utilisateur finale (site statique ou wiki)
|
- [x] Documentation utilisateur finale (site statique `docs/index.html`)
|
||||||
- [ ] Signature GPG des releases
|
- [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
|
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 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
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
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="https://git.dracodev.net/Projets/clip-sync"
|
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}"
|
VERSION="${CLIP_SYNC_VERSION:-latest}"
|
||||||
BINARY="clip-sync"
|
BINARY="clip-sync"
|
||||||
INSTALL_DIR="${HOME}/.local/bin"
|
INSTALL_DIR="${HOME}/.local/bin"
|
||||||
CONFIG_DIR="${HOME}/.config/clip-sync"
|
CONFIG_DIR="${HOME}/.config/clip-sync"
|
||||||
CONFIG_FILE="${CONFIG_DIR}/peers.toml"
|
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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
@@ -46,14 +61,7 @@ detect_platform() {
|
|||||||
download_binary() {
|
download_binary() {
|
||||||
local platform="$1"
|
local platform="$1"
|
||||||
local bin_name="${BINARY}-${platform}"
|
local bin_name="${BINARY}-${platform}"
|
||||||
local download_url
|
local download_url="${REPO}/releases/download/${VERSION}/${bin_name}"
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Windows binary has .exe extension
|
# Windows binary has .exe extension
|
||||||
if [[ "$platform" == windows-* ]]; then
|
if [[ "$platform" == windows-* ]]; then
|
||||||
@@ -61,16 +69,18 @@ download_binary() {
|
|||||||
download_url="${download_url}.exe"
|
download_url="${download_url}.exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local out_path="${INSTALL_DIR}/${bin_name}"
|
||||||
|
|
||||||
echo "clip-sync: downloading ${download_url}..."
|
echo "clip-sync: downloading ${download_url}..."
|
||||||
|
|
||||||
if command -v curl &>/dev/null; then
|
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 "clip-sync: download failed. Try building from source:"
|
||||||
echo " git clone ${REPO} && cd clip-sync && make install"
|
echo " git clone ${REPO} && cd clip-sync && make install"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
elif command -v wget &>/dev/null; then
|
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 "clip-sync: download failed. Try building from source:"
|
||||||
echo " git clone ${REPO} && cd clip-sync && make install"
|
echo " git clone ${REPO} && cd clip-sync && make install"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -80,8 +90,8 @@ download_binary() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x "${INSTALL_DIR}/${BINARY}"
|
chmod +x "${out_path}"
|
||||||
echo "clip-sync: installed to ${INSTALL_DIR}/${BINARY}"
|
echo "clip-sync: installed to ${out_path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Create default config ──────────────────────────────────────────────
|
# ── Create default config ──────────────────────────────────────────────
|
||||||
@@ -100,10 +110,18 @@ create_config() {
|
|||||||
# Environment overrides:
|
# Environment overrides:
|
||||||
# CLIP_SYNC_PORT=9137 override daemon.port
|
# CLIP_SYNC_PORT=9137 override daemon.port
|
||||||
# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms
|
# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms
|
||||||
|
# CLIP_SYNC_KEY=... override security.shared_key
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
port = 9137
|
port = 9137
|
||||||
poll_interval_ms = 500
|
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:
|
# Add your peers here:
|
||||||
# [[peers]]
|
# [[peers]]
|
||||||
@@ -124,9 +142,12 @@ main() {
|
|||||||
|
|
||||||
mkdir -p "${INSTALL_DIR}"
|
mkdir -p "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
resolve_version
|
||||||
|
|
||||||
local platform
|
local platform
|
||||||
platform=$(detect_platform)
|
platform=$(detect_platform)
|
||||||
echo "clip-sync: detected platform: ${platform}"
|
echo "clip-sync: detected platform: ${platform}"
|
||||||
|
echo "clip-sync: version: ${VERSION}"
|
||||||
|
|
||||||
download_binary "${platform}"
|
download_binary "${platform}"
|
||||||
create_config
|
create_config
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
// Each platform file defines its own New() constructor using //go:build tags.
|
// Each platform file defines its own New() constructor using //go:build tags.
|
||||||
package clipboard
|
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.
|
// Clipboard is the cross-platform clipboard interface.
|
||||||
type Clipboard interface {
|
type Clipboard interface {
|
||||||
// Read returns the current text content of the clipboard.
|
// Read returns the current text content of the clipboard.
|
||||||
@@ -17,3 +23,15 @@ type Clipboard interface {
|
|||||||
// Write sets the clipboard text content.
|
// Write sets the clipboard text content.
|
||||||
Write(text string) error
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -43,3 +44,55 @@ func (c *darwinClipboard) Write(text string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
package clipboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -70,3 +71,37 @@ func (c *linuxClipboard) Write(text string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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")
|
procGlobalLock = kernel32.NewProc("GlobalLock")
|
||||||
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||||
procGlobalFree = kernel32.NewProc("GlobalFree")
|
procGlobalFree = kernel32.NewProc("GlobalFree")
|
||||||
|
procGlobalSize = kernel32.NewProc("GlobalSize")
|
||||||
)
|
)
|
||||||
|
|
||||||
type windowsClipboard struct{}
|
type windowsClipboard struct{}
|
||||||
@@ -52,9 +53,16 @@ func (c *windowsClipboard) Read() (string, error) {
|
|||||||
|
|
||||||
defer func() { _, _, _ = procGlobalUnlock.Call(h) }()
|
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.
|
// Lock and read the global memory block.
|
||||||
// Use a helper to keep the uintptr→unsafe.Pointer conversion close to the syscall.
|
// Use a helper to keep the uintptr→unsafe.Pointer conversion close to the syscall.
|
||||||
mem := lockAndRead(h)
|
mem := lockAndRead(h, units)
|
||||||
if mem == nil {
|
if mem == nil {
|
||||||
return "", fmt.Errorf("clipboard read: GlobalLock failed")
|
return "", fmt.Errorf("clipboard read: GlobalLock failed")
|
||||||
}
|
}
|
||||||
@@ -103,9 +111,10 @@ func (c *windowsClipboard) Write(text string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// lockAndRead locks a global memory handle and returns its UTF-16 data.
|
// lockAndRead locks a global memory handle and returns its UTF-16 data of the
|
||||||
// Returns nil on failure. Caller must call GlobalUnlock after use.
|
// given length (in code units). Returns nil on failure. Caller must call
|
||||||
func lockAndRead(h uintptr) []uint16 {
|
// GlobalUnlock after use.
|
||||||
|
func lockAndRead(h uintptr, units uintptr) []uint16 {
|
||||||
p, _, _ := procGlobalLock.Call(h)
|
p, _, _ := procGlobalLock.Call(h)
|
||||||
if p == 0 {
|
if p == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -113,7 +122,7 @@ func lockAndRead(h uintptr) []uint16 {
|
|||||||
// Use unsafe.Add to derive a pointer from nil; the uintptr came from
|
// 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.
|
// GlobalLock (GMEM_FIXED) and is a valid virtual address, not a Go pointer.
|
||||||
ptr := unsafe.Add(unsafe.Pointer(nil), p)
|
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.
|
// 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)
|
copy(dst, data)
|
||||||
return true
|
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
|
// Package config parses the clip-sync TOML configuration file and applies
|
||||||
// environment variable overrides.
|
// environment variable overrides.
|
||||||
//
|
//
|
||||||
// Config file location: ~/.config/clip-sync/peers.toml
|
// Config file location: ~/.config/clip-sync/peers.toml (overridable with
|
||||||
// Environment overrides:
|
// the --config flag). Environment overrides:
|
||||||
// CLIP_SYNC_PORT — override daemon.port
|
//
|
||||||
// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms
|
// 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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,59 +22,119 @@ import (
|
|||||||
|
|
||||||
// Config represents the full clip-sync configuration.
|
// Config represents the full clip-sync configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Daemon DaemonConfig `toml:"daemon"`
|
Daemon DaemonConfig `toml:"daemon"`
|
||||||
Peers []PeerConfig `toml:"peers"`
|
Security SecurityConfig `toml:"security"`
|
||||||
|
Peers []PeerConfig `toml:"peers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DaemonConfig holds daemon-level settings.
|
// DaemonConfig holds daemon-level settings.
|
||||||
type DaemonConfig struct {
|
type DaemonConfig struct {
|
||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
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.
|
// PeerConfig represents one remote clip-sync peer.
|
||||||
type PeerConfig struct {
|
type PeerConfig struct {
|
||||||
Name string `toml:"name"`
|
Name string `toml:"name"`
|
||||||
Addr string `toml:"addr"`
|
Addr string `toml:"addr"`
|
||||||
|
TLS bool `toml:"tls"` // per-peer override of security.tls
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default values.
|
// Default values.
|
||||||
const (
|
const (
|
||||||
DefaultPort = 9137
|
DefaultPort = 9137
|
||||||
DefaultPollIntervalMs = 500
|
DefaultPollIntervalMs = 500
|
||||||
|
DefaultMaxBodyBytes = 10 << 20 // 10 MiB
|
||||||
|
DefaultHistorySize = 50
|
||||||
ConfigDir = ".config/clip-sync"
|
ConfigDir = ".config/clip-sync"
|
||||||
ConfigFile = "peers.toml"
|
ConfigFile = "peers.toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the config file from ~/.config/clip-sync/peers.toml and applies
|
// DefaultConfigPath returns the default config file path (~/.config/clip-sync/peers.toml).
|
||||||
// environment variable overrides.
|
func DefaultConfigPath() (string, error) {
|
||||||
func Load() (*Config, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
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{
|
cfg := &Config{
|
||||||
Daemon: DaemonConfig{
|
Daemon: DaemonConfig{
|
||||||
Port: DefaultPort,
|
Port: DefaultPort,
|
||||||
PollIntervalMs: DefaultPollIntervalMs,
|
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) {
|
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).
|
// 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 != "" {
|
if v := os.Getenv("CLIP_SYNC_PORT"); v != "" {
|
||||||
port, err := strconv.Atoi(v)
|
port, err := strconv.Atoi(v)
|
||||||
if err != nil {
|
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
|
cfg.Daemon.Port = port
|
||||||
}
|
}
|
||||||
@@ -79,12 +142,24 @@ func Load() (*Config, error) {
|
|||||||
if v := os.Getenv("CLIP_SYNC_POLL_MS"); v != "" {
|
if v := os.Getenv("CLIP_SYNC_POLL_MS"); v != "" {
|
||||||
ms, err := strconv.Atoi(v)
|
ms, err := strconv.Atoi(v)
|
||||||
if err != nil {
|
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
|
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.
|
// PollInterval returns the poll interval as a time.Duration.
|
||||||
@@ -96,3 +171,25 @@ func (c *Config) PollInterval() time.Duration {
|
|||||||
func (c *Config) ListenAddr() string {
|
func (c *Config) ListenAddr() string {
|
||||||
return fmt.Sprintf(":%d", c.Daemon.Port)
|
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)
|
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,
|
// 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
|
package daemon
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -12,17 +15,23 @@ import (
|
|||||||
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/config"
|
"git.dracodev.net/Projets/clip-sync/internal/config"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
"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/peer"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/server"
|
"git.dracodev.net/Projets/clip-sync/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Daemon is the main clip-sync process.
|
// Daemon is the main clip-sync process.
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
clipboard clipboard.Clipboard
|
clipboard clipboard.Clipboard
|
||||||
filter *dedup.Filter
|
filter *dedup.Filter
|
||||||
server *server.Server
|
server *server.Server
|
||||||
broadcaster *peer.Broadcaster
|
broadcaster *peer.Broadcaster
|
||||||
|
history *history.Store
|
||||||
|
origin string
|
||||||
|
discovery discovery.Discovery
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Daemon from the given configuration.
|
// New creates a Daemon from the given configuration.
|
||||||
@@ -32,16 +41,26 @@ func New(cfg *config.Config) (*Daemon, error) {
|
|||||||
return nil, err
|
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).
|
// History is best-effort: if we cannot resolve a path, run without it.
|
||||||
func newWithClipboard(cfg *config.Config, cb clipboard.Clipboard) (*Daemon, error) {
|
hist := (*history.Store)(nil)
|
||||||
return newWithClipboardOrigin(cfg, cb, "")
|
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.
|
// newWithClipboardOrigin creates a Daemon with a pre-existing clipboard and explicit origin.
|
||||||
func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin string) (*Daemon, error) {
|
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 == "" {
|
if origin == "" {
|
||||||
var err error
|
var err error
|
||||||
origin, err = os.Hostname()
|
origin, err = os.Hostname()
|
||||||
@@ -51,39 +70,91 @@ func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin s
|
|||||||
}
|
}
|
||||||
|
|
||||||
filter := dedup.NewFilter(origin)
|
filter := dedup.NewFilter(origin)
|
||||||
broadcaster := peer.NewBroadcaster(cfg.Peers, origin)
|
broadcaster := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
|
||||||
srv := server.New(cfg.ListenAddr(), cb, filter)
|
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,
|
cfg: cfg,
|
||||||
clipboard: cb,
|
clipboard: cb,
|
||||||
filter: filter,
|
filter: filter,
|
||||||
server: srv,
|
server: srv,
|
||||||
broadcaster: broadcaster,
|
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.
|
// Run starts the daemon loop. It blocks until ctx is cancelled.
|
||||||
func (d *Daemon) Run(ctx context.Context) error {
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
|
var err error
|
||||||
if err := d.server.ListenAndServe(); err != nil {
|
if d.cfg.Security.TLS {
|
||||||
// ListenAndServe returns http.ErrServerClosed on graceful shutdown.
|
certFile, keyFile := d.cfg.Security.CertFile, d.cfg.Security.KeyFile
|
||||||
if ctx.Err() == nil {
|
if certFile == "" {
|
||||||
log.Printf("clip-sync: server error: %v", err)
|
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.
|
// Main clipboard polling loop.
|
||||||
ticker := time.NewTicker(d.cfg.PollInterval())
|
ticker := time.NewTicker(d.cfg.PollInterval())
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
var lastText string
|
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",
|
log.Printf("clip-sync: polling clipboard every %v, %d peer(s) configured",
|
||||||
d.cfg.PollInterval(), len(d.cfg.Peers))
|
d.cfg.PollInterval(), len(d.cfg.Peers))
|
||||||
@@ -99,22 +170,48 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
text, err := d.clipboard.Read()
|
text, err := d.clipboard.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("clip-sync: clipboard read: %v", err)
|
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 {
|
// Image sync (opt-in).
|
||||||
continue
|
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.
|
if len(d.cfg.Peers) > 0 || d.discovery != nil {
|
||||||
d.filter.MarkWritten(text)
|
d.broadcaster.BroadcastImage(mime, data)
|
||||||
|
}
|
||||||
// Broadcast to all peers.
|
|
||||||
if len(d.cfg.Peers) > 0 {
|
|
||||||
d.broadcaster.Broadcast(text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Filter prevents clipboard sync loops.
|
||||||
type Filter struct {
|
type Filter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
origin string // our hostname
|
origin string // our hostname
|
||||||
cache []string // ring buffer of content hashes
|
cache []string // ring buffer of content hashes
|
||||||
cacheIdx int // write position in ring buffer
|
cacheIdx int // write position in ring buffer
|
||||||
lastWriteTs int64 // nanosecond timestamp of our last local write
|
lastWriteTs int64 // nanosecond timestamp of our last local write
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFilter creates a new deduplication filter for the given origin hostname.
|
// 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 {
|
type Payload struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Mime string `json:"mime,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"` // base64-encoded image bytes
|
||||||
Ts int64 `json:"ts"`
|
Ts int64 `json:"ts"`
|
||||||
Origin string `json:"origin"`
|
Origin string `json:"origin"`
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@ func (f *Filter) ShouldIgnore(p Payload) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Guard 3: ignore content we recently wrote (hash match).
|
// 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 {
|
for _, h := range f.cache {
|
||||||
if h == hash {
|
if h == hash {
|
||||||
return true
|
return true
|
||||||
@@ -71,11 +74,21 @@ func (f *Filter) ShouldIgnore(p Payload) bool {
|
|||||||
// MarkWritten records that we just wrote text to the local clipboard.
|
// MarkWritten records that we just wrote text to the local clipboard.
|
||||||
// This populates the hash cache and updates the last-write timestamp.
|
// This populates the hash cache and updates the last-write timestamp.
|
||||||
func (f *Filter) MarkWritten(text string) {
|
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()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now().UnixNano()
|
now := time.Now().UnixNano()
|
||||||
hash := hashText(text)
|
hash := hashText(content)
|
||||||
|
|
||||||
// Ring buffer: overwrite oldest entry when full.
|
// Ring buffer: overwrite oldest entry when full.
|
||||||
if len(f.cache) < defaultCacheSize {
|
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) {
|
func TestCacheEviction(t *testing.T) {
|
||||||
f := NewFilter("machine-a")
|
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
|
package peer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/config"
|
"git.dracodev.net/Projets/clip-sync/internal/config"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
"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.
|
// Broadcaster sends clip payloads to all configured peers concurrently.
|
||||||
type Broadcaster struct {
|
type Broadcaster struct {
|
||||||
|
mu sync.RWMutex
|
||||||
peers []config.PeerConfig
|
peers []config.PeerConfig
|
||||||
origin string
|
origin string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
opts Options
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBroadcaster creates a Broadcaster for the configured peer list.
|
// 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{
|
return &Broadcaster{
|
||||||
peers: peers,
|
peers: peers,
|
||||||
origin: origin,
|
origin: origin,
|
||||||
client: &http.Client{
|
client: client,
|
||||||
Timeout: 5 * time.Second,
|
opts: opts,
|
||||||
Transport: &http.Transport{
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
MaxIdleConnsPerHost: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Broadcast sends the given text to all configured peers in parallel.
|
||||||
// Errors are logged but not returned — a single unreachable peer should not
|
// Errors are logged but not returned — a single unreachable peer should not
|
||||||
// block other peers or the daemon loop.
|
// block other peers or the daemon loop.
|
||||||
func (b *Broadcaster) Broadcast(text string) {
|
func (b *Broadcaster) Broadcast(text string) {
|
||||||
payload := dedup.Payload{
|
b.BroadcastPayload(dedup.Payload{Text: text})
|
||||||
Text: text,
|
}
|
||||||
Ts: time.Now().UnixNano(),
|
|
||||||
Origin: b.origin,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
log.Printf("peer: marshal payload: %v", err)
|
log.Printf("peer: marshal payload: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range b.peers {
|
snap := b.snapshot()
|
||||||
go b.sendToPeer(b.peers[i], body)
|
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) {
|
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))
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,6 +227,9 @@ func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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)
|
resp, err := b.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestBroadcastSendsPayload(t *testing.T) {
|
|||||||
{Name: "test-peer", Addr: ts.Listener.Addr().String()},
|
{Name: "test-peer", Addr: ts.Listener.Addr().String()},
|
||||||
}
|
}
|
||||||
|
|
||||||
b := NewBroadcaster(peers, "my-machine")
|
b := NewBroadcaster(peers, "my-machine", Options{})
|
||||||
b.Broadcast("hello, peer!")
|
b.Broadcast("hello, peer!")
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -57,7 +57,39 @@ func TestBroadcastSendsPayload(t *testing.T) {
|
|||||||
|
|
||||||
func TestBroadcastEmptyPeers(t *testing.T) {
|
func TestBroadcastEmptyPeers(t *testing.T) {
|
||||||
// Should not panic or error with zero peers.
|
// Should not panic or error with zero peers.
|
||||||
b := NewBroadcaster(nil, "my-machine")
|
b := NewBroadcaster(nil, "my-machine", Options{})
|
||||||
b.Broadcast("test")
|
b.Broadcast("test")
|
||||||
b.Broadcast("")
|
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
|
// 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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
"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.
|
// Server receives clips from peers and writes them to the local clipboard.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
clipboard clipboard.Clipboard
|
clipboard clipboard.Clipboard
|
||||||
filter *dedup.Filter
|
filter *dedup.Filter
|
||||||
|
opts Options
|
||||||
|
allowed map[string]struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
stats Stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Server that listens on addr.
|
// New creates a Server that listens on opts.Addr.
|
||||||
func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
|
func New(opts Options, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
clipboard: cb,
|
clipboard: cb,
|
||||||
filter: filter,
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("/clip", s.handleClip)
|
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{
|
s.httpServer = &http.Server{
|
||||||
Addr: addr,
|
Addr: opts.Addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,39 +85,283 @@ func (s *Server) ListenAndServe() error {
|
|||||||
return s.httpServer.ListenAndServe()
|
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.
|
// Close gracefully shuts down the HTTP server.
|
||||||
func (s *Server) Close() error {
|
func (s *Server) Close() error {
|
||||||
return s.httpServer.Close()
|
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) {
|
func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
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
|
var p dedup.Payload
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
|
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Text == "" {
|
isImage := p.Data != ""
|
||||||
http.Error(w, "bad request: empty text", http.StatusBadRequest)
|
if p.Text == "" && !isImage {
|
||||||
|
http.Error(w, "bad request: empty content", http.StatusBadRequest)
|
||||||
return
|
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) {
|
if s.filter.ShouldIgnore(p) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.clipboard.Write(p.Text); err != nil {
|
if isImage {
|
||||||
log.Printf("server: clipboard write: %v", err)
|
if err := s.writeImage(p); err != nil {
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
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
|
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)
|
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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockClipboard is an in-memory clipboard for testing.
|
// mockClipboard is an in-memory clipboard for testing.
|
||||||
type mockClipboard struct {
|
type mockClipboard struct {
|
||||||
text string
|
text string
|
||||||
|
mime string
|
||||||
|
imgBytes []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
|
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) Write(s string) error { m.text = s; return nil }
|
||||||
|
|
||||||
func TestHandleClipAcceptsValidPayload(t *testing.T) {
|
func (m *mockClipboard) ReadImage() (string, []byte, error) {
|
||||||
cb := &mockClipboard{}
|
if len(m.imgBytes) == 0 {
|
||||||
filter := dedup.NewFilter("my-machine")
|
return "", nil, clipboard.ErrNoImage
|
||||||
srv := New(":0", cb, filter)
|
|
||||||
|
|
||||||
payload := dedup.Payload{
|
|
||||||
Text: "test text",
|
|
||||||
Ts: 1690000000000000000,
|
|
||||||
Origin: "other-machine",
|
|
||||||
}
|
}
|
||||||
|
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)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("json.Marshal: %v", err)
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
w := httptest.NewRecorder()
|
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 {
|
if w.Code != http.StatusNoContent {
|
||||||
t.Errorf("status = %d, want %d", 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) {
|
func TestHandleClipIgnoresEcho(t *testing.T) {
|
||||||
cb := &mockClipboard{text: "existing"}
|
cb := &mockClipboard{text: "existing"}
|
||||||
filter := dedup.NewFilter("my-machine")
|
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||||
|
|
||||||
// Simulate we just wrote "echo text" locally.
|
// Simulate we just wrote "echo text" locally.
|
||||||
filter.MarkWritten("echo text")
|
srv.filter.MarkWritten("echo text")
|
||||||
|
|
||||||
srv := New(":0", cb, filter)
|
w := doClip(t, srv, dedup.Payload{
|
||||||
|
Text: "echo text", Ts: 1690000000000000000, Origin: "other-machine",
|
||||||
payload := dedup.Payload{
|
}, nil)
|
||||||
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)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNoContent {
|
if w.Code != http.StatusNoContent {
|
||||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
|
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
// Clipboard should NOT be overwritten.
|
|
||||||
if cb.text != "existing" {
|
if cb.text != "existing" {
|
||||||
t.Errorf("clipboard = %q, want \"existing\" (should not be overwritten)", cb.text)
|
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) {
|
func TestHandleClipRejectsInvalidMethod(t *testing.T) {
|
||||||
cb := &mockClipboard{}
|
cb := &mockClipboard{}
|
||||||
filter := dedup.NewFilter("my-machine")
|
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||||
srv := New(":0", cb, filter)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/clip", nil)
|
req := httptest.NewRequest(http.MethodGet, "/clip", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -98,23 +107,11 @@ func TestHandleClipRejectsInvalidMethod(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleClipRejectsEmptyText(t *testing.T) {
|
func TestHandleClipRejectsEmptyText(t *testing.T) {
|
||||||
cb := &mockClipboard{}
|
cb := &mockClipboard{}
|
||||||
filter := dedup.NewFilter("my-machine")
|
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||||
srv := New(":0", cb, filter)
|
|
||||||
|
|
||||||
payload := dedup.Payload{
|
w := doClip(t, srv, dedup.Payload{
|
||||||
Text: "",
|
Text: "", Ts: 1690000000000000000, Origin: "other-machine",
|
||||||
Ts: 1690000000000000000,
|
}, nil)
|
||||||
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)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Errorf("status = %d, want %d", 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) {
|
func TestHandleClipRejectsInvalidJSON(t *testing.T) {
|
||||||
cb := &mockClipboard{}
|
cb := &mockClipboard{}
|
||||||
filter := dedup.NewFilter("my-machine")
|
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||||
srv := New(":0", cb, filter)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json")))
|
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json")))
|
||||||
w := httptest.NewRecorder()
|
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) {
|
func TestMain(m *testing.M) {
|
||||||
// Ensure clipboard.New() won't be called in test (mock is used directly).
|
|
||||||
os.Exit(m.Run())
|
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:
|
// 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
|
// Flags:
|
||||||
// text content to all configured peers via HTTP POST /clip. It also runs an
|
|
||||||
// HTTP server on :9137 (configurable) to receive clips from peers and write
|
|
||||||
// them to the local clipboard.
|
|
||||||
//
|
//
|
||||||
// Configuration is read from ~/.config/clip-sync/peers.toml (TOML format).
|
// --config path config file (default ~/.config/clip-sync/peers.toml)
|
||||||
// Environment variables CLIP_SYNC_PORT and CLIP_SYNC_POLL_MS override
|
// --version print version and exit
|
||||||
// the corresponding config values.
|
// --generate-key generate a shared key and exit
|
||||||
|
// --generate-cert generate a self-signed TLS cert/key and exit
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,31 +22,94 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"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/config"
|
||||||
"git.dracodev.net/Projets/clip-sync/internal/daemon"
|
"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() {
|
func main() {
|
||||||
showVersion := flag.Bool("version", false, "print version and exit")
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
flag.Parse()
|
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 {
|
if *showVersion {
|
||||||
fmt.Printf("clip-sync %s\n", version)
|
fmt.Printf("clip-sync %s\n", version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
if *generateKey {
|
||||||
log.SetPrefix("")
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("clip-sync: config: %v", err)
|
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)
|
d, err := daemon.New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("clip-sync: init: %v", err)
|
log.Fatalf("clip-sync: init: %v", err)
|
||||||
@@ -54,7 +118,6 @@ func main() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Handle graceful shutdown on SIGINT / SIGTERM.
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
@@ -70,3 +133,100 @@ func main() {
|
|||||||
|
|
||||||
log.Println("clip-sync: stopped")
|
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.
|
# Copy this to ~/.config/clip-sync/peers.toml and edit your machines.
|
||||||
#
|
#
|
||||||
# Environment overrides:
|
# Environment overrides:
|
||||||
# CLIP_SYNC_PORT=9137 override daemon.port
|
# CLIP_SYNC_PORT=9137 override daemon.port
|
||||||
# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms
|
# 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]
|
[daemon]
|
||||||
port = 9137
|
port = 9137
|
||||||
poll_interval_ms = 500
|
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]]
|
[[peers]]
|
||||||
name = "bureau"
|
name = "bureau"
|
||||||
addr = "192.168.1.10:9137"
|
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