feat(clipboard): darwin pasteboard image reader via osascript
- commit
- fe5ad84c22204ae5625cda3fa414708eace8abe7
- author
- brice · · 2026-07-16 18:10 UTC
- parent
- a83ab49
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +180 -1
A internal/adapters/clipboard/osascript_darwin.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package clipboard | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os/exec" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const osaReadTimeout = time.Second | |
| 14 | + | ||
| 15 | + | // OSA implements ports.ClipboardReader using the macOS pasteboard through | |
| 16 | + | // osascript. It holds no state and is safe for concurrent use. | |
| 17 | + | type OSA struct{} | |
| 18 | + | ||
| 19 | + | var _ ports.ClipboardReader = (*OSA)(nil) | |
| 20 | + | ||
| 21 | + | // New returns the native clipboard reader for this operating system. | |
| 22 | + | func New() ports.ClipboardReader { return &OSA{} } | |
| 23 | + | ||
| 24 | + | // ReadImage reads the preferred pasteboard image representation. osascript | |
| 25 | + | // reports unavailable pasteboard flavors as command failures; those and all | |
| 26 | + | // malformed outputs are intentionally indistinguishable to callers from an | |
| 27 | + | // empty clipboard. | |
| 28 | + | func (*OSA) ReadImage(ctx context.Context) (string, []byte, error) { | |
| 29 | + | for _, format := range []struct { | |
| 30 | + | class string | |
| 31 | + | mime string | |
| 32 | + | }{ | |
| 33 | + | {class: "PNGf", mime: "image/png"}, | |
| 34 | + | {class: "JPEG", mime: "image/jpeg"}, | |
| 35 | + | } { | |
| 36 | + | commandCtx, cancel := context.WithTimeout(ctx, osaReadTimeout) | |
| 37 | + | out, err := exec.CommandContext(commandCtx, "osascript", "-e", "the clipboard as «class "+format.class+"»").Output() | |
| 38 | + | cancel() | |
| 39 | + | if err != nil { | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | data, err := parseOSAData(string(out), format.class) | |
| 44 | + | if err == nil { | |
| 45 | + | return format.mime, data, nil | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | return "", nil, ports.ErrNoClipboardImage | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | //go:build darwin | |||
| 2 | + | ||||
| 3 | + | package clipboard | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const osaReadTimeout = time.Second | |||
| 14 | + | ||||
| 15 | + | // OSA implements ports.ClipboardReader using the macOS pasteboard through | |||
| 16 | + | // osascript. It holds no state and is safe for concurrent use. | |||
| 17 | + | type OSA struct{} | |||
| 18 | + | ||||
| 19 | + | var _ ports.ClipboardReader = (*OSA)(nil) | |||
| 20 | + | ||||
| 21 | + | // New returns the native clipboard reader for this operating system. | |||
| 22 | + | func New() ports.ClipboardReader { return &OSA{} } | |||
| 23 | + | ||||
| 24 | + | // ReadImage reads the preferred pasteboard image representation. osascript | |||
| 25 | + | // reports unavailable pasteboard flavors as command failures; those and all | |||
| 26 | + | // malformed outputs are intentionally indistinguishable to callers from an | |||
| 27 | + | // empty clipboard. | |||
| 28 | + | func (*OSA) ReadImage(ctx context.Context) (string, []byte, error) { | |||
| 29 | + | for _, format := range []struct { | |||
| 30 | + | class string | |||
| 31 | + | mime string | |||
| 32 | + | }{ | |||
| 33 | + | {class: "PNGf", mime: "image/png"}, | |||
| 34 | + | {class: "JPEG", mime: "image/jpeg"}, | |||
| 35 | + | } { | |||
| 36 | + | commandCtx, cancel := context.WithTimeout(ctx, osaReadTimeout) | |||
| 37 | + | out, err := exec.CommandContext(commandCtx, "osascript", "-e", "the clipboard as «class "+format.class+"»").Output() | |||
| 38 | + | cancel() | |||
| 39 | + | if err != nil { | |||
| 40 | + | continue | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | data, err := parseOSAData(string(out), format.class) | |||
| 44 | + | if err == nil { | |||
| 45 | + | return format.mime, data, nil | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | return "", nil, ports.ErrNoClipboardImage | |||
| 49 | + | } | |||
A internal/adapters/clipboard/osascript_parse.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/hex" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | osaDataPrefix = "«data " | |
| 11 | + | osaDataSuffix = "»" | |
| 12 | + | osaClassLen = 4 | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // parseOSAData decodes osascript's textual representation of an AppleScript | |
| 16 | + | // data value: «data CLASSHEX». osascript terminates successful output with a | |
| 17 | + | // newline, so surrounding whitespace is ignored; all other bytes must belong | |
| 18 | + | // to the data value. | |
| 19 | + | func parseOSAData(output, class string) ([]byte, error) { | |
| 20 | + | if len(class) != osaClassLen { | |
| 21 | + | return nil, errors.New("clipboard: invalid expected osascript data class") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | output = strings.TrimSpace(output) | |
| 25 | + | if !strings.HasPrefix(output, osaDataPrefix) || !strings.HasSuffix(output, osaDataSuffix) { | |
| 26 | + | return nil, errors.New("clipboard: invalid osascript data output") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | payload := strings.TrimSuffix(strings.TrimPrefix(output, osaDataPrefix), osaDataSuffix) | |
| 30 | + | if !strings.HasPrefix(payload, class) { | |
| 31 | + | return nil, errors.New("clipboard: unexpected osascript data class") | |
| 32 | + | } | |
| 33 | + | hexData := strings.TrimPrefix(payload, class) | |
| 34 | + | if hexData == "" { | |
| 35 | + | return nil, errors.New("clipboard: osascript data output has no data") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | data, err := hex.DecodeString(hexData) | |
| 39 | + | if err != nil || len(data) == 0 { | |
| 40 | + | return nil, errors.New("clipboard: invalid osascript data hex") | |
| 41 | + | } | |
| 42 | + | return data, nil | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/hex" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | osaDataPrefix = "«data " | |||
| 11 | + | osaDataSuffix = "»" | |||
| 12 | + | osaClassLen = 4 | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // parseOSAData decodes osascript's textual representation of an AppleScript | |||
| 16 | + | // data value: «data CLASSHEX». osascript terminates successful output with a | |||
| 17 | + | // newline, so surrounding whitespace is ignored; all other bytes must belong | |||
| 18 | + | // to the data value. | |||
| 19 | + | func parseOSAData(output, class string) ([]byte, error) { | |||
| 20 | + | if len(class) != osaClassLen { | |||
| 21 | + | return nil, errors.New("clipboard: invalid expected osascript data class") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | output = strings.TrimSpace(output) | |||
| 25 | + | if !strings.HasPrefix(output, osaDataPrefix) || !strings.HasSuffix(output, osaDataSuffix) { | |||
| 26 | + | return nil, errors.New("clipboard: invalid osascript data output") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | payload := strings.TrimSuffix(strings.TrimPrefix(output, osaDataPrefix), osaDataSuffix) | |||
| 30 | + | if !strings.HasPrefix(payload, class) { | |||
| 31 | + | return nil, errors.New("clipboard: unexpected osascript data class") | |||
| 32 | + | } | |||
| 33 | + | hexData := strings.TrimPrefix(payload, class) | |||
| 34 | + | if hexData == "" { | |||
| 35 | + | return nil, errors.New("clipboard: osascript data output has no data") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | data, err := hex.DecodeString(hexData) | |||
| 39 | + | if err != nil || len(data) == 0 { | |||
| 40 | + | return nil, errors.New("clipboard: invalid osascript data hex") | |||
| 41 | + | } | |||
| 42 | + | return data, nil | |||
| 43 | + | } | |||
A internal/adapters/clipboard/osascript_parse_test.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestParseOSAData(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | output string | |
| 12 | + | class string | |
| 13 | + | want []byte | |
| 14 | + | wantErr bool | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "png data", | |
| 18 | + | output: "«data PNGf89504E470D0A1A0A»\n", | |
| 19 | + | class: "PNGf", | |
| 20 | + | want: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, | |
| 21 | + | }, | |
| 22 | + | { | |
| 23 | + | name: "jpeg data", | |
| 24 | + | output: " «data JPEGffd8ffe000104a464946» ", | |
| 25 | + | class: "JPEG", | |
| 26 | + | want: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46}, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "missing data wrapper", | |
| 30 | + | output: "PNGf89504E47", | |
| 31 | + | class: "PNGf", | |
| 32 | + | wantErr: true, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "trailing output", | |
| 36 | + | output: "«data PNGf89504E47» unexpected", | |
| 37 | + | class: "PNGf", | |
| 38 | + | wantErr: true, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "wrong data class", | |
| 42 | + | output: "«data JPEGffd8»", | |
| 43 | + | class: "PNGf", | |
| 44 | + | wantErr: true, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "missing hex data", | |
| 48 | + | output: "«data PNGf»", | |
| 49 | + | class: "PNGf", | |
| 50 | + | wantErr: true, | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "odd length hex data", | |
| 54 | + | output: "«data PNGf895»", | |
| 55 | + | class: "PNGf", | |
| 56 | + | wantErr: true, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "invalid hex data", | |
| 60 | + | output: "«data PNGf89504ZZ»", | |
| 61 | + | class: "PNGf", | |
| 62 | + | wantErr: true, | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | got, err := parseOSAData(tt.output, tt.class) | |
| 69 | + | if tt.wantErr { | |
| 70 | + | if err == nil { | |
| 71 | + | t.Fatal("parseOSAData() error = nil, want error") | |
| 72 | + | } | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatalf("parseOSAData() error = %v", err) | |
| 77 | + | } | |
| 78 | + | if !bytes.Equal(got, tt.want) { | |
| 79 | + | t.Fatalf("parseOSAData() = %x, want %x", got, tt.want) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestParseOSAData(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | output string | |||
| 12 | + | class string | |||
| 13 | + | want []byte | |||
| 14 | + | wantErr bool | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "png data", | |||
| 18 | + | output: "«data PNGf89504E470D0A1A0A»\n", | |||
| 19 | + | class: "PNGf", | |||
| 20 | + | want: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, | |||
| 21 | + | }, | |||
| 22 | + | { | |||
| 23 | + | name: "jpeg data", | |||
| 24 | + | output: " «data JPEGffd8ffe000104a464946» ", | |||
| 25 | + | class: "JPEG", | |||
| 26 | + | want: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46}, | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "missing data wrapper", | |||
| 30 | + | output: "PNGf89504E47", | |||
| 31 | + | class: "PNGf", | |||
| 32 | + | wantErr: true, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "trailing output", | |||
| 36 | + | output: "«data PNGf89504E47» unexpected", | |||
| 37 | + | class: "PNGf", | |||
| 38 | + | wantErr: true, | |||
| 39 | + | }, | |||
| 40 | + | { | |||
| 41 | + | name: "wrong data class", | |||
| 42 | + | output: "«data JPEGffd8»", | |||
| 43 | + | class: "PNGf", | |||
| 44 | + | wantErr: true, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "missing hex data", | |||
| 48 | + | output: "«data PNGf»", | |||
| 49 | + | class: "PNGf", | |||
| 50 | + | wantErr: true, | |||
| 51 | + | }, | |||
| 52 | + | { | |||
| 53 | + | name: "odd length hex data", | |||
| 54 | + | output: "«data PNGf895»", | |||
| 55 | + | class: "PNGf", | |||
| 56 | + | wantErr: true, | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "invalid hex data", | |||
| 60 | + | output: "«data PNGf89504ZZ»", | |||
| 61 | + | class: "PNGf", | |||
| 62 | + | wantErr: true, | |||
| 63 | + | }, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for _, tt := range tests { | |||
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | + | got, err := parseOSAData(tt.output, tt.class) | |||
| 69 | + | if tt.wantErr { | |||
| 70 | + | if err == nil { | |||
| 71 | + | t.Fatal("parseOSAData() error = nil, want error") | |||
| 72 | + | } | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatalf("parseOSAData() error = %v", err) | |||
| 77 | + | } | |||
| 78 | + | if !bytes.Equal(got, tt.want) { | |||
| 79 | + | t.Fatalf("parseOSAData() = %x, want %x", got, tt.want) | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||
M internal/adapters/clipboard/wlpaste.go +3 -1
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 1 | 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | |
| 2 | 4 | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | |
| 3 | 5 | // attach forward a locally clipped image to the daemon (see | |
| @@ -50,7 +52,7 @@ type WlPaste struct { | |||
| 50 | 52 | ||
| 51 | 53 | // New returns a WlPaste clipboard reader that shells out to the real | |
| 52 | 54 | // wl-paste binary. | |
| 53 | - | func New() *WlPaste { | |
| 55 | + | func New() ports.ClipboardReader { | |
| 54 | 56 | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | |
| 55 | 57 | } | |
| 56 | 58 | ||
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | // Package clipboard implements ports.ClipboardReader over Wayland's | 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | ||
| 2 | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | 4 | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | ||
| 3 | // attach forward a locally clipped image to the daemon (see | 5 | // attach forward a locally clipped image to the daemon (see | ||
| @@ -50,7 +52,7 @@ type WlPaste struct { | |||||
| 50 | 52 | ||||
| 51 | // New returns a WlPaste clipboard reader that shells out to the real | 53 | // New returns a WlPaste clipboard reader that shells out to the real | ||
| 52 | // wl-paste binary. | 54 | // wl-paste binary. | ||
| 53 | - | func New() *WlPaste { | 55 | + | func New() ports.ClipboardReader { |
| 54 | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | 56 | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | ||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
M internal/adapters/clipboard/wlpaste_test.go +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 1 | 3 | package clipboard | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package clipboard | 3 | package clipboard | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||