feat: Windows (CF_DIB) and macOS (AppleScript) clipboard image sync
This commit is contained in:
+4
-1
@@ -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`
|
||||||
|
|||||||
+1
-1
@@ -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`
|
- 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)
|
- Sync d'images via le presse-papiers (`daemon.sync_images`, Linux/Windows/macOS)
|
||||||
- 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,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.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.sync_images` | `false` | Sync d'images (PNG) entre pairs |
|
||||||
| `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`) |
|
||||||
|
|||||||
+3
-2
@@ -26,8 +26,9 @@
|
|||||||
- [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
|
||||||
- [x] Images copiées/collées via le presse-papiers (PNG) sur Linux
|
- [x] Images copiées/collées via le presse-papiers (PNG) sur Linux, Windows et
|
||||||
(`daemon.sync_images`) — Windows/macOS à implémenter par plateforme
|
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)
|
## v1.0.0 🌐 Stable (partiellement réalisée)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +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)
|
# sync_images = true # sync images from the clipboard (PNG)
|
||||||
# receive_files = true # accept incoming file transfers
|
# receive_files = true # accept incoming file transfers
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,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,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +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)
|
# sync_images = true # sync images from the clipboard (PNG)
|
||||||
# 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