feat: native clipboard image sync (Linux, opt-in sync_images)
This commit is contained in:
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Historique du presse-papiers persistant + commande `clip-sync history`
|
- Historique du presse-papiers persistant + commande `clip-sync history`
|
||||||
- Mode one-shot : `clip-sync --one-shot [text]`
|
- Mode one-shot : `clip-sync --one-shot [text]`
|
||||||
- Transfert de fichiers : `clip-sync send-file <path>` (réception optionnelle)
|
- Transfert de fichiers : `clip-sync send-file <path>` (réception optionnelle)
|
||||||
|
- Sync d'images via le presse-papiers (`daemon.sync_images`, Linux)
|
||||||
- Découverte automatique des pairs via mDNS (`daemon.discovery`)
|
- Découverte automatique des pairs via mDNS (`daemon.discovery`)
|
||||||
- Interface web locale de monitoring (`/`, `/health`, `/history`)
|
- Interface web locale de monitoring (`/`, `/health`, `/history`)
|
||||||
- Notifications desktop (`daemon.notify`)
|
- Notifications desktop (`daemon.notify`)
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ Copiez du texte sur la machine A (Ctrl+C), collez sur la machine B (Ctrl+V).
|
|||||||
| `daemon.history_size` | `50` | Nombre d'entrées conservées dans l'historique |
|
| `daemon.history_size` | `50` | Nombre d'entrées conservées dans l'historique |
|
||||||
| `daemon.discovery` | `false` | Découverte automatique des pairs via mDNS |
|
| `daemon.discovery` | `false` | Découverte automatique des pairs via mDNS |
|
||||||
| `daemon.notify` | `false` | Notifications desktop à chaque clip reçu |
|
| `daemon.notify` | `false` | Notifications desktop à chaque clip reçu |
|
||||||
|
| `daemon.sync_images` | `false` | Sync d'images (Linux uniquement) |
|
||||||
| `daemon.receive_files` | `false` | Accepter les transferts de fichiers |
|
| `daemon.receive_files` | `false` | Accepter les transferts de fichiers |
|
||||||
| `daemon.receive_dir` | `~/Downloads/clip-sync` | Dossier de réception des fichiers |
|
| `daemon.receive_dir` | `~/Downloads/clip-sync` | Dossier de réception des fichiers |
|
||||||
| `security.shared_key` | — | Clé partagée (`clip-sync --generate-key`) |
|
| `security.shared_key` | — | Clé partagée (`clip-sync --generate-key`) |
|
||||||
|
|||||||
+2
-3
@@ -26,9 +26,8 @@
|
|||||||
- [x] Historique du presse-papiers local (derniers N éléments)
|
- [x] Historique du presse-papiers local (derniers N éléments)
|
||||||
- [x] Commande `clip-sync history` pour afficher l'historique
|
- [x] Commande `clip-sync history` pour afficher l'historique
|
||||||
- [x] Option `--one-shot` : envoi unique sans daemon
|
- [x] Option `--one-shot` : envoi unique sans daemon
|
||||||
- [ ] Images copiées/collées via le presse-papiers (JPEG/PNG) — le transfert
|
- [x] Images copiées/collées via le presse-papiers (PNG) sur Linux
|
||||||
de fichiers est fait, mais la lecture/écriture d'images dans le
|
(`daemon.sync_images`) — Windows/macOS à implémenter par plateforme
|
||||||
presse-papiers natif reste à implémenter par plateforme
|
|
||||||
|
|
||||||
## v1.0.0 🌐 Stable (partiellement réalisée)
|
## v1.0.0 🌐 Stable (partiellement réalisée)
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ port = 9137
|
|||||||
poll_interval_ms = 500
|
poll_interval_ms = 500
|
||||||
# discovery = true # enable mDNS auto-discovery of peers
|
# discovery = true # enable mDNS auto-discovery of peers
|
||||||
# notify = true # enable desktop notifications
|
# notify = true # enable desktop notifications
|
||||||
|
# sync_images = true # sync images from the clipboard (Linux only)
|
||||||
# receive_files = true # accept incoming file transfers
|
# receive_files = true # accept incoming file transfers
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type DaemonConfig struct {
|
|||||||
HistorySize int `toml:"history_size"`
|
HistorySize int `toml:"history_size"`
|
||||||
Discovery bool `toml:"discovery"`
|
Discovery bool `toml:"discovery"`
|
||||||
Notify bool `toml:"notify"`
|
Notify bool `toml:"notify"`
|
||||||
|
SyncImages bool `toml:"sync_images"`
|
||||||
ReceiveFiles bool `toml:"receive_files"`
|
ReceiveFiles bool `toml:"receive_files"`
|
||||||
ReceiveDir string `toml:"receive_dir"`
|
ReceiveDir string `toml:"receive_dir"`
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-15
@@ -4,6 +4,9 @@ package daemon
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -153,6 +156,10 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
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))
|
||||||
@@ -168,26 +175,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)
|
||||||
|
}
|
||||||
if d.history != nil {
|
|
||||||
d.history.Append(history.Entry{Text: text, Origin: d.origin, Ts: time.Now().UnixNano()})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to all peers.
|
|
||||||
if len(d.cfg.Peers) > 0 || d.discovery != nil {
|
|
||||||
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[:])
|
||||||
|
}
|
||||||
|
|||||||
+16
-3
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+27
-18
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -84,8 +85,25 @@ func buildTLSConfig(opts Options) *tls.Config {
|
|||||||
// 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) {
|
||||||
body, ok := b.marshalPayload(text)
|
b.BroadcastPayload(dedup.Payload{Text: text})
|
||||||
if !ok {
|
}
|
||||||
|
|
||||||
|
// BroadcastImage sends an image (base64-encoded) to all peers in parallel.
|
||||||
|
func (b *Broadcaster) BroadcastImage(mime string, data []byte) {
|
||||||
|
b.BroadcastPayload(dedup.Payload{
|
||||||
|
Mime: mime,
|
||||||
|
Data: base64.StdEncoding.EncodeToString(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastPayload sends an arbitrary payload to all peers in parallel.
|
||||||
|
func (b *Broadcaster) BroadcastPayload(p dedup.Payload) {
|
||||||
|
p.Ts = time.Now().UnixNano()
|
||||||
|
p.Origin = b.origin
|
||||||
|
|
||||||
|
body, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("peer: marshal payload: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +117,13 @@ func (b *Broadcaster) Broadcast(text string) {
|
|||||||
// has completed (or failed). Used by the one-shot mode so the process does not
|
// has completed (or failed). Used by the one-shot mode so the process does not
|
||||||
// exit before the payload is on the wire.
|
// exit before the payload is on the wire.
|
||||||
func (b *Broadcaster) BroadcastSync(text string) {
|
func (b *Broadcaster) BroadcastSync(text string) {
|
||||||
body, ok := b.marshalPayload(text)
|
body, err := json.Marshal(dedup.Payload{
|
||||||
if !ok {
|
Text: text,
|
||||||
|
Ts: time.Now().UnixNano(),
|
||||||
|
Origin: b.origin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("peer: marshal payload: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,20 +139,6 @@ func (b *Broadcaster) BroadcastSync(text string) {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Broadcaster) marshalPayload(text string) ([]byte, bool) {
|
|
||||||
payload := dedup.Payload{
|
|
||||||
Text: text,
|
|
||||||
Ts: time.Now().UnixNano(),
|
|
||||||
Origin: b.origin,
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("peer: marshal payload: %v", err)
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return body, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshot returns a copy of the current peer list under read lock.
|
// snapshot returns a copy of the current peer list under read lock.
|
||||||
func (b *Broadcaster) snapshot() []config.PeerConfig {
|
func (b *Broadcaster) snapshot() []config.PeerConfig {
|
||||||
b.mu.RLock()
|
b.mu.RLock()
|
||||||
|
|||||||
@@ -61,3 +61,35 @@ func TestBroadcastEmptyPeers(t *testing.T) {
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -119,8 +121,9 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +140,21 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
|||||||
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)
|
||||||
return
|
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.MarkWritten(p.Text)
|
s.filter.MarkWrittenPayload(p)
|
||||||
s.recordReceive(p)
|
s.recordReceive(p)
|
||||||
|
|
||||||
if s.opts.History != nil {
|
if s.opts.History != nil {
|
||||||
@@ -151,7 +162,11 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.opts.Notifier != nil {
|
if s.opts.Notifier != nil {
|
||||||
if err := s.opts.Notifier.Notify("clip-sync", "Clip reçu de "+p.Origin); err != 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)
|
log.Printf("server: notify: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +174,24 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (s *Server) recordReceive(p dedup.Payload) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -9,17 +9,32 @@ import (
|
|||||||
"strings"
|
"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 (m *mockClipboard) ReadImage() (string, []byte, error) {
|
||||||
|
if len(m.imgBytes) == 0 {
|
||||||
|
return "", nil, clipboard.ErrNoImage
|
||||||
|
}
|
||||||
|
return m.mime, m.imgBytes, nil
|
||||||
|
}
|
||||||
|
func (m *mockClipboard) WriteImage(mime string, data []byte) error {
|
||||||
|
m.mime = mime
|
||||||
|
m.imgBytes = append([]byte(nil), data...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newTestServer(cb *mockClipboard, opts Options) *Server {
|
func newTestServer(cb *mockClipboard, opts Options) *Server {
|
||||||
filter := dedup.NewFilter("my-machine")
|
filter := dedup.NewFilter("my-machine")
|
||||||
return New(opts, cb, filter)
|
return New(opts, cb, filter)
|
||||||
@@ -206,6 +221,28 @@ func TestHandleFileDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ poll_interval_ms = 500
|
|||||||
# history_size = 50 # number of clipboard history entries
|
# history_size = 50 # number of clipboard history entries
|
||||||
# discovery = true # mDNS auto-discovery of peers (no manual peers needed)
|
# discovery = true # mDNS auto-discovery of peers (no manual peers needed)
|
||||||
# notify = true # desktop notifications on incoming clips
|
# notify = true # desktop notifications on incoming clips
|
||||||
|
# sync_images = true # sync images from the clipboard (Linux only)
|
||||||
# receive_files = true # accept incoming file transfers (saved to ~/Downloads/clip-sync)
|
# receive_files = true # accept incoming file transfers (saved to ~/Downloads/clip-sync)
|
||||||
# receive_dir = "/path/to/dir" # override where received files are written
|
# receive_dir = "/path/to/dir" # override where received files are written
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user