From 32eff914d51271deca007d31c2910387cdaf279b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 14 Aug 2026 09:55:32 -0400 Subject: [PATCH] feat: Windows (CF_DIB) and macOS (AppleScript) clipboard image sync --- ARCHITECTURE.md | 5 +- CHANGELOG.md | 2 +- README.md | 2 +- ROADMAP.md | 5 +- install.sh | 2 +- internal/clipboard/clipboard_darwin.go | 53 +++++++++++ internal/clipboard/clipboard_windows.go | 80 ++++++++++++++++ internal/clipboard/dib_windows.go | 118 ++++++++++++++++++++++++ internal/clipboard/dib_windows_test.go | 72 +++++++++++++++ peers.example.toml | 2 +- 10 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 internal/clipboard/dib_windows.go create mode 100644 internal/clipboard/dib_windows_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 747b495..af2eed0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,7 +41,10 @@ en mesh pair-à-pair sur réseau local. Chaque nœud est à la fois client et se ### 1. Clipboard (`internal/clipboard/`) -Interface unifiée `Read() / Write()` avec implémentations par plateforme : +Interface unifiée `Read() / Write()` avec implémentations par plateforme. +L'interface optionnelle `ImageClipboard` (`ReadImage` / `WriteImage`) ajoute la +sync d'images PNG (xclip/wl-clipboard sur Linux, CF_DIB sur Windows, AppleScript +sur macOS) : - **Linux** : auto-détection X11/Wayland via `$XDG_SESSION_TYPE` - X11 → `xclip -selection clipboard -o` / `xclip -selection clipboard` diff --git a/CHANGELOG.md b/CHANGELOG.md index e88b253..ac80a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +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) +- 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`) diff --git a/README.md b/README.md index aa11884..b4a4e41 100644 --- a/README.md +++ b/README.md @@ -285,7 +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.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`) | diff --git a/ROADMAP.md b/ROADMAP.md index efe9752..8673e07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,8 +26,9 @@ - [x] Historique du presse-papiers local (derniers N éléments) - [x] Commande `clip-sync history` pour afficher l'historique - [x] Option `--one-shot` : envoi unique sans daemon -- [x] Images copiées/collées via le presse-papiers (PNG) sur Linux - (`daemon.sync_images`) — Windows/macOS à implémenter par plateforme +- [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 (partiellement réalisée) diff --git a/install.sh b/install.sh index bd7ee27..db891bf 100644 --- a/install.sh +++ b/install.sh @@ -102,7 +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) +# sync_images = true # sync images from the clipboard (PNG) # receive_files = true # accept incoming file transfers [security] diff --git a/internal/clipboard/clipboard_darwin.go b/internal/clipboard/clipboard_darwin.go index 77407d6..d5d226e 100644 --- a/internal/clipboard/clipboard_darwin.go +++ b/internal/clipboard/clipboard_darwin.go @@ -4,6 +4,7 @@ package clipboard import ( "fmt" + "os" "os/exec" "strings" ) @@ -43,3 +44,55 @@ func (c *darwinClipboard) Write(text string) error { } return nil } + +// ReadImage reads a PNG image from the clipboard via AppleScript, writing it to +// a temporary file and reading it back. +func (c *darwinClipboard) ReadImage() (string, []byte, error) { + tmp, err := os.CreateTemp("", "clip-sync-*.png") + if err != nil { + return "", nil, err + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + + // AppleScript type code PNGf, wrapped in the chevron quotes AppleScript uses. + script := `set f to (open for access POSIX file "` + tmpPath + `" with write permission) +set eof f to 0 +write (the clipboard as «class PNGf») to f +close access f` + if err := exec.Command("osascript", "-e", script).Run(); err != nil { + return "", nil, ErrNoImage + } + + data, err := os.ReadFile(tmpPath) + if err != nil || len(data) == 0 { + return "", nil, ErrNoImage + } + return "image/png", data, nil +} + +// WriteImage writes a PNG image to the clipboard via AppleScript. +func (c *darwinClipboard) WriteImage(mime string, data []byte) error { + if mime != "" && mime != "image/png" { + return fmt.Errorf("clipboard: unsupported image mime %q (only PNG)", mime) + } + tmp, err := os.CreateTemp("", "clip-sync-*.png") + if err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + + script := `set the clipboard to (read (POSIX file "` + tmpPath + `") as «class PNGf»)` + if err := exec.Command("osascript", "-e", script).Run(); err != nil { + return fmt.Errorf("clipboard write image (osascript): %w", err) + } + return nil +} diff --git a/internal/clipboard/clipboard_windows.go b/internal/clipboard/clipboard_windows.go index 5954e60..686e25c 100644 --- a/internal/clipboard/clipboard_windows.go +++ b/internal/clipboard/clipboard_windows.go @@ -137,3 +137,83 @@ func lockAndWrite(h uintptr, data []uint16) bool { copy(dst, data) return true } + +// ReadImage returns the clipboard image (CF_DIB) encoded as PNG. +func (c *windowsClipboard) ReadImage() (string, []byte, error) { + r1, _, _ := procOpenClipboard.Call(0) + if r1 == 0 { + return "", nil, ErrNoImage + } + defer func() { _, _, _ = procCloseClipboard.Call() }() + + h, _, _ := procGetClipboardData.Call(uintptr(cfDIB)) + if h == 0 { + return "", nil, ErrNoImage + } + + size, _, _ := procGlobalSize.Call(h) + if size == 0 { + return "", nil, ErrNoImage + } + + p, _, _ := procGlobalLock.Call(h) + if p == 0 { + return "", nil, ErrNoImage + } + defer func() { _, _, _ = procGlobalUnlock.Call(h) }() + + // Copy the DIB bytes out of the global block before parsing. + ptr := unsafe.Add(unsafe.Pointer(nil), p) + raw := unsafe.Slice((*byte)(ptr), size) + dib := make([]byte, size) + copy(dib, raw) + + pngBytes, err := dibToPNG(dib) + if err != nil { + return "", nil, err + } + return "image/png", pngBytes, nil +} + +// WriteImage writes a PNG image to the clipboard as a CF_DIB. +func (c *windowsClipboard) WriteImage(mime string, data []byte) error { + if mime != "" && mime != "image/png" { + return fmt.Errorf("clipboard: unsupported image mime %q (only PNG)", mime) + } + dib, err := pngToDIB(data) + if err != nil { + return fmt.Errorf("clipboard: decode image: %w", err) + } + + r1, _, err2 := procOpenClipboard.Call(0) + if r1 == 0 { + return fmt.Errorf("clipboard write image: OpenClipboard: %w", err2) + } + defer func() { _, _, _ = procCloseClipboard.Call() }() + + if r1, _, err2 = procEmptyClipboard.Call(); r1 == 0 { + return fmt.Errorf("clipboard write image: EmptyClipboard: %w", err2) + } + + h, _, err2 := procGlobalAlloc.Call(0, uintptr(len(dib))) + if h == 0 { + return fmt.Errorf("clipboard write image: GlobalAlloc: %w", err2) + } + + p, _, _ := procGlobalLock.Call(h) + if p == 0 { + _, _, _ = procGlobalFree.Call(h) + return fmt.Errorf("clipboard write image: GlobalLock failed") + } + ptr := unsafe.Add(unsafe.Pointer(nil), p) + dst := unsafe.Slice((*byte)(ptr), len(dib)) + copy(dst, dib) + _, _, _ = procGlobalUnlock.Call(h) + + if r1, _, err2 = procSetClipboardData.Call(uintptr(cfDIB), h); r1 == 0 { + _, _, _ = procGlobalFree.Call(h) + return fmt.Errorf("clipboard write image: SetClipboardData: %w", err2) + } + // Windows now owns the handle. + return nil +} diff --git a/internal/clipboard/dib_windows.go b/internal/clipboard/dib_windows.go new file mode 100644 index 0000000..9cd31f1 --- /dev/null +++ b/internal/clipboard/dib_windows.go @@ -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 +} diff --git a/internal/clipboard/dib_windows_test.go b/internal/clipboard/dib_windows_test.go new file mode 100644 index 0000000..d9c810e --- /dev/null +++ b/internal/clipboard/dib_windows_test.go @@ -0,0 +1,72 @@ +//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") + } +} diff --git a/peers.example.toml b/peers.example.toml index 0d4df9b..13ae423 100644 --- a/peers.example.toml +++ b/peers.example.toml @@ -16,7 +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) +# 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