detached ba4d7c0

Add cross-platform clipboard image paste

commit
ba4d7c0bb0a319e65f744f00d0630e77b2313890
author
patriceckhart · · 2026-09-04 14:42 UTC
parent
bfc7b11
signature
unsigned
13 files · +724 -61
M README.md +1 -1
@@ -898,7 +898,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
898898 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
899899 | `ctrl+d` | Exit on empty input. |
900900 | `ctrl+l` | Redraw the screen. |
901-| `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. |
901+| `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is normalized to PNG and attached to the next prompt on macOS, Windows, Linux Wayland with `wl-paste`, and Linux X11 with `xclip`. Linux text paste also supports `xsel`; terminal-native bracketed text paste remains available without clipboard commands. |
902902 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, glob outputs over ~12 lines). |
903903 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
904904 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -898,7 +898,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
898 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |898 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
899 | `ctrl+d` | Exit on empty input. |899 | `ctrl+d` | Exit on empty input. |
900 | `ctrl+l` | Redraw the screen. |900 | `ctrl+l` | Redraw the screen. |
901-| `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. |901+| `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is normalized to PNG and attached to the next prompt on macOS, Windows, Linux Wayland with `wl-paste`, and Linux X11 with `xclip`. Linux text paste also supports `xsel`; terminal-native bracketed text paste remains available without clipboard commands. |
902 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, glob outputs over ~12 lines). |902 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, glob outputs over ~12 lines). |
903 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |903 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
904 | `@` | Open the file picker. Browse files and directories in the working directory. |904 | `@` | Open the file picker. Browse files and directories in the working directory. |
M packages/agent/modes/clipboard_images.go +14 -0
@@ -1,6 +1,8 @@
11 package modes
22
33 import (
4+ "fmt"
5+ "os"
46 "strings"
57
68 "github.com/patriceckhart/zot/packages/provider"
@@ -11,6 +13,18 @@ type clipboardImageAttachment struct {
1113 Image provider.ImageBlock
1214 }
1315
16+type clipboardImageReader func() (string, []byte, bool, error)
17+
18+func readClipboardImageAndCleanup(read clipboardImageReader) ([]byte, bool, error) {
19+ path, data, ok, err := read()
20+ if path != "" {
21+ if removeErr := os.Remove(path); removeErr != nil && err == nil {
22+ err = fmt.Errorf("remove temporary clipboard image: %w", removeErr)
23+ }
24+ }
25+ return data, ok, err
26+}
27+
1428 func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
1529 if len(pending) == 0 {
1630 return text, nil
@@ -1,6 +1,8 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "fmt"
5+ "os"
4 "strings"6 "strings"
5 7
6 "github.com/patriceckhart/zot/packages/provider"8 "github.com/patriceckhart/zot/packages/provider"
@@ -11,6 +13,18 @@ type clipboardImageAttachment struct {
11 Image provider.ImageBlock13 Image provider.ImageBlock
12 }14 }
13 15
16+type clipboardImageReader func() (string, []byte, bool, error)
17+
18+func readClipboardImageAndCleanup(read clipboardImageReader) ([]byte, bool, error) {
19+ path, data, ok, err := read()
20+ if path != "" {
21+ if removeErr := os.Remove(path); removeErr != nil && err == nil {
22+ err = fmt.Errorf("remove temporary clipboard image: %w", removeErr)
23+ }
24+ }
25+ return data, ok, err
26+}
27+
14 func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {28 func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
15 if len(pending) == 0 {29 if len(pending) == 0 {
16 return text, nil30 return text, nil
M packages/agent/modes/clipboard_images_test.go +22 -0
@@ -1,12 +1,34 @@
11 package modes
22
33 import (
4+ "os"
5+ "path/filepath"
46 "strings"
57 "testing"
68
79 "github.com/patriceckhart/zot/packages/provider"
810 )
911
12+func TestReadClipboardImageAndCleanupRemovesTemporaryFile(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "clipboard.png")
14+ if err := os.WriteFile(path, []byte("png"), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+
18+ data, ok, err := readClipboardImageAndCleanup(func() (string, []byte, bool, error) {
19+ return path, []byte("png"), true, nil
20+ })
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if !ok || string(data) != "png" {
25+ t.Fatalf("ok = %v, data = %q", ok, data)
26+ }
27+ if _, err := os.Stat(path); !os.IsNotExist(err) {
28+ t.Fatalf("temporary image still exists: %v", err)
29+ }
30+}
31+
1032 func testClipboardImage(marker string, data string) clipboardImageAttachment {
1133 return clipboardImageAttachment{
1234 Marker: marker,
@@ -1,12 +1,34 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
4 "strings"6 "strings"
5 "testing"7 "testing"
6 8
7 "github.com/patriceckhart/zot/packages/provider"9 "github.com/patriceckhart/zot/packages/provider"
8 )10 )
9 11
12+func TestReadClipboardImageAndCleanupRemovesTemporaryFile(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "clipboard.png")
14+ if err := os.WriteFile(path, []byte("png"), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+
18+ data, ok, err := readClipboardImageAndCleanup(func() (string, []byte, bool, error) {
19+ return path, []byte("png"), true, nil
20+ })
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if !ok || string(data) != "png" {
25+ t.Fatalf("ok = %v, data = %q", ok, data)
26+ }
27+ if _, err := os.Stat(path); !os.IsNotExist(err) {
28+ t.Fatalf("temporary image still exists: %v", err)
29+ }
30+}
31+
10 func testClipboardImage(marker string, data string) clipboardImageAttachment {32 func testClipboardImage(marker string, data string) clipboardImageAttachment {
11 return clipboardImageAttachment{33 return clipboardImageAttachment{
12 Marker: marker,34 Marker: marker,
M packages/agent/modes/interactive.go +1 -1
@@ -2815,7 +2815,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
28152815 }
28162816
28172817 func (i *Interactive) pasteClipboard() {
2818- _, data, ok, err := tui.ReadClipboardImagePNG()
2818+ data, ok, err := readClipboardImageAndCleanup(tui.ReadClipboardImagePNG)
28192819 if err != nil {
28202820 i.mu.Lock()
28212821 i.statusErr = "clipboard paste failed: " + err.Error()
@@ -2815,7 +2815,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2815 }2815 }
2816 2816
2817 func (i *Interactive) pasteClipboard() {2817 func (i *Interactive) pasteClipboard() {
2818- _, data, ok, err := tui.ReadClipboardImagePNG()2818+ data, ok, err := readClipboardImageAndCleanup(tui.ReadClipboardImagePNG)
2819 if err != nil {2819 if err != nil {
2820 i.mu.Lock()2820 i.mu.Lock()
2821 i.statusErr = "clipboard paste failed: " + err.Error()2821 i.statusErr = "clipboard paste failed: " + err.Error()
M packages/tui/clipboard_darwin.go +24 -58
@@ -3,17 +3,15 @@
33 package tui
44
55 import (
6+ "context"
67 "crypto/rand"
78 "encoding/hex"
89 "fmt"
9- "image/png"
1010 "os"
1111 "os/exec"
1212 "path/filepath"
1313 "strings"
1414 "time"
15-
16- "golang.org/x/image/tiff"
1715 )
1816
1917 const readClipboardImageScript = `
@@ -50,7 +48,10 @@ end run
5048 func ReadClipboardImagePNG() (string, []byte, bool, error) {
5149 dir := clipboardImageDir()
5250 if err := os.MkdirAll(dir, 0o700); err != nil {
53- return "", nil, false, err
51+ return "", nil, false, fmt.Errorf("create clipboard image directory: %w", err)
52+ }
53+ if err := os.Chmod(dir, 0o700); err != nil {
54+ return "", nil, false, fmt.Errorf("secure clipboard image directory: %w", err)
5455 }
5556 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
5657 rawPath := path + ".raw"
@@ -64,36 +65,37 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
6465 return "", nil, false, nil
6566 }
6667
67- switch kind {
68- case "png":
69- if err := os.Rename(rawPath, path); err != nil {
70- return "", nil, false, err
71- }
72- case "tiff":
73- if err := convertTIFFFileToPNG(rawPath, path); err != nil {
74- return "", nil, false, err
75- }
76- default:
77- clipPath, ok := findClipboardImagePath(kind)
68+ sourcePath := rawPath
69+ if kind != "png" && kind != "tiff" {
70+ var ok bool
71+ sourcePath, ok = findClipboardImagePath(kind)
7872 if !ok {
7973 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
8074 }
81- if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
82- return "", nil, false, err
83- }
8475 }
85-
86- data, err := os.ReadFile(path)
76+ raw, err := readClipboardImageFile(sourcePath)
8777 if err != nil {
88- return "", nil, false, err
78+ return "", nil, false, fmt.Errorf("read clipboard image: %w", err)
79+ }
80+ data, err := normalizeClipboardImagePNG(raw)
81+ if err != nil {
82+ return "", nil, false, fmt.Errorf("invalid clipboard image: %w", err)
83+ }
84+ if err := writeClipboardPNGFile(path, data); err != nil {
85+ return "", nil, false, fmt.Errorf("write clipboard PNG: %w", err)
8986 }
9087 return path, data, true, nil
9188 }
9289
9390 func writeClipboardImageData(path string) (string, error) {
94- cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
91+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
92+ defer cancel()
93+ cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
9594 out, err := cmd.CombinedOutput()
9695 trimmed := strings.TrimSpace(string(out))
96+ if ctx.Err() != nil {
97+ return "", fmt.Errorf("osascript timed out: %w", ctx.Err())
98+ }
9799 if err != nil {
98100 if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
99101 return "", nil
@@ -130,21 +132,6 @@ func clipboardImageKind(s string) (string, bool) {
130132 return "", false
131133 }
132134
133-func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
134- switch strings.ToLower(filepath.Ext(srcPath)) {
135- case ".png":
136- data, err := os.ReadFile(srcPath)
137- if err != nil {
138- return err
139- }
140- return os.WriteFile(dstPath, data, 0o600)
141- case ".tif", ".tiff":
142- return convertTIFFFileToPNG(srcPath, dstPath)
143- default:
144- return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath)
145- }
146-}
147-
148135 func findClipboardImagePath(s string) (string, bool) {
149136 if p, ok := clipboardImagePath(s); ok {
150137 return p, true
@@ -192,27 +179,6 @@ func clipboardImagePath(s string) (string, bool) {
192179 }
193180 }
194181
195-func convertTIFFFileToPNG(srcPath, dstPath string) error {
196- in, err := os.Open(srcPath)
197- if err != nil {
198- return err
199- }
200- defer in.Close()
201-
202- img, err := tiff.Decode(in)
203- if err != nil {
204- return err
205- }
206-
207- out, err := os.Create(dstPath)
208- if err != nil {
209- return err
210- }
211- defer out.Close()
212-
213- return png.Encode(out, img)
214-}
215-
216182 func clipboardImageDir() string {
217183 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
218184 return filepath.Join("/tmp", "zot-clipboard-images")
@@ -3,17 +3,15 @@
3 package tui3 package tui
4 4
5 import (5 import (
6+ "context"
6 "crypto/rand"7 "crypto/rand"
7 "encoding/hex"8 "encoding/hex"
8 "fmt"9 "fmt"
9- "image/png"
10 "os"10 "os"
11 "os/exec"11 "os/exec"
12 "path/filepath"12 "path/filepath"
13 "strings"13 "strings"
14 "time"14 "time"
15-
16- "golang.org/x/image/tiff"
17 )15 )
18 16
19 const readClipboardImageScript = `17 const readClipboardImageScript = `
@@ -50,7 +48,10 @@ end run
50 func ReadClipboardImagePNG() (string, []byte, bool, error) {48 func ReadClipboardImagePNG() (string, []byte, bool, error) {
51 dir := clipboardImageDir()49 dir := clipboardImageDir()
52 if err := os.MkdirAll(dir, 0o700); err != nil {50 if err := os.MkdirAll(dir, 0o700); err != nil {
53- return "", nil, false, err51+ return "", nil, false, fmt.Errorf("create clipboard image directory: %w", err)
52+ }
53+ if err := os.Chmod(dir, 0o700); err != nil {
54+ return "", nil, false, fmt.Errorf("secure clipboard image directory: %w", err)
54 }55 }
55 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")56 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
56 rawPath := path + ".raw"57 rawPath := path + ".raw"
@@ -64,36 +65,37 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) {
64 return "", nil, false, nil65 return "", nil, false, nil
65 }66 }
66 67
67- switch kind {68+ sourcePath := rawPath
68- case "png":69+ if kind != "png" && kind != "tiff" {
69- if err := os.Rename(rawPath, path); err != nil {70+ var ok bool
70- return "", nil, false, err71+ sourcePath, ok = findClipboardImagePath(kind)
71- }
72- case "tiff":
73- if err := convertTIFFFileToPNG(rawPath, path); err != nil {
74- return "", nil, false, err
75- }
76- default:
77- clipPath, ok := findClipboardImagePath(kind)
78 if !ok {72 if !ok {
79 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)73 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
80 }74 }
81- if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
82- return "", nil, false, err
83- }
84 }75 }
85-76+ raw, err := readClipboardImageFile(sourcePath)
86- data, err := os.ReadFile(path)
87 if err != nil {77 if err != nil {
88- return "", nil, false, err78+ return "", nil, false, fmt.Errorf("read clipboard image: %w", err)
79+ }
80+ data, err := normalizeClipboardImagePNG(raw)
81+ if err != nil {
82+ return "", nil, false, fmt.Errorf("invalid clipboard image: %w", err)
83+ }
84+ if err := writeClipboardPNGFile(path, data); err != nil {
85+ return "", nil, false, fmt.Errorf("write clipboard PNG: %w", err)
89 }86 }
90 return path, data, true, nil87 return path, data, true, nil
91 }88 }
92 89
93 func writeClipboardImageData(path string) (string, error) {90 func writeClipboardImageData(path string) (string, error) {
94- cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)91+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
92+ defer cancel()
93+ cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
95 out, err := cmd.CombinedOutput()94 out, err := cmd.CombinedOutput()
96 trimmed := strings.TrimSpace(string(out))95 trimmed := strings.TrimSpace(string(out))
96+ if ctx.Err() != nil {
97+ return "", fmt.Errorf("osascript timed out: %w", ctx.Err())
98+ }
97 if err != nil {99 if err != nil {
98 if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {100 if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
99 return "", nil101 return "", nil
@@ -130,21 +132,6 @@ func clipboardImageKind(s string) (string, bool) {
130 return "", false132 return "", false
131 }133 }
132 134
133-func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
134- switch strings.ToLower(filepath.Ext(srcPath)) {
135- case ".png":
136- data, err := os.ReadFile(srcPath)
137- if err != nil {
138- return err
139- }
140- return os.WriteFile(dstPath, data, 0o600)
141- case ".tif", ".tiff":
142- return convertTIFFFileToPNG(srcPath, dstPath)
143- default:
144- return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath)
145- }
146-}
147-
148 func findClipboardImagePath(s string) (string, bool) {135 func findClipboardImagePath(s string) (string, bool) {
149 if p, ok := clipboardImagePath(s); ok {136 if p, ok := clipboardImagePath(s); ok {
150 return p, true137 return p, true
@@ -192,27 +179,6 @@ func clipboardImagePath(s string) (string, bool) {
192 }179 }
193 }180 }
194 181
195-func convertTIFFFileToPNG(srcPath, dstPath string) error {
196- in, err := os.Open(srcPath)
197- if err != nil {
198- return err
199- }
200- defer in.Close()
201-
202- img, err := tiff.Decode(in)
203- if err != nil {
204- return err
205- }
206-
207- out, err := os.Create(dstPath)
208- if err != nil {
209- return err
210- }
211- defer out.Close()
212-
213- return png.Encode(out, img)
214-}
215-
216 func clipboardImageDir() string {182 func clipboardImageDir() string {
217 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {183 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
218 return filepath.Join("/tmp", "zot-clipboard-images")184 return filepath.Join("/tmp", "zot-clipboard-images")
A packages/tui/clipboard_image.go +124 -0
@@ -0,0 +1,124 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "image"
8+ _ "image/gif"
9+ _ "image/jpeg"
10+ "image/png"
11+ "io"
12+ "os"
13+
14+ _ "golang.org/x/image/tiff"
15+ _ "golang.org/x/image/webp"
16+)
17+
18+const (
19+ maxClipboardImageBytes = 32 << 20
20+ maxClipboardImagePixels = 25_000_000
21+ maxNormalizedImageBytes = 64 << 20
22+ maxClipboardMetadataBytes = 1 << 20
23+ maxClipboardDiagnosticBytes = 16 << 10
24+)
25+
26+var errClipboardImageTooLarge = errors.New("clipboard image is too large")
27+
28+// normalizeClipboardImagePNG validates an image and converts it to PNG. The
29+// limits keep an untrusted clipboard owner from making zot allocate without
30+// bound while probing the clipboard.
31+func normalizeClipboardImagePNG(data []byte) ([]byte, error) {
32+ return normalizeClipboardImagePNGWithLimits(data, maxClipboardImageBytes, maxClipboardImagePixels, maxNormalizedImageBytes)
33+}
34+
35+func normalizeClipboardImagePNGWithLimits(data []byte, maxInput, maxPixels, maxOutput int) ([]byte, error) {
36+ if len(data) == 0 {
37+ return nil, errors.New("clipboard image is empty")
38+ }
39+ if len(data) > maxInput {
40+ return nil, fmt.Errorf("%w (maximum input is %d bytes)", errClipboardImageTooLarge, maxInput)
41+ }
42+
43+ cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
44+ if err != nil {
45+ return nil, fmt.Errorf("decode clipboard image metadata: %w", err)
46+ }
47+ if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxPixels/cfg.Height {
48+ return nil, fmt.Errorf("%w (maximum is %d pixels)", errClipboardImageTooLarge, maxPixels)
49+ }
50+
51+ img, _, err := image.Decode(bytes.NewReader(data))
52+ if err != nil {
53+ return nil, fmt.Errorf("decode clipboard image: %w", err)
54+ }
55+ var out cappedBuffer
56+ out.max = maxOutput
57+ if err := png.Encode(&out, img); err != nil {
58+ return nil, fmt.Errorf("encode clipboard image as PNG: %w", err)
59+ }
60+ if out.overflow {
61+ return nil, fmt.Errorf("%w (maximum normalized size is %d bytes)", errClipboardImageTooLarge, maxOutput)
62+ }
63+ return append([]byte(nil), out.Bytes()...), nil
64+}
65+
66+// cappedBuffer retains at most max bytes while reporting successful writes.
67+// Reporting the full write prevents child processes and encoders from failing
68+// with an unrelated short-write error after the configured limit is reached.
69+type cappedBuffer struct {
70+ buf bytes.Buffer
71+ max int
72+ overflow bool
73+}
74+
75+func (b *cappedBuffer) Write(p []byte) (int, error) {
76+ n := len(p)
77+ remaining := b.max - b.buf.Len()
78+ if remaining > 0 {
79+ if remaining > len(p) {
80+ remaining = len(p)
81+ }
82+ _, _ = b.buf.Write(p[:remaining])
83+ }
84+ if remaining < len(p) {
85+ b.overflow = true
86+ }
87+ return n, nil
88+}
89+
90+func (b *cappedBuffer) Bytes() []byte { return b.buf.Bytes() }
91+func (b *cappedBuffer) String() string { return b.buf.String() }
92+
93+func readClipboardImageFile(path string) ([]byte, error) {
94+ f, err := os.Open(path)
95+ if err != nil {
96+ return nil, err
97+ }
98+ defer f.Close()
99+ data, err := io.ReadAll(io.LimitReader(f, maxClipboardImageBytes+1))
100+ if err != nil {
101+ return nil, err
102+ }
103+ if len(data) > maxClipboardImageBytes {
104+ return nil, fmt.Errorf("%w (maximum input is %d bytes)", errClipboardImageTooLarge, maxClipboardImageBytes)
105+ }
106+ return data, nil
107+}
108+
109+func writeClipboardPNGFile(path string, data []byte) error {
110+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
111+ if err != nil {
112+ return err
113+ }
114+ if _, err := f.Write(data); err != nil {
115+ _ = f.Close()
116+ _ = os.Remove(path)
117+ return err
118+ }
119+ if err := f.Close(); err != nil {
120+ _ = os.Remove(path)
121+ return err
122+ }
123+ return nil
124+}
@@ -0,0 +1,124 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "image"
8+ _ "image/gif"
9+ _ "image/jpeg"
10+ "image/png"
11+ "io"
12+ "os"
13+
14+ _ "golang.org/x/image/tiff"
15+ _ "golang.org/x/image/webp"
16+)
17+
18+const (
19+ maxClipboardImageBytes = 32 << 20
20+ maxClipboardImagePixels = 25_000_000
21+ maxNormalizedImageBytes = 64 << 20
22+ maxClipboardMetadataBytes = 1 << 20
23+ maxClipboardDiagnosticBytes = 16 << 10
24+)
25+
26+var errClipboardImageTooLarge = errors.New("clipboard image is too large")
27+
28+// normalizeClipboardImagePNG validates an image and converts it to PNG. The
29+// limits keep an untrusted clipboard owner from making zot allocate without
30+// bound while probing the clipboard.
31+func normalizeClipboardImagePNG(data []byte) ([]byte, error) {
32+ return normalizeClipboardImagePNGWithLimits(data, maxClipboardImageBytes, maxClipboardImagePixels, maxNormalizedImageBytes)
33+}
34+
35+func normalizeClipboardImagePNGWithLimits(data []byte, maxInput, maxPixels, maxOutput int) ([]byte, error) {
36+ if len(data) == 0 {
37+ return nil, errors.New("clipboard image is empty")
38+ }
39+ if len(data) > maxInput {
40+ return nil, fmt.Errorf("%w (maximum input is %d bytes)", errClipboardImageTooLarge, maxInput)
41+ }
42+
43+ cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
44+ if err != nil {
45+ return nil, fmt.Errorf("decode clipboard image metadata: %w", err)
46+ }
47+ if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxPixels/cfg.Height {
48+ return nil, fmt.Errorf("%w (maximum is %d pixels)", errClipboardImageTooLarge, maxPixels)
49+ }
50+
51+ img, _, err := image.Decode(bytes.NewReader(data))
52+ if err != nil {
53+ return nil, fmt.Errorf("decode clipboard image: %w", err)
54+ }
55+ var out cappedBuffer
56+ out.max = maxOutput
57+ if err := png.Encode(&out, img); err != nil {
58+ return nil, fmt.Errorf("encode clipboard image as PNG: %w", err)
59+ }
60+ if out.overflow {
61+ return nil, fmt.Errorf("%w (maximum normalized size is %d bytes)", errClipboardImageTooLarge, maxOutput)
62+ }
63+ return append([]byte(nil), out.Bytes()...), nil
64+}
65+
66+// cappedBuffer retains at most max bytes while reporting successful writes.
67+// Reporting the full write prevents child processes and encoders from failing
68+// with an unrelated short-write error after the configured limit is reached.
69+type cappedBuffer struct {
70+ buf bytes.Buffer
71+ max int
72+ overflow bool
73+}
74+
75+func (b *cappedBuffer) Write(p []byte) (int, error) {
76+ n := len(p)
77+ remaining := b.max - b.buf.Len()
78+ if remaining > 0 {
79+ if remaining > len(p) {
80+ remaining = len(p)
81+ }
82+ _, _ = b.buf.Write(p[:remaining])
83+ }
84+ if remaining < len(p) {
85+ b.overflow = true
86+ }
87+ return n, nil
88+}
89+
90+func (b *cappedBuffer) Bytes() []byte { return b.buf.Bytes() }
91+func (b *cappedBuffer) String() string { return b.buf.String() }
92+
93+func readClipboardImageFile(path string) ([]byte, error) {
94+ f, err := os.Open(path)
95+ if err != nil {
96+ return nil, err
97+ }
98+ defer f.Close()
99+ data, err := io.ReadAll(io.LimitReader(f, maxClipboardImageBytes+1))
100+ if err != nil {
101+ return nil, err
102+ }
103+ if len(data) > maxClipboardImageBytes {
104+ return nil, fmt.Errorf("%w (maximum input is %d bytes)", errClipboardImageTooLarge, maxClipboardImageBytes)
105+ }
106+ return data, nil
107+}
108+
109+func writeClipboardPNGFile(path string, data []byte) error {
110+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
111+ if err != nil {
112+ return err
113+ }
114+ if _, err := f.Write(data); err != nil {
115+ _ = f.Close()
116+ _ = os.Remove(path)
117+ return err
118+ }
119+ if err := f.Close(); err != nil {
120+ _ = os.Remove(path)
121+ return err
122+ }
123+ return nil
124+}
A packages/tui/clipboard_image_command.go +49 -0
@@ -0,0 +1,49 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os/exec"
8+ "strings"
9+ "time"
10+)
11+
12+var errClipboardImageCommandUnavailable = errors.New("clipboard image command is unavailable")
13+
14+// runClipboardImageCommand keeps binary stdout separate from diagnostics and
15+// caps both streams. Clipboard utilities can be controlled by another process,
16+// so neither their output size nor their run time is trusted.
17+func runClipboardImageCommand(name string, maxOutput int, args ...string) ([]byte, error) {
18+ path, err := exec.LookPath(name)
19+ if err != nil {
20+ return nil, errClipboardImageCommandUnavailable
21+ }
22+
23+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
24+ defer cancel()
25+ var stdout, stderr cappedBuffer
26+ stdout.max = maxOutput
27+ stderr.max = maxClipboardDiagnosticBytes
28+ cmd := exec.CommandContext(ctx, path, args...)
29+ cmd.Stdout = &stdout
30+ cmd.Stderr = &stderr
31+ err = cmd.Run()
32+ if ctx.Err() != nil {
33+ return nil, fmt.Errorf("%s timed out: %w", name, ctx.Err())
34+ }
35+ if stdout.overflow {
36+ return nil, fmt.Errorf("%s output exceeds %d bytes", name, maxOutput)
37+ }
38+ if err != nil {
39+ diagnostic := strings.TrimSpace(stderr.String())
40+ if diagnostic == "" {
41+ return nil, fmt.Errorf("%s failed: %w", name, err)
42+ }
43+ if stderr.overflow {
44+ diagnostic += " (truncated)"
45+ }
46+ return nil, fmt.Errorf("%s failed: %w: %s", name, err, diagnostic)
47+ }
48+ return append([]byte(nil), stdout.Bytes()...), nil
49+}
@@ -0,0 +1,49 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os/exec"
8+ "strings"
9+ "time"
10+)
11+
12+var errClipboardImageCommandUnavailable = errors.New("clipboard image command is unavailable")
13+
14+// runClipboardImageCommand keeps binary stdout separate from diagnostics and
15+// caps both streams. Clipboard utilities can be controlled by another process,
16+// so neither their output size nor their run time is trusted.
17+func runClipboardImageCommand(name string, maxOutput int, args ...string) ([]byte, error) {
18+ path, err := exec.LookPath(name)
19+ if err != nil {
20+ return nil, errClipboardImageCommandUnavailable
21+ }
22+
23+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
24+ defer cancel()
25+ var stdout, stderr cappedBuffer
26+ stdout.max = maxOutput
27+ stderr.max = maxClipboardDiagnosticBytes
28+ cmd := exec.CommandContext(ctx, path, args...)
29+ cmd.Stdout = &stdout
30+ cmd.Stderr = &stderr
31+ err = cmd.Run()
32+ if ctx.Err() != nil {
33+ return nil, fmt.Errorf("%s timed out: %w", name, ctx.Err())
34+ }
35+ if stdout.overflow {
36+ return nil, fmt.Errorf("%s output exceeds %d bytes", name, maxOutput)
37+ }
38+ if err != nil {
39+ diagnostic := strings.TrimSpace(stderr.String())
40+ if diagnostic == "" {
41+ return nil, fmt.Errorf("%s failed: %w", name, err)
42+ }
43+ if stderr.overflow {
44+ diagnostic += " (truncated)"
45+ }
46+ return nil, fmt.Errorf("%s failed: %w: %s", name, err, diagnostic)
47+ }
48+ return append([]byte(nil), stdout.Bytes()...), nil
49+}
A packages/tui/clipboard_image_test.go +121 -0
@@ -0,0 +1,121 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "image"
7+ "image/color"
8+ "image/jpeg"
9+ "image/png"
10+ "os"
11+ "path/filepath"
12+ "runtime"
13+ "testing"
14+)
15+
16+func testClipboardPNG(t *testing.T) []byte {
17+ t.Helper()
18+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
19+ img.Set(0, 0, color.RGBA{R: 255, A: 255})
20+ img.Set(1, 0, color.RGBA{G: 255, A: 255})
21+ var buf bytes.Buffer
22+ if err := png.Encode(&buf, img); err != nil {
23+ t.Fatal(err)
24+ }
25+ return buf.Bytes()
26+}
27+
28+func TestNormalizeClipboardImagePNGValidatesPNG(t *testing.T) {
29+ got, err := normalizeClipboardImagePNG(testClipboardPNG(t))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if _, format, err := image.Decode(bytes.NewReader(got)); err != nil || format != "png" {
34+ t.Fatalf("decoded format = %q, err = %v", format, err)
35+ }
36+}
37+
38+func TestNormalizeClipboardImagePNGConvertsJPEG(t *testing.T) {
39+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
40+ var jpegData bytes.Buffer
41+ if err := jpeg.Encode(&jpegData, img, nil); err != nil {
42+ t.Fatal(err)
43+ }
44+
45+ got, err := normalizeClipboardImagePNG(jpegData.Bytes())
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if _, format, err := image.Decode(bytes.NewReader(got)); err != nil || format != "png" {
50+ t.Fatalf("decoded format = %q, err = %v", format, err)
51+ }
52+}
53+
54+func TestNormalizeClipboardImagePNGRejectsMalformedData(t *testing.T) {
55+ if _, err := normalizeClipboardImagePNG([]byte("not an image")); err == nil {
56+ t.Fatal("normalizeClipboardImagePNG accepted malformed data")
57+ }
58+}
59+
60+func TestNormalizeClipboardImagePNGEnforcesLimits(t *testing.T) {
61+ data := testClipboardPNG(t)
62+ if _, err := normalizeClipboardImagePNGWithLimits(data, len(data)-1, 100, 1024); !errors.Is(err, errClipboardImageTooLarge) {
63+ t.Fatalf("input limit error = %v", err)
64+ }
65+ if _, err := normalizeClipboardImagePNGWithLimits(data, len(data), 3, 1024); !errors.Is(err, errClipboardImageTooLarge) {
66+ t.Fatalf("pixel limit error = %v", err)
67+ }
68+ if _, err := normalizeClipboardImagePNGWithLimits(data, len(data), 100, 1); !errors.Is(err, errClipboardImageTooLarge) {
69+ t.Fatalf("output limit error = %v", err)
70+ }
71+}
72+
73+func TestWriteClipboardPNGFileUsesRestrictivePermissions(t *testing.T) {
74+ path := filepath.Join(t.TempDir(), "clipboard.png")
75+ if err := writeClipboardPNGFile(path, testClipboardPNG(t)); err != nil {
76+ t.Fatal(err)
77+ }
78+ info, err := os.Stat(path)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ if got := info.Mode().Perm(); got&0o077 != 0 {
83+ t.Fatalf("file permissions = %o, want no group or other access", got)
84+ }
85+}
86+
87+func TestRunClipboardImageCommandSeparatesStderr(t *testing.T) {
88+ if runtime.GOOS == "windows" {
89+ t.Skip("test helper is a shell script")
90+ }
91+ dir := t.TempDir()
92+ path := filepath.Join(dir, "clipboard-image-test")
93+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf payload\nprintf diagnostic >&2\n"), 0o700); err != nil {
94+ t.Fatal(err)
95+ }
96+ t.Setenv("PATH", dir)
97+
98+ got, err := runClipboardImageCommand("clipboard-image-test", 100)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if string(got) != "payload" {
103+ t.Fatalf("output = %q, want payload", got)
104+ }
105+}
106+
107+func TestRunClipboardImageCommandCapsOutput(t *testing.T) {
108+ if runtime.GOOS == "windows" {
109+ t.Skip("test helper is a shell script")
110+ }
111+ dir := t.TempDir()
112+ path := filepath.Join(dir, "clipboard-image-test")
113+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'too much data'\n"), 0o700); err != nil {
114+ t.Fatal(err)
115+ }
116+ t.Setenv("PATH", dir)
117+
118+ if _, err := runClipboardImageCommand("clipboard-image-test", 3); err == nil {
119+ t.Fatal("runClipboardImageCommand accepted oversized output")
120+ }
121+}
@@ -0,0 +1,121 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "image"
7+ "image/color"
8+ "image/jpeg"
9+ "image/png"
10+ "os"
11+ "path/filepath"
12+ "runtime"
13+ "testing"
14+)
15+
16+func testClipboardPNG(t *testing.T) []byte {
17+ t.Helper()
18+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
19+ img.Set(0, 0, color.RGBA{R: 255, A: 255})
20+ img.Set(1, 0, color.RGBA{G: 255, A: 255})
21+ var buf bytes.Buffer
22+ if err := png.Encode(&buf, img); err != nil {
23+ t.Fatal(err)
24+ }
25+ return buf.Bytes()
26+}
27+
28+func TestNormalizeClipboardImagePNGValidatesPNG(t *testing.T) {
29+ got, err := normalizeClipboardImagePNG(testClipboardPNG(t))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if _, format, err := image.Decode(bytes.NewReader(got)); err != nil || format != "png" {
34+ t.Fatalf("decoded format = %q, err = %v", format, err)
35+ }
36+}
37+
38+func TestNormalizeClipboardImagePNGConvertsJPEG(t *testing.T) {
39+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
40+ var jpegData bytes.Buffer
41+ if err := jpeg.Encode(&jpegData, img, nil); err != nil {
42+ t.Fatal(err)
43+ }
44+
45+ got, err := normalizeClipboardImagePNG(jpegData.Bytes())
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if _, format, err := image.Decode(bytes.NewReader(got)); err != nil || format != "png" {
50+ t.Fatalf("decoded format = %q, err = %v", format, err)
51+ }
52+}
53+
54+func TestNormalizeClipboardImagePNGRejectsMalformedData(t *testing.T) {
55+ if _, err := normalizeClipboardImagePNG([]byte("not an image")); err == nil {
56+ t.Fatal("normalizeClipboardImagePNG accepted malformed data")
57+ }
58+}
59+
60+func TestNormalizeClipboardImagePNGEnforcesLimits(t *testing.T) {
61+ data := testClipboardPNG(t)
62+ if _, err := normalizeClipboardImagePNGWithLimits(data, len(data)-1, 100, 1024); !errors.Is(err, errClipboardImageTooLarge) {
63+ t.Fatalf("input limit error = %v", err)
64+ }
65+ if _, err := normalizeClipboardImagePNGWithLimits(data, len(data), 3, 1024); !errors.Is(err, errClipboardImageTooLarge) {
66+ t.Fatalf("pixel limit error = %v", err)
67+ }
68+ if _, err := normalizeClipboardImagePNGWithLimits(data, len(data), 100, 1); !errors.Is(err, errClipboardImageTooLarge) {
69+ t.Fatalf("output limit error = %v", err)
70+ }
71+}
72+
73+func TestWriteClipboardPNGFileUsesRestrictivePermissions(t *testing.T) {
74+ path := filepath.Join(t.TempDir(), "clipboard.png")
75+ if err := writeClipboardPNGFile(path, testClipboardPNG(t)); err != nil {
76+ t.Fatal(err)
77+ }
78+ info, err := os.Stat(path)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ if got := info.Mode().Perm(); got&0o077 != 0 {
83+ t.Fatalf("file permissions = %o, want no group or other access", got)
84+ }
85+}
86+
87+func TestRunClipboardImageCommandSeparatesStderr(t *testing.T) {
88+ if runtime.GOOS == "windows" {
89+ t.Skip("test helper is a shell script")
90+ }
91+ dir := t.TempDir()
92+ path := filepath.Join(dir, "clipboard-image-test")
93+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf payload\nprintf diagnostic >&2\n"), 0o700); err != nil {
94+ t.Fatal(err)
95+ }
96+ t.Setenv("PATH", dir)
97+
98+ got, err := runClipboardImageCommand("clipboard-image-test", 100)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if string(got) != "payload" {
103+ t.Fatalf("output = %q, want payload", got)
104+ }
105+}
106+
107+func TestRunClipboardImageCommandCapsOutput(t *testing.T) {
108+ if runtime.GOOS == "windows" {
109+ t.Skip("test helper is a shell script")
110+ }
111+ dir := t.TempDir()
112+ path := filepath.Join(dir, "clipboard-image-test")
113+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'too much data'\n"), 0o700); err != nil {
114+ t.Fatal(err)
115+ }
116+ t.Setenv("PATH", dir)
117+
118+ if _, err := runClipboardImageCommand("clipboard-image-test", 3); err == nil {
119+ t.Fatal("runClipboardImageCommand accepted oversized output")
120+ }
121+}
A packages/tui/clipboard_linux.go +113 -0
@@ -0,0 +1,113 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "strings"
10+)
11+
12+type linuxImageClipboardBackend struct {
13+ name string
14+ listArgs []string
15+ readArgs func(string) []string
16+}
17+
18+// ReadClipboardImagePNG reads and normalizes an image from a Wayland or X11
19+// clipboard. Small command-line clients preserve zot's CGO-free build.
20+func ReadClipboardImagePNG() (string, []byte, bool, error) {
21+ backends := linuxImageClipboardBackends()
22+ var lastErr error
23+ found := false
24+ for _, backend := range backends {
25+ data, ok, err := readLinuxClipboardImage(backend)
26+ if errors.Is(err, errClipboardImageCommandUnavailable) {
27+ continue
28+ }
29+ found = true
30+ if err != nil {
31+ lastErr = err
32+ continue
33+ }
34+ if !ok {
35+ return "", nil, false, nil
36+ }
37+ pngData, err := normalizeClipboardImagePNG(data)
38+ if err != nil {
39+ return "", nil, false, fmt.Errorf("invalid clipboard image: %w", err)
40+ }
41+ return "", pngData, true, nil
42+ }
43+ if found && lastErr != nil {
44+ return "", nil, false, fmt.Errorf("read image clipboard: %w", lastErr)
45+ }
46+ return "", nil, false, nil
47+}
48+
49+func linuxImageClipboardBackends() []linuxImageClipboardBackend {
50+ wayland := linuxImageClipboardBackend{
51+ name: "wl-paste",
52+ listArgs: []string{"--list-types"},
53+ readArgs: func(mime string) []string { return []string{"--no-newline", "--type", mime} },
54+ }
55+ xclip := linuxImageClipboardBackend{
56+ name: "xclip",
57+ listArgs: []string{"-selection", "clipboard", "-target", "TARGETS", "-out"},
58+ readArgs: func(mime string) []string { return []string{"-selection", "clipboard", "-target", mime, "-out"} },
59+ }
60+
61+ var backends []linuxImageClipboardBackend
62+ if os.Getenv("WAYLAND_DISPLAY") != "" {
63+ backends = append(backends, wayland)
64+ }
65+ if os.Getenv("DISPLAY") != "" {
66+ backends = append(backends, xclip)
67+ }
68+ if len(backends) == 0 {
69+ backends = []linuxImageClipboardBackend{wayland, xclip}
70+ }
71+ return backends
72+}
73+
74+func readLinuxClipboardImage(backend linuxImageClipboardBackend) ([]byte, bool, error) {
75+ types, err := runClipboardImageCommand(backend.name, maxClipboardMetadataBytes, backend.listArgs...)
76+ if err != nil {
77+ return nil, false, err
78+ }
79+ mime, ok := preferredClipboardImageMIME(string(types))
80+ if !ok {
81+ return nil, false, nil
82+ }
83+ data, err := runClipboardImageCommand(backend.name, maxClipboardImageBytes, backend.readArgs(mime)...)
84+ if err != nil {
85+ return nil, false, err
86+ }
87+ if len(data) == 0 {
88+ return nil, false, fmt.Errorf("%s returned empty data for %s", backend.name, mime)
89+ }
90+ return data, true, nil
91+}
92+
93+func preferredClipboardImageMIME(types string) (string, bool) {
94+ available := make(map[string]string)
95+ for _, field := range strings.Fields(types) {
96+ mime := strings.TrimSpace(field)
97+ available[strings.ToLower(mime)] = mime
98+ }
99+ for _, mime := range []string{
100+ "image/png",
101+ "image/tiff",
102+ "image/x-tiff",
103+ "image/jpeg",
104+ "image/jpg",
105+ "image/webp",
106+ "image/gif",
107+ } {
108+ if original, ok := available[mime]; ok {
109+ return original, true
110+ }
111+ }
112+ return "", false
113+}
@@ -0,0 +1,113 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "strings"
10+)
11+
12+type linuxImageClipboardBackend struct {
13+ name string
14+ listArgs []string
15+ readArgs func(string) []string
16+}
17+
18+// ReadClipboardImagePNG reads and normalizes an image from a Wayland or X11
19+// clipboard. Small command-line clients preserve zot's CGO-free build.
20+func ReadClipboardImagePNG() (string, []byte, bool, error) {
21+ backends := linuxImageClipboardBackends()
22+ var lastErr error
23+ found := false
24+ for _, backend := range backends {
25+ data, ok, err := readLinuxClipboardImage(backend)
26+ if errors.Is(err, errClipboardImageCommandUnavailable) {
27+ continue
28+ }
29+ found = true
30+ if err != nil {
31+ lastErr = err
32+ continue
33+ }
34+ if !ok {
35+ return "", nil, false, nil
36+ }
37+ pngData, err := normalizeClipboardImagePNG(data)
38+ if err != nil {
39+ return "", nil, false, fmt.Errorf("invalid clipboard image: %w", err)
40+ }
41+ return "", pngData, true, nil
42+ }
43+ if found && lastErr != nil {
44+ return "", nil, false, fmt.Errorf("read image clipboard: %w", lastErr)
45+ }
46+ return "", nil, false, nil
47+}
48+
49+func linuxImageClipboardBackends() []linuxImageClipboardBackend {
50+ wayland := linuxImageClipboardBackend{
51+ name: "wl-paste",
52+ listArgs: []string{"--list-types"},
53+ readArgs: func(mime string) []string { return []string{"--no-newline", "--type", mime} },
54+ }
55+ xclip := linuxImageClipboardBackend{
56+ name: "xclip",
57+ listArgs: []string{"-selection", "clipboard", "-target", "TARGETS", "-out"},
58+ readArgs: func(mime string) []string { return []string{"-selection", "clipboard", "-target", mime, "-out"} },
59+ }
60+
61+ var backends []linuxImageClipboardBackend
62+ if os.Getenv("WAYLAND_DISPLAY") != "" {
63+ backends = append(backends, wayland)
64+ }
65+ if os.Getenv("DISPLAY") != "" {
66+ backends = append(backends, xclip)
67+ }
68+ if len(backends) == 0 {
69+ backends = []linuxImageClipboardBackend{wayland, xclip}
70+ }
71+ return backends
72+}
73+
74+func readLinuxClipboardImage(backend linuxImageClipboardBackend) ([]byte, bool, error) {
75+ types, err := runClipboardImageCommand(backend.name, maxClipboardMetadataBytes, backend.listArgs...)
76+ if err != nil {
77+ return nil, false, err
78+ }
79+ mime, ok := preferredClipboardImageMIME(string(types))
80+ if !ok {
81+ return nil, false, nil
82+ }
83+ data, err := runClipboardImageCommand(backend.name, maxClipboardImageBytes, backend.readArgs(mime)...)
84+ if err != nil {
85+ return nil, false, err
86+ }
87+ if len(data) == 0 {
88+ return nil, false, fmt.Errorf("%s returned empty data for %s", backend.name, mime)
89+ }
90+ return data, true, nil
91+}
92+
93+func preferredClipboardImageMIME(types string) (string, bool) {
94+ available := make(map[string]string)
95+ for _, field := range strings.Fields(types) {
96+ mime := strings.TrimSpace(field)
97+ available[strings.ToLower(mime)] = mime
98+ }
99+ for _, mime := range []string{
100+ "image/png",
101+ "image/tiff",
102+ "image/x-tiff",
103+ "image/jpeg",
104+ "image/jpg",
105+ "image/webp",
106+ "image/gif",
107+ } {
108+ if original, ok := available[mime]; ok {
109+ return original, true
110+ }
111+ }
112+ return "", false
113+}
A packages/tui/clipboard_linux_test.go +123 -0
@@ -0,0 +1,123 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func writeFakeLinuxClipboard(t *testing.T, name, script string) {
13+ t.Helper()
14+ path := filepath.Join(t.TempDir(), name)
15+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o700); err != nil {
16+ t.Fatal(err)
17+ }
18+ t.Setenv("PATH", filepath.Dir(path))
19+}
20+
21+func TestReadClipboardImagePNGWayland(t *testing.T) {
22+ fixture := filepath.Join(t.TempDir(), "image.png")
23+ want := testClipboardPNG(t)
24+ if err := os.WriteFile(fixture, want, 0o600); err != nil {
25+ t.Fatal(err)
26+ }
27+ t.Setenv("CLIPBOARD_IMAGE_FIXTURE", fixture)
28+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
29+ t.Setenv("DISPLAY", "")
30+ writeFakeLinuxClipboard(t, "wl-paste", `
31+if [ "$1" = "--list-types" ]; then
32+ printf 'text/plain\nimage/png\n'
33+else
34+ /bin/cat "$CLIPBOARD_IMAGE_FIXTURE"
35+fi
36+`)
37+
38+ path, got, ok, err := ReadClipboardImagePNG()
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if path != "" || !ok {
43+ t.Fatalf("path = %q, ok = %v", path, ok)
44+ }
45+ if !bytes.Equal(got, want) {
46+ // Normalization may choose a different valid PNG representation, so
47+ // compare dimensions when the encoded bytes differ.
48+ if gotW, gotH := ImageDimensions(got); gotW != 2 || gotH != 2 {
49+ t.Fatalf("normalized image dimensions = %dx%d", gotW, gotH)
50+ }
51+ }
52+}
53+
54+func TestReadClipboardImagePNGX11(t *testing.T) {
55+ fixture := filepath.Join(t.TempDir(), "image.png")
56+ if err := os.WriteFile(fixture, testClipboardPNG(t), 0o600); err != nil {
57+ t.Fatal(err)
58+ }
59+ t.Setenv("CLIPBOARD_IMAGE_FIXTURE", fixture)
60+ t.Setenv("WAYLAND_DISPLAY", "")
61+ t.Setenv("DISPLAY", ":1")
62+ writeFakeLinuxClipboard(t, "xclip", `
63+if [ "$4" = "TARGETS" ]; then
64+ printf 'TARGETS\nimage/png\n'
65+else
66+ /bin/cat "$CLIPBOARD_IMAGE_FIXTURE"
67+fi
68+`)
69+
70+ _, got, ok, err := ReadClipboardImagePNG()
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if !ok || len(got) == 0 {
75+ t.Fatalf("ok = %v, len(data) = %d", ok, len(got))
76+ }
77+}
78+
79+func TestReadClipboardImagePNGNoImageFallsBack(t *testing.T) {
80+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
81+ t.Setenv("DISPLAY", "")
82+ writeFakeLinuxClipboard(t, "wl-paste", `
83+if [ "$1" = "--list-types" ]; then
84+ printf 'text/plain;charset=utf-8\n'
85+ exit 0
86+fi
87+exit 9
88+`)
89+
90+ _, data, ok, err := ReadClipboardImagePNG()
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if ok || data != nil {
95+ t.Fatalf("ok = %v, data = %v", ok, data)
96+ }
97+}
98+
99+func TestReadClipboardImagePNGRejectsMalformedData(t *testing.T) {
100+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
101+ t.Setenv("DISPLAY", "")
102+ writeFakeLinuxClipboard(t, "wl-paste", `
103+if [ "$1" = "--list-types" ]; then
104+ printf 'image/png\n'
105+else
106+ printf 'not a png'
107+fi
108+`)
109+
110+ if _, _, ok, err := ReadClipboardImagePNG(); err == nil || ok {
111+ t.Fatalf("ok = %v, err = %v", ok, err)
112+ }
113+}
114+
115+func TestPreferredClipboardImageMIME(t *testing.T) {
116+ mime, ok := preferredClipboardImageMIME("text/plain\nimage/jpeg\nimage/png\n")
117+ if !ok || mime != "image/png" {
118+ t.Fatalf("mime = %q, ok = %v", mime, ok)
119+ }
120+ if mime, ok := preferredClipboardImageMIME("text/plain\ntext/html\n"); ok {
121+ t.Fatalf("mime = %q, want no image MIME", mime)
122+ }
123+}
@@ -0,0 +1,123 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "bytes"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func writeFakeLinuxClipboard(t *testing.T, name, script string) {
13+ t.Helper()
14+ path := filepath.Join(t.TempDir(), name)
15+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o700); err != nil {
16+ t.Fatal(err)
17+ }
18+ t.Setenv("PATH", filepath.Dir(path))
19+}
20+
21+func TestReadClipboardImagePNGWayland(t *testing.T) {
22+ fixture := filepath.Join(t.TempDir(), "image.png")
23+ want := testClipboardPNG(t)
24+ if err := os.WriteFile(fixture, want, 0o600); err != nil {
25+ t.Fatal(err)
26+ }
27+ t.Setenv("CLIPBOARD_IMAGE_FIXTURE", fixture)
28+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
29+ t.Setenv("DISPLAY", "")
30+ writeFakeLinuxClipboard(t, "wl-paste", `
31+if [ "$1" = "--list-types" ]; then
32+ printf 'text/plain\nimage/png\n'
33+else
34+ /bin/cat "$CLIPBOARD_IMAGE_FIXTURE"
35+fi
36+`)
37+
38+ path, got, ok, err := ReadClipboardImagePNG()
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if path != "" || !ok {
43+ t.Fatalf("path = %q, ok = %v", path, ok)
44+ }
45+ if !bytes.Equal(got, want) {
46+ // Normalization may choose a different valid PNG representation, so
47+ // compare dimensions when the encoded bytes differ.
48+ if gotW, gotH := ImageDimensions(got); gotW != 2 || gotH != 2 {
49+ t.Fatalf("normalized image dimensions = %dx%d", gotW, gotH)
50+ }
51+ }
52+}
53+
54+func TestReadClipboardImagePNGX11(t *testing.T) {
55+ fixture := filepath.Join(t.TempDir(), "image.png")
56+ if err := os.WriteFile(fixture, testClipboardPNG(t), 0o600); err != nil {
57+ t.Fatal(err)
58+ }
59+ t.Setenv("CLIPBOARD_IMAGE_FIXTURE", fixture)
60+ t.Setenv("WAYLAND_DISPLAY", "")
61+ t.Setenv("DISPLAY", ":1")
62+ writeFakeLinuxClipboard(t, "xclip", `
63+if [ "$4" = "TARGETS" ]; then
64+ printf 'TARGETS\nimage/png\n'
65+else
66+ /bin/cat "$CLIPBOARD_IMAGE_FIXTURE"
67+fi
68+`)
69+
70+ _, got, ok, err := ReadClipboardImagePNG()
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if !ok || len(got) == 0 {
75+ t.Fatalf("ok = %v, len(data) = %d", ok, len(got))
76+ }
77+}
78+
79+func TestReadClipboardImagePNGNoImageFallsBack(t *testing.T) {
80+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
81+ t.Setenv("DISPLAY", "")
82+ writeFakeLinuxClipboard(t, "wl-paste", `
83+if [ "$1" = "--list-types" ]; then
84+ printf 'text/plain;charset=utf-8\n'
85+ exit 0
86+fi
87+exit 9
88+`)
89+
90+ _, data, ok, err := ReadClipboardImagePNG()
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if ok || data != nil {
95+ t.Fatalf("ok = %v, data = %v", ok, data)
96+ }
97+}
98+
99+func TestReadClipboardImagePNGRejectsMalformedData(t *testing.T) {
100+ t.Setenv("WAYLAND_DISPLAY", "wayland-0")
101+ t.Setenv("DISPLAY", "")
102+ writeFakeLinuxClipboard(t, "wl-paste", `
103+if [ "$1" = "--list-types" ]; then
104+ printf 'image/png\n'
105+else
106+ printf 'not a png'
107+fi
108+`)
109+
110+ if _, _, ok, err := ReadClipboardImagePNG(); err == nil || ok {
111+ t.Fatalf("ok = %v, err = %v", ok, err)
112+ }
113+}
114+
115+func TestPreferredClipboardImageMIME(t *testing.T) {
116+ mime, ok := preferredClipboardImageMIME("text/plain\nimage/jpeg\nimage/png\n")
117+ if !ok || mime != "image/png" {
118+ t.Fatalf("mime = %q, ok = %v", mime, ok)
119+ }
120+ if mime, ok := preferredClipboardImageMIME("text/plain\ntext/html\n"); ok {
121+ t.Fatalf("mime = %q, want no image MIME", mime)
122+ }
123+}
M packages/tui/clipboard_stub.go +1 -1
@@ -1,4 +1,4 @@
1-//go:build !darwin
1+//go:build !darwin && !linux && !windows
22
33 package tui
44
@@ -1,4 +1,4 @@
1-//go:build !darwin1+//go:build !darwin && !linux && !windows
2 2
3 package tui3 package tui
4 4
A packages/tui/clipboard_windows.go +70 -0
@@ -0,0 +1,70 @@
1+//go:build windows
2+
3+package tui
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+)
10+
11+const readClipboardImagePowerShell = `
12+Add-Type -AssemblyName System.Windows.Forms
13+Add-Type -AssemblyName System.Drawing
14+if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) { exit 3 }
15+$image = [System.Windows.Forms.Clipboard]::GetImage()
16+if ($null -eq $image) { exit 3 }
17+$stream = New-Object System.IO.MemoryStream
18+try {
19+ $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
20+ $bytes = $stream.ToArray()
21+ $stdout = [Console]::OpenStandardOutput()
22+ $stdout.Write($bytes, 0, $bytes.Length)
23+ $stdout.Flush()
24+} finally {
25+ $stream.Dispose()
26+ $image.Dispose()
27+}
28+`
29+
30+// ReadClipboardImagePNG reads the Windows desktop clipboard through the
31+// PowerShell installation included with supported Windows versions. STA mode
32+// is required by the Windows Forms clipboard API.
33+func ReadClipboardImagePNG() (string, []byte, bool, error) {
34+ return readWindowsClipboardImagePNG(runClipboardImageCommand)
35+}
36+
37+func readWindowsClipboardImagePNG(run func(string, int, ...string) ([]byte, error)) (string, []byte, bool, error) {
38+ commands := []string{"powershell.exe", "pwsh.exe"}
39+ var lastErr error
40+ found := false
41+ for _, name := range commands {
42+ data, err := run(name, maxClipboardImageBytes,
43+ "-NoProfile", "-NonInteractive", "-STA", "-Command", readClipboardImagePowerShell)
44+ if errors.Is(err, errClipboardImageCommandUnavailable) {
45+ continue
46+ }
47+ found = true
48+ if clipboardHasNoImageExit(err) {
49+ return "", nil, false, nil
50+ }
51+ if err != nil {
52+ lastErr = err
53+ continue
54+ }
55+ pngData, err := normalizeClipboardImagePNG(data)
56+ if err != nil {
57+ return "", nil, false, fmt.Errorf("invalid clipboard image: %w", err)
58+ }
59+ return "", pngData, true, nil
60+ }
61+ if found && lastErr != nil {
62+ return "", nil, false, fmt.Errorf("read image clipboard: %w", lastErr)
63+ }
64+ return "", nil, false, nil
65+}
66+
67+func clipboardHasNoImageExit(err error) bool {
68+ var exitErr *exec.ExitError
69+ return errors.As(err, &exitErr) && exitErr.ExitCode() == 3
70+}
@@ -0,0 +1,70 @@
1+//go:build windows
2+
3+package tui
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+)
10+
11+const readClipboardImagePowerShell = `
12+Add-Type -AssemblyName System.Windows.Forms
13+Add-Type -AssemblyName System.Drawing
14+if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) { exit 3 }
15+$image = [System.Windows.Forms.Clipboard]::GetImage()
16+if ($null -eq $image) { exit 3 }
17+$stream = New-Object System.IO.MemoryStream
18+try {
19+ $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
20+ $bytes = $stream.ToArray()
21+ $stdout = [Console]::OpenStandardOutput()
22+ $stdout.Write($bytes, 0, $bytes.Length)
23+ $stdout.Flush()
24+} finally {
25+ $stream.Dispose()
26+ $image.Dispose()
27+}
28+`
29+
30+// ReadClipboardImagePNG reads the Windows desktop clipboard through the
31+// PowerShell installation included with supported Windows versions. STA mode
32+// is required by the Windows Forms clipboard API.
33+func ReadClipboardImagePNG() (string, []byte, bool, error) {
34+ return readWindowsClipboardImagePNG(runClipboardImageCommand)
35+}
36+
37+func readWindowsClipboardImagePNG(run func(string, int, ...string) ([]byte, error)) (string, []byte, bool, error) {
38+ commands := []string{"powershell.exe", "pwsh.exe"}
39+ var lastErr error
40+ found := false
41+ for _, name := range commands {
42+ data, err := run(name, maxClipboardImageBytes,
43+ "-NoProfile", "-NonInteractive", "-STA", "-Command", readClipboardImagePowerShell)
44+ if errors.Is(err, errClipboardImageCommandUnavailable) {
45+ continue
46+ }
47+ found = true
48+ if clipboardHasNoImageExit(err) {
49+ return "", nil, false, nil
50+ }
51+ if err != nil {
52+ lastErr = err
53+ continue
54+ }
55+ pngData, err := normalizeClipboardImagePNG(data)
56+ if err != nil {
57+ return "", nil, false, fmt.Errorf("invalid clipboard image: %w", err)
58+ }
59+ return "", pngData, true, nil
60+ }
61+ if found && lastErr != nil {
62+ return "", nil, false, fmt.Errorf("read image clipboard: %w", lastErr)
63+ }
64+ return "", nil, false, nil
65+}
66+
67+func clipboardHasNoImageExit(err error) bool {
68+ var exitErr *exec.ExitError
69+ return errors.As(err, &exitErr) && exitErr.ExitCode() == 3
70+}
A packages/tui/clipboard_windows_test.go +61 -0
@@ -0,0 +1,61 @@
1+//go:build windows
2+
3+package tui
4+
5+import (
6+ "errors"
7+ "slices"
8+ "testing"
9+)
10+
11+func TestReadWindowsClipboardImagePNGUsesSTAAndNormalizes(t *testing.T) {
12+ var gotName string
13+ var gotArgs []string
14+ run := func(name string, _ int, args ...string) ([]byte, error) {
15+ gotName = name
16+ gotArgs = append([]string(nil), args...)
17+ return testClipboardPNG(t), nil
18+ }
19+
20+ path, data, ok, err := readWindowsClipboardImagePNG(run)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if path != "" || !ok || len(data) == 0 {
25+ t.Fatalf("path = %q, ok = %v, len(data) = %d", path, ok, len(data))
26+ }
27+ if gotName != "powershell.exe" {
28+ t.Fatalf("command = %q, want powershell.exe", gotName)
29+ }
30+ if !slices.Contains(gotArgs, "-STA") {
31+ t.Fatalf("PowerShell args do not enable STA: %q", gotArgs)
32+ }
33+}
34+
35+func TestReadWindowsClipboardImagePNGTriesPwshFallback(t *testing.T) {
36+ var commands []string
37+ run := func(name string, _ int, _ ...string) ([]byte, error) {
38+ commands = append(commands, name)
39+ if name == "powershell.exe" {
40+ return nil, errors.New("Windows PowerShell failed")
41+ }
42+ return testClipboardPNG(t), nil
43+ }
44+
45+ _, _, ok, err := readWindowsClipboardImagePNG(run)
46+ if err != nil || !ok {
47+ t.Fatalf("ok = %v, err = %v", ok, err)
48+ }
49+ if !slices.Equal(commands, []string{"powershell.exe", "pwsh.exe"}) {
50+ t.Fatalf("commands = %q", commands)
51+ }
52+}
53+
54+func TestReadWindowsClipboardImagePNGRejectsMalformedData(t *testing.T) {
55+ run := func(string, int, ...string) ([]byte, error) {
56+ return []byte("not an image"), nil
57+ }
58+ if _, _, ok, err := readWindowsClipboardImagePNG(run); err == nil || ok {
59+ t.Fatalf("ok = %v, err = %v", ok, err)
60+ }
61+}
@@ -0,0 +1,61 @@
1+//go:build windows
2+
3+package tui
4+
5+import (
6+ "errors"
7+ "slices"
8+ "testing"
9+)
10+
11+func TestReadWindowsClipboardImagePNGUsesSTAAndNormalizes(t *testing.T) {
12+ var gotName string
13+ var gotArgs []string
14+ run := func(name string, _ int, args ...string) ([]byte, error) {
15+ gotName = name
16+ gotArgs = append([]string(nil), args...)
17+ return testClipboardPNG(t), nil
18+ }
19+
20+ path, data, ok, err := readWindowsClipboardImagePNG(run)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if path != "" || !ok || len(data) == 0 {
25+ t.Fatalf("path = %q, ok = %v, len(data) = %d", path, ok, len(data))
26+ }
27+ if gotName != "powershell.exe" {
28+ t.Fatalf("command = %q, want powershell.exe", gotName)
29+ }
30+ if !slices.Contains(gotArgs, "-STA") {
31+ t.Fatalf("PowerShell args do not enable STA: %q", gotArgs)
32+ }
33+}
34+
35+func TestReadWindowsClipboardImagePNGTriesPwshFallback(t *testing.T) {
36+ var commands []string
37+ run := func(name string, _ int, _ ...string) ([]byte, error) {
38+ commands = append(commands, name)
39+ if name == "powershell.exe" {
40+ return nil, errors.New("Windows PowerShell failed")
41+ }
42+ return testClipboardPNG(t), nil
43+ }
44+
45+ _, _, ok, err := readWindowsClipboardImagePNG(run)
46+ if err != nil || !ok {
47+ t.Fatalf("ok = %v, err = %v", ok, err)
48+ }
49+ if !slices.Equal(commands, []string{"powershell.exe", "pwsh.exe"}) {
50+ t.Fatalf("commands = %q", commands)
51+ }
52+}
53+
54+func TestReadWindowsClipboardImagePNGRejectsMalformedData(t *testing.T) {
55+ run := func(string, int, ...string) ([]byte, error) {
56+ return []byte("not an image"), nil
57+ }
58+ if _, _, ok, err := readWindowsClipboardImagePNG(run); err == nil || ok {
59+ t.Fatalf("ok = %v, err = %v", ok, err)
60+ }
61+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close