From 147cba6d2a4b37c9ae00f245eaab85b81c9a77ea Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 14 Aug 2026 09:51:25 -0400 Subject: [PATCH] feat: native clipboard image sync (Linux, opt-in sync_images) --- CHANGELOG.md | 1 + README.md | 1 + ROADMAP.md | 5 +-- install.sh | 1 + internal/clipboard/clipboard.go | 18 ++++++++ internal/clipboard/clipboard_linux.go | 35 ++++++++++++++++ internal/config/config.go | 1 + internal/daemon/daemon.go | 59 ++++++++++++++++++++------- internal/dedup/dedup.go | 19 +++++++-- internal/dedup/dedup_test.go | 16 ++++++++ internal/peer/peer.go | 45 ++++++++++++-------- internal/peer/peer_test.go | 32 +++++++++++++++ internal/server/server.go | 49 ++++++++++++++++++---- internal/server/server_test.go | 39 +++++++++++++++++- peers.example.toml | 1 + 15 files changed, 274 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e973a..e88b253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` - Mode one-shot : `clip-sync --one-shot [text]` - Transfert de fichiers : `clip-sync send-file ` (réception optionnelle) +- Sync d'images via le presse-papiers (`daemon.sync_images`, Linux) - Découverte automatique des pairs via mDNS (`daemon.discovery`) - Interface web locale de monitoring (`/`, `/health`, `/history`) - Notifications desktop (`daemon.notify`) diff --git a/README.md b/README.md index 07d0e0f..aa11884 100644 --- a/README.md +++ b/README.md @@ -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.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 (Linux uniquement) | | `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`) | diff --git a/ROADMAP.md b/ROADMAP.md index cef6288..efe9752 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,9 +26,8 @@ - [x] Historique du presse-papiers local (derniers N éléments) - [x] Commande `clip-sync history` pour afficher l'historique - [x] Option `--one-shot` : envoi unique sans daemon -- [ ] Images copiées/collées via le presse-papiers (JPEG/PNG) — le transfert - de fichiers est fait, mais la lecture/écriture d'images dans le - presse-papiers natif reste à implémenter par plateforme +- [x] Images copiées/collées via le presse-papiers (PNG) sur Linux + (`daemon.sync_images`) — Windows/macOS à implémenter par plateforme ## v1.0.0 🌐 Stable (partiellement réalisée) diff --git a/install.sh b/install.sh index 90d5125..bd7ee27 100644 --- a/install.sh +++ b/install.sh @@ -102,6 +102,7 @@ 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 (Linux only) # receive_files = true # accept incoming file transfers [security] diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index b5b9fe6..873661c 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -8,6 +8,12 @@ // Each platform file defines its own New() constructor using //go:build tags. package clipboard +import "errors" + +// ErrNoImage is returned by ImageClipboard.ReadImage when the clipboard holds +// no image data. It is a normal condition, not an error. +var ErrNoImage = errors.New("clipboard: no image") + // Clipboard is the cross-platform clipboard interface. type Clipboard interface { // Read returns the current text content of the clipboard. @@ -17,3 +23,15 @@ type Clipboard interface { // Write sets the clipboard text content. Write(text string) error } + +// ImageClipboard is an optional interface implemented by platforms that can +// read and write image data from the clipboard. Use a type assertion to detect +// support: `if ic, ok := cb.(clipboard.ImageClipboard); ok { ... }`. +type ImageClipboard interface { + // ReadImage returns the MIME type and raw bytes of the clipboard image. + // It returns ErrNoImage if the clipboard holds no image. + ReadImage() (mime string, data []byte, err error) + + // WriteImage sets the clipboard image content. + WriteImage(mime string, data []byte) error +} diff --git a/internal/clipboard/clipboard_linux.go b/internal/clipboard/clipboard_linux.go index 77ac87b..48d0593 100644 --- a/internal/clipboard/clipboard_linux.go +++ b/internal/clipboard/clipboard_linux.go @@ -3,6 +3,7 @@ package clipboard import ( + "bytes" "fmt" "os" "os/exec" @@ -70,3 +71,37 @@ func (c *linuxClipboard) Write(text string) error { } return nil } + +// ReadImage returns the PNG image from the clipboard, if any. +func (c *linuxClipboard) ReadImage() (string, []byte, error) { + var args []string + if c.pasteCmd == "wl-paste" { + args = []string{"--type", "image/png"} + } else { + args = []string{"-selection", "clipboard", "-t", "image/png", "-o"} + } + + cmd := exec.Command(c.pasteCmd, args...) + out, err := cmd.Output() + if err != nil || len(out) == 0 { + return "", nil, ErrNoImage + } + return "image/png", out, nil +} + +// WriteImage writes image bytes to the clipboard using the given MIME type. +func (c *linuxClipboard) WriteImage(mime string, data []byte) error { + var args []string + if c.copyCmd == "wl-copy" { + args = []string{"--type", mime} + } else { + args = []string{"-selection", "clipboard", "-t", mime} + } + + cmd := exec.Command(c.copyCmd, args...) + cmd.Stdin = bytes.NewReader(data) + if err := cmd.Run(); err != nil { + return fmt.Errorf("clipboard write image (%s): %w", c.copyCmd, err) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index bdb14d8..1f695f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type DaemonConfig struct { 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"` } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e4d8698..796b3c7 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -4,6 +4,9 @@ package daemon import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "log" "os" "sync" @@ -153,6 +156,10 @@ func (d *Daemon) Run(ctx context.Context) error { defer ticker.Stop() var lastText string + var lastImageHash string + + // Optional image sync (Linux only today); requires platform support. + imageCB, _ := d.clipboard.(clipboard.ImageClipboard) log.Printf("clip-sync: polling clipboard every %v, %d peer(s) configured", d.cfg.PollInterval(), len(d.cfg.Peers)) @@ -168,26 +175,48 @@ func (d *Daemon) Run(ctx context.Context) error { text, err := d.clipboard.Read() if err != nil { log.Printf("clip-sync: clipboard read: %v", err) - continue + } else if text != "" && text != lastText { + lastText = text + + // Record our write so the filter can block echoes. + d.filter.MarkWritten(text) + + if d.history != nil { + d.history.Append(history.Entry{Text: text, Origin: d.origin, Ts: time.Now().UnixNano()}) + } + + if len(d.cfg.Peers) > 0 || d.discovery != nil { + d.broadcaster.Broadcast(text) + } } - if text == "" || text == lastText { - continue - } + // Image sync (opt-in). + if d.cfg.Daemon.SyncImages && imageCB != nil { + mime, data, err := imageCB.ReadImage() + if err != nil || len(data) == 0 { + continue + } + h := hashBytes(data) + if h == lastImageHash { + continue + } + lastImageHash = h - lastText = text + d.filter.MarkWrittenPayload(dedup.Payload{ + Mime: mime, + Data: base64.StdEncoding.EncodeToString(data), + }) - // Record our write so the filter can block echoes. - d.filter.MarkWritten(text) - - 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) + if len(d.cfg.Peers) > 0 || d.discovery != nil { + d.broadcaster.BroadcastImage(mime, data) + } } } } } + +// hashBytes returns a short hex digest of b, used to detect clipboard changes. +func hashBytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/dedup/dedup.go b/internal/dedup/dedup.go index b2d8a12..7225a81 100644 --- a/internal/dedup/dedup.go +++ b/internal/dedup/dedup.go @@ -35,9 +35,12 @@ func NewFilter(origin string) *Filter { } } -// Payload represents an incoming clip payload from a peer. +// Payload represents an incoming clip payload from a peer. It carries either +// text (Text) or a base64-encoded image (Mime + Data). type Payload struct { Text string `json:"text"` + Mime string `json:"mime,omitempty"` + Data string `json:"data,omitempty"` // base64-encoded image bytes Ts int64 `json:"ts"` Origin string `json:"origin"` } @@ -58,7 +61,7 @@ func (f *Filter) ShouldIgnore(p Payload) bool { } // Guard 3: ignore content we recently wrote (hash match). - hash := hashText(p.Text) + hash := hashText(p.Text + "\x00" + p.Data) for _, h := range f.cache { if h == hash { return true @@ -71,11 +74,21 @@ func (f *Filter) ShouldIgnore(p Payload) bool { // MarkWritten records that we just wrote text to the local clipboard. // This populates the hash cache and updates the last-write timestamp. func (f *Filter) MarkWritten(text string) { + f.markWritten(text) +} + +// MarkWrittenPayload records that we just wrote a payload (text or image) to +// the local clipboard, so echoes of it are ignored. +func (f *Filter) MarkWrittenPayload(p Payload) { + f.markWritten(p.Text + "\x00" + p.Data) +} + +func (f *Filter) markWritten(content string) { f.mu.Lock() defer f.mu.Unlock() now := time.Now().UnixNano() - hash := hashText(text) + hash := hashText(content) // Ring buffer: overwrite oldest entry when full. if len(f.cache) < defaultCacheSize { diff --git a/internal/dedup/dedup_test.go b/internal/dedup/dedup_test.go index ce8baf8..b28e465 100644 --- a/internal/dedup/dedup_test.go +++ b/internal/dedup/dedup_test.go @@ -69,6 +69,22 @@ func TestShouldAcceptNewContent(t *testing.T) { } } +func TestShouldIgnoreDuplicateImageHash(t *testing.T) { + f := NewFilter("machine-a") + f.MarkWrittenPayload(Payload{Mime: "image/png", Data: "aGVsbG8="}) + + p := Payload{ + Mime: "image/png", + Data: "aGVsbG8=", + Ts: time.Now().UnixNano(), + Origin: "machine-b", + } + + if !f.ShouldIgnore(p) { + t.Error("should ignore image payload with recently written data hash") + } +} + func TestCacheEviction(t *testing.T) { f := NewFilter("machine-a") diff --git a/internal/peer/peer.go b/internal/peer/peer.go index 5e6ed62..dc3b8d3 100644 --- a/internal/peer/peer.go +++ b/internal/peer/peer.go @@ -5,6 +5,7 @@ import ( "bytes" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "fmt" "log" @@ -84,8 +85,25 @@ func buildTLSConfig(opts Options) *tls.Config { // Errors are logged but not returned — a single unreachable peer should not // block other peers or the daemon loop. func (b *Broadcaster) Broadcast(text string) { - body, ok := b.marshalPayload(text) - if !ok { + b.BroadcastPayload(dedup.Payload{Text: text}) +} + +// 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 } @@ -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 // exit before the payload is on the wire. func (b *Broadcaster) BroadcastSync(text string) { - body, ok := b.marshalPayload(text) - if !ok { + 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 } @@ -116,20 +139,6 @@ func (b *Broadcaster) BroadcastSync(text string) { 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. func (b *Broadcaster) snapshot() []config.PeerConfig { b.mu.RLock() diff --git a/internal/peer/peer_test.go b/internal/peer/peer_test.go index 0657d59..ec1c52f 100644 --- a/internal/peer/peer_test.go +++ b/internal/peer/peer_test.go @@ -61,3 +61,35 @@ func TestBroadcastEmptyPeers(t *testing.T) { b.Broadcast("test") b.Broadcast("") } + +func TestBroadcastImage(t *testing.T) { + received := make(chan dedup.Payload, 1) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p dedup.Payload + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + t.Errorf("decode: %v", err) + } + received <- p + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + b := NewBroadcaster([]config.PeerConfig{{Name: "p", Addr: ts.Listener.Addr().String()}}, "my-machine", Options{}) + b.BroadcastImage("image/png", []byte{1, 2, 3}) + + select { + case p := <-received: + if p.Mime != "image/png" { + t.Errorf("mime = %q, want \"image/png\"", p.Mime) + } + if p.Data != "AQID" { // base64 of {1,2,3} + t.Errorf("data = %q, want \"AQID\"", p.Data) + } + if p.Text != "" { + t.Errorf("text = %q, want empty", p.Text) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for image broadcast") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 491cfb1..8e8b083 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,7 +4,9 @@ package server import ( "crypto/subtle" + "encoding/base64" "encoding/json" + "fmt" "log" "net/http" "sync" @@ -119,8 +121,9 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { return } - if p.Text == "" { - http.Error(w, "bad request: empty text", http.StatusBadRequest) + isImage := p.Data != "" + if p.Text == "" && !isImage { + http.Error(w, "bad request: empty content", http.StatusBadRequest) return } @@ -137,13 +140,21 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { return } - if err := s.clipboard.Write(p.Text); err != nil { - log.Printf("server: clipboard write: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return + if isImage { + if err := s.writeImage(p); err != nil { + log.Printf("server: image write: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } else { + if err := s.clipboard.Write(p.Text); err != nil { + log.Printf("server: clipboard write: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } } - s.filter.MarkWritten(p.Text) + s.filter.MarkWrittenPayload(p) s.recordReceive(p) 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 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) } } @@ -159,6 +174,24 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { 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() diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9b954e6..d5a7d69 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -9,17 +9,32 @@ import ( "strings" "testing" + "git.dracodev.net/Projets/clip-sync/internal/clipboard" "git.dracodev.net/Projets/clip-sync/internal/dedup" ) // mockClipboard is an in-memory clipboard for testing. type mockClipboard struct { - text string + text string + mime string + imgBytes []byte } func (m *mockClipboard) Read() (string, error) { return m.text, nil } func (m *mockClipboard) Write(s string) error { m.text = s; return nil } +func (m *mockClipboard) ReadImage() (string, []byte, error) { + if len(m.imgBytes) == 0 { + return "", nil, clipboard.ErrNoImage + } + return m.mime, m.imgBytes, nil +} +func (m *mockClipboard) WriteImage(mime string, data []byte) error { + m.mime = mime + m.imgBytes = append([]byte(nil), data...) + return nil +} + func newTestServer(cb *mockClipboard, opts Options) *Server { filter := dedup.NewFilter("my-machine") return New(opts, cb, filter) @@ -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) { os.Exit(m.Run()) } diff --git a/peers.example.toml b/peers.example.toml index 99226a7..0d4df9b 100644 --- a/peers.example.toml +++ b/peers.example.toml @@ -16,6 +16,7 @@ poll_interval_ms = 500 # 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 (Linux only) # receive_files = true # accept incoming file transfers (saved to ~/Downloads/clip-sync) # receive_dir = "/path/to/dir" # override where received files are written