Merge pull request #6 from bnema/feat/clipboard-image-paste
feat(tui): add clipboard image paste
8 files · +417 -18
M README.md +5 -1
| @@ -761,6 +761,10 @@ Opening `/model` refreshes the router and lists every loaded model under provide | |||
| 761 | 761 | ||
| 762 | 762 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | |
| 763 | 763 | ||
| 764 | + | ## Clipboard images | |
| 765 | + | ||
| 766 | + | In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available. Clipboard images larger than 32 MiB are ignored and use the normal text-paste fallback. | |
| 767 | + | ||
| 764 | 768 | ## Inline images | |
| 765 | 769 | ||
| 766 | 770 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | |
| @@ -865,7 +869,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 865 | 869 | | `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. | | |
| 866 | 870 | | `ctrl+d` | Exit on empty input. | | |
| 867 | 871 | | `ctrl+l` | Redraw the screen. | | |
| 868 | - | | `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. | | |
| 872 | + | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | |
| 869 | 873 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 870 | 874 | | `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. | | |
| 871 | 875 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| @@ -761,6 +761,10 @@ Opening `/model` refreshes the router and lists every loaded model under provide | |||||
| 761 | 761 | ||||
| 762 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | 762 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | ||
| 763 | 763 | ||||
| 764 | + | ## Clipboard images | |||
| 765 | + | ||||
| 766 | + | In the main chat, `ctrl+v` checks the system clipboard for an image before falling back to text. A pasted image appears as a `[clipboard image #N]` marker and is attached when you submit the prompt; `esc` or `ctrl+c` clears pending image markers with the rest of the input. Wayland image paste uses `wl-paste` from `wl-clipboard`; X11 image paste uses `xclip` when available. Clipboard images larger than 32 MiB are ignored and use the normal text-paste fallback. | |||
| 767 | + | ||||
| 764 | ## Inline images | 768 | ## Inline images | ||
| 765 | 769 | ||||
| 766 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | 770 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | ||
| @@ -865,7 +869,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 865 | | `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. | | 869 | | `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. | | ||
| 866 | | `ctrl+d` | Exit on empty input. | | 870 | | `ctrl+d` | Exit on empty input. | | ||
| 867 | | `ctrl+l` | Redraw the screen. | | 871 | | `ctrl+l` | Redraw the screen. | | ||
| 868 | - | | `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. | | 872 | + | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | |
| 869 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 873 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | ||
| 870 | | `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. | | 874 | | `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. | | ||
| 871 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 875 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
M packages/agent/modes/interactive.go +4 -11
| @@ -2497,7 +2497,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2497 | 2497 | i.invalidate() | |
| 2498 | 2498 | return false | |
| 2499 | 2499 | case tui.KeyPasteClipboard: | |
| 2500 | - | i.pasteClipboard() | |
| 2500 | + | i.pasteClipboard(ctx) | |
| 2501 | 2501 | return false | |
| 2502 | 2502 | case tui.KeyCtrlO: | |
| 2503 | 2503 | i.toggleToolExpansion() | |
| @@ -2789,21 +2789,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2789 | 2789 | return false | |
| 2790 | 2790 | } | |
| 2791 | 2791 | ||
| 2792 | - | func (i *Interactive) pasteClipboard() { | |
| 2793 | - | _, data, ok, err := tui.ReadClipboardImagePNG() | |
| 2794 | - | if err != nil { | |
| 2795 | - | i.mu.Lock() | |
| 2796 | - | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 2797 | - | i.statusOK = "" | |
| 2798 | - | i.mu.Unlock() | |
| 2799 | - | return | |
| 2800 | - | } | |
| 2792 | + | func (i *Interactive) pasteClipboard(ctx context.Context) { | |
| 2793 | + | image, ok, _ := tui.ReadClipboardImage(ctx) | |
| 2801 | 2794 | if ok { | |
| 2802 | 2795 | i.mu.Lock() | |
| 2803 | 2796 | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | |
| 2804 | 2797 | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | |
| 2805 | 2798 | Marker: marker, | |
| 2806 | - | Image: provider.ImageBlock{MimeType: "image/png", Data: data}, | |
| 2799 | + | Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data}, | |
| 2807 | 2800 | }) | |
| 2808 | 2801 | i.ed.Insert(marker + " ") | |
| 2809 | 2802 | i.statusOK = "" | |
| @@ -2497,7 +2497,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2497 | i.invalidate() | 2497 | i.invalidate() | ||
| 2498 | return false | 2498 | return false | ||
| 2499 | case tui.KeyPasteClipboard: | 2499 | case tui.KeyPasteClipboard: | ||
| 2500 | - | i.pasteClipboard() | 2500 | + | i.pasteClipboard(ctx) |
| 2501 | return false | 2501 | return false | ||
| 2502 | case tui.KeyCtrlO: | 2502 | case tui.KeyCtrlO: | ||
| 2503 | i.toggleToolExpansion() | 2503 | i.toggleToolExpansion() | ||
| @@ -2789,21 +2789,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2789 | return false | 2789 | return false | ||
| 2790 | } | 2790 | } | ||
| 2791 | 2791 | ||||
| 2792 | - | func (i *Interactive) pasteClipboard() { | 2792 | + | func (i *Interactive) pasteClipboard(ctx context.Context) { |
| 2793 | - | _, data, ok, err := tui.ReadClipboardImagePNG() | 2793 | + | image, ok, _ := tui.ReadClipboardImage(ctx) |
| 2794 | - | if err != nil { | |||
| 2795 | - | i.mu.Lock() | |||
| 2796 | - | i.statusErr = "clipboard paste failed: " + err.Error() | |||
| 2797 | - | i.statusOK = "" | |||
| 2798 | - | i.mu.Unlock() | |||
| 2799 | - | return | |||
| 2800 | - | } | |||
| 2801 | if ok { | 2794 | if ok { | ||
| 2802 | i.mu.Lock() | 2795 | i.mu.Lock() | ||
| 2803 | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | 2796 | marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1) | ||
| 2804 | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | 2797 | i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{ | ||
| 2805 | Marker: marker, | 2798 | Marker: marker, | ||
| 2806 | - | Image: provider.ImageBlock{MimeType: "image/png", Data: data}, | 2799 | + | Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data}, |
| 2807 | }) | 2800 | }) | ||
| 2808 | i.ed.Insert(marker + " ") | 2801 | i.ed.Insert(marker + " ") | ||
| 2809 | i.statusOK = "" | 2802 | i.statusOK = "" | ||
A packages/tui/clipboard.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | clipboardImageReadTimeout = 5 * time.Second | |
| 10 | + | maxClipboardImageBytes = 32 << 20 | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit") | |
| 14 | + | ||
| 15 | + | // ClipboardImage is an image read from the system clipboard. | |
| 16 | + | type ClipboardImage struct { | |
| 17 | + | MimeType string | |
| 18 | + | Data []byte | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | clipboardImageReadTimeout = 5 * time.Second | |||
| 10 | + | maxClipboardImageBytes = 32 << 20 | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit") | |||
| 14 | + | ||||
| 15 | + | // ClipboardImage is an image read from the system clipboard. | |||
| 16 | + | type ClipboardImage struct { | |||
| 17 | + | MimeType string | |||
| 18 | + | Data []byte | |||
| 19 | + | } | |||
M packages/tui/clipboard_darwin.go +62 -5
| @@ -3,10 +3,12 @@ | |||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "crypto/rand" | |
| 7 | 8 | "encoding/hex" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "image/png" | |
| 11 | + | "io" | |
| 10 | 12 | "os" | |
| 11 | 13 | "os/exec" | |
| 12 | 14 | "path/filepath" | |
| @@ -47,7 +49,29 @@ on run argv | |||
| 47 | 49 | end run | |
| 48 | 50 | ` | |
| 49 | 51 | ||
| 52 | + | func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) { | |
| 53 | + | if ctx == nil { | |
| 54 | + | ctx = context.Background() | |
| 55 | + | } | |
| 56 | + | ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout) | |
| 57 | + | defer cancel() | |
| 58 | + | ||
| 59 | + | path, data, ok, err := readClipboardImagePNG(ctx) | |
| 60 | + | if err != nil { | |
| 61 | + | return ClipboardImage{}, false, err | |
| 62 | + | } | |
| 63 | + | if !ok { | |
| 64 | + | return ClipboardImage{}, false, nil | |
| 65 | + | } | |
| 66 | + | _ = removeClipboardImageFile(path) | |
| 67 | + | return ClipboardImage{MimeType: "image/png", Data: data}, true, nil | |
| 68 | + | } | |
| 69 | + | ||
| 50 | 70 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 71 | + | return readClipboardImagePNG(context.Background()) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) { | |
| 51 | 75 | dir := clipboardImageDir() | |
| 52 | 76 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 53 | 77 | return "", nil, false, err | |
| @@ -56,7 +80,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 56 | 80 | rawPath := path + ".raw" | |
| 57 | 81 | defer os.Remove(rawPath) | |
| 58 | 82 | ||
| 59 | - | kind, err := writeClipboardImageData(rawPath) | |
| 83 | + | kind, err := writeClipboardImageData(ctx, rawPath) | |
| 60 | 84 | if err != nil { | |
| 61 | 85 | return "", nil, false, err | |
| 62 | 86 | } | |
| @@ -79,19 +103,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 79 | 103 | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | |
| 80 | 104 | } | |
| 81 | 105 | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | |
| 106 | + | if err == errClipboardImageTooLarge { | |
| 107 | + | return "", nil, false, nil | |
| 108 | + | } | |
| 82 | 109 | return "", nil, false, err | |
| 83 | 110 | } | |
| 84 | 111 | } | |
| 85 | 112 | ||
| 86 | - | data, err := os.ReadFile(path) | |
| 113 | + | data, err := readClipboardImageFile(path) | |
| 114 | + | if err == errClipboardImageTooLarge { | |
| 115 | + | if err := removeClipboardImageFile(path); err != nil { | |
| 116 | + | return "", nil, false, err | |
| 117 | + | } | |
| 118 | + | return "", nil, false, nil | |
| 119 | + | } | |
| 87 | 120 | if err != nil { | |
| 88 | 121 | return "", nil, false, err | |
| 89 | 122 | } | |
| 90 | 123 | return path, data, true, nil | |
| 91 | 124 | } | |
| 92 | 125 | ||
| 93 | - | func writeClipboardImageData(path string) (string, error) { | |
| 94 | - | cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path) | |
| 126 | + | func writeClipboardImageData(ctx context.Context, path string) (string, error) { | |
| 127 | + | cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path) | |
| 95 | 128 | out, err := cmd.CombinedOutput() | |
| 96 | 129 | trimmed := strings.TrimSpace(string(out)) | |
| 97 | 130 | if err != nil { | |
| @@ -130,10 +163,34 @@ func clipboardImageKind(s string) (string, bool) { | |||
| 130 | 163 | return "", false | |
| 131 | 164 | } | |
| 132 | 165 | ||
| 166 | + | func readClipboardImageFile(path string) ([]byte, error) { | |
| 167 | + | file, err := os.Open(path) | |
| 168 | + | if err != nil { | |
| 169 | + | return nil, err | |
| 170 | + | } | |
| 171 | + | defer file.Close() | |
| 172 | + | ||
| 173 | + | data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1)) | |
| 174 | + | if err != nil { | |
| 175 | + | return nil, err | |
| 176 | + | } | |
| 177 | + | if len(data) > maxClipboardImageBytes { | |
| 178 | + | return nil, errClipboardImageTooLarge | |
| 179 | + | } | |
| 180 | + | return data, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func removeClipboardImageFile(path string) error { | |
| 184 | + | if err := os.Remove(path); err != nil { | |
| 185 | + | return fmt.Errorf("remove temporary clipboard image: %w", err) | |
| 186 | + | } | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | ||
| 133 | 190 | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | |
| 134 | 191 | switch strings.ToLower(filepath.Ext(srcPath)) { | |
| 135 | 192 | case ".png": | |
| 136 | - | data, err := os.ReadFile(srcPath) | |
| 193 | + | data, err := readClipboardImageFile(srcPath) | |
| 137 | 194 | if err != nil { | |
| 138 | 195 | return err | |
| 139 | 196 | } | |
| @@ -3,10 +3,12 @@ | |||||
| 3 | package tui | 3 | 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 | "image/png" | ||
| 11 | + | "io" | |||
| 10 | "os" | 12 | "os" | ||
| 11 | "os/exec" | 13 | "os/exec" | ||
| 12 | "path/filepath" | 14 | "path/filepath" | ||
| @@ -47,7 +49,29 @@ on run argv | |||||
| 47 | end run | 49 | end run | ||
| 48 | ` | 50 | ` | ||
| 49 | 51 | ||||
| 52 | + | func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) { | |||
| 53 | + | if ctx == nil { | |||
| 54 | + | ctx = context.Background() | |||
| 55 | + | } | |||
| 56 | + | ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout) | |||
| 57 | + | defer cancel() | |||
| 58 | + | ||||
| 59 | + | path, data, ok, err := readClipboardImagePNG(ctx) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return ClipboardImage{}, false, err | |||
| 62 | + | } | |||
| 63 | + | if !ok { | |||
| 64 | + | return ClipboardImage{}, false, nil | |||
| 65 | + | } | |||
| 66 | + | _ = removeClipboardImageFile(path) | |||
| 67 | + | return ClipboardImage{MimeType: "image/png", Data: data}, true, nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 50 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | 70 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | ||
| 71 | + | return readClipboardImagePNG(context.Background()) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) { | |||
| 51 | dir := clipboardImageDir() | 75 | dir := clipboardImageDir() | ||
| 52 | if err := os.MkdirAll(dir, 0o700); err != nil { | 76 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 53 | return "", nil, false, err | 77 | return "", nil, false, err | ||
| @@ -56,7 +80,7 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||||
| 56 | rawPath := path + ".raw" | 80 | rawPath := path + ".raw" | ||
| 57 | defer os.Remove(rawPath) | 81 | defer os.Remove(rawPath) | ||
| 58 | 82 | ||||
| 59 | - | kind, err := writeClipboardImageData(rawPath) | 83 | + | kind, err := writeClipboardImageData(ctx, rawPath) |
| 60 | if err != nil { | 84 | if err != nil { | ||
| 61 | return "", nil, false, err | 85 | return "", nil, false, err | ||
| 62 | } | 86 | } | ||
| @@ -79,19 +103,28 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||||
| 79 | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | 103 | return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind) | ||
| 80 | } | 104 | } | ||
| 81 | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | 105 | if err := copyClipboardImageFileToPNG(clipPath, path); err != nil { | ||
| 106 | + | if err == errClipboardImageTooLarge { | |||
| 107 | + | return "", nil, false, nil | |||
| 108 | + | } | |||
| 82 | return "", nil, false, err | 109 | return "", nil, false, err | ||
| 83 | } | 110 | } | ||
| 84 | } | 111 | } | ||
| 85 | 112 | ||||
| 86 | - | data, err := os.ReadFile(path) | 113 | + | data, err := readClipboardImageFile(path) |
| 114 | + | if err == errClipboardImageTooLarge { | |||
| 115 | + | if err := removeClipboardImageFile(path); err != nil { | |||
| 116 | + | return "", nil, false, err | |||
| 117 | + | } | |||
| 118 | + | return "", nil, false, nil | |||
| 119 | + | } | |||
| 87 | if err != nil { | 120 | if err != nil { | ||
| 88 | return "", nil, false, err | 121 | return "", nil, false, err | ||
| 89 | } | 122 | } | ||
| 90 | return path, data, true, nil | 123 | return path, data, true, nil | ||
| 91 | } | 124 | } | ||
| 92 | 125 | ||||
| 93 | - | func writeClipboardImageData(path string) (string, error) { | 126 | + | func writeClipboardImageData(ctx context.Context, path string) (string, error) { |
| 94 | - | cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path) | 127 | + | cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path) |
| 95 | out, err := cmd.CombinedOutput() | 128 | out, err := cmd.CombinedOutput() | ||
| 96 | trimmed := strings.TrimSpace(string(out)) | 129 | trimmed := strings.TrimSpace(string(out)) | ||
| 97 | if err != nil { | 130 | if err != nil { | ||
| @@ -130,10 +163,34 @@ func clipboardImageKind(s string) (string, bool) { | |||||
| 130 | return "", false | 163 | return "", false | ||
| 131 | } | 164 | } | ||
| 132 | 165 | ||||
| 166 | + | func readClipboardImageFile(path string) ([]byte, error) { | |||
| 167 | + | file, err := os.Open(path) | |||
| 168 | + | if err != nil { | |||
| 169 | + | return nil, err | |||
| 170 | + | } | |||
| 171 | + | defer file.Close() | |||
| 172 | + | ||||
| 173 | + | data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1)) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return nil, err | |||
| 176 | + | } | |||
| 177 | + | if len(data) > maxClipboardImageBytes { | |||
| 178 | + | return nil, errClipboardImageTooLarge | |||
| 179 | + | } | |||
| 180 | + | return data, nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func removeClipboardImageFile(path string) error { | |||
| 184 | + | if err := os.Remove(path); err != nil { | |||
| 185 | + | return fmt.Errorf("remove temporary clipboard image: %w", err) | |||
| 186 | + | } | |||
| 187 | + | return nil | |||
| 188 | + | } | |||
| 189 | + | ||||
| 133 | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | 190 | func copyClipboardImageFileToPNG(srcPath, dstPath string) error { | ||
| 134 | switch strings.ToLower(filepath.Ext(srcPath)) { | 191 | switch strings.ToLower(filepath.Ext(srcPath)) { | ||
| 135 | case ".png": | 192 | case ".png": | ||
| 136 | - | data, err := os.ReadFile(srcPath) | 193 | + | data, err := readClipboardImageFile(srcPath) |
| 137 | if err != nil { | 194 | if err != nil { | ||
| 138 | return err | 195 | return err | ||
| 139 | } | 196 | } | ||
A packages/tui/clipboard_darwin_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestReadClipboardImageFileSizeLimit(t *testing.T) { | |
| 13 | + | path := filepath.Join(t.TempDir(), "clipboard.png") | |
| 14 | + | if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | data, err := readClipboardImageFile(path) | |
| 18 | + | if err != nil || len(data) != maxClipboardImageBytes { | |
| 19 | + | t.Fatalf("exact-limit file result = (%d bytes, %v), want (%d bytes, nil)", len(data), err, maxClipboardImageBytes) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | data, err = readClipboardImageFile(path) | |
| 26 | + | if err != errClipboardImageTooLarge || data != nil { | |
| 27 | + | t.Fatalf("over-limit file result = (%d bytes, %v), want (0 bytes, too-large)", len(data), err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestRemoveClipboardImageFile(t *testing.T) { | |
| 32 | + | path := filepath.Join(t.TempDir(), "clipboard.png") | |
| 33 | + | if err := os.WriteFile(path, []byte("image"), 0o600); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if err := removeClipboardImageFile(path); err != nil { | |
| 37 | + | t.Fatalf("removeClipboardImageFile() error = %v", err) | |
| 38 | + | } | |
| 39 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 40 | + | t.Fatalf("clipboard image stat error = %v, want not-exist", err) | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | //go:build darwin | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestReadClipboardImageFileSizeLimit(t *testing.T) { | |||
| 13 | + | path := filepath.Join(t.TempDir(), "clipboard.png") | |||
| 14 | + | if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | data, err := readClipboardImageFile(path) | |||
| 18 | + | if err != nil || len(data) != maxClipboardImageBytes { | |||
| 19 | + | t.Fatalf("exact-limit file result = (%d bytes, %v), want (%d bytes, nil)", len(data), err, maxClipboardImageBytes) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | data, err = readClipboardImageFile(path) | |||
| 26 | + | if err != errClipboardImageTooLarge || data != nil { | |||
| 27 | + | t.Fatalf("over-limit file result = (%d bytes, %v), want (0 bytes, too-large)", len(data), err) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestRemoveClipboardImageFile(t *testing.T) { | |||
| 32 | + | path := filepath.Join(t.TempDir(), "clipboard.png") | |||
| 33 | + | if err := os.WriteFile(path, []byte("image"), 0o600); err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if err := removeClipboardImageFile(path); err != nil { | |||
| 37 | + | t.Fatalf("removeClipboardImageFile() error = %v", err) | |||
| 38 | + | } | |||
| 39 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 40 | + | t.Fatalf("clipboard image stat error = %v, want not-exist", err) | |||
| 41 | + | } | |||
| 42 | + | } | |||
A packages/tui/clipboard_linux.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var supportedClipboardImageMIMEs = []string{ | |
| 14 | + | "image/png", | |
| 15 | + | "image/jpeg", | |
| 16 | + | "image/gif", | |
| 17 | + | "image/webp", | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type clipboardImageCommand struct { | |
| 21 | + | name string | |
| 22 | + | args []string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // ReadClipboardImage reads an image from the Wayland or X11 system clipboard. | |
| 26 | + | // Clipboard helper failures are intentionally treated as an empty clipboard so | |
| 27 | + | // that optional desktop integrations do not affect paste handling. | |
| 28 | + | func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) { | |
| 29 | + | if ctx == nil { | |
| 30 | + | ctx = context.Background() | |
| 31 | + | } | |
| 32 | + | ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout) | |
| 33 | + | defer cancel() | |
| 34 | + | ||
| 35 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 36 | + | if image, ok := readClipboardImageWayland(ctx); ok { | |
| 37 | + | return image, true, nil | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | if os.Getenv("DISPLAY") != "" { | |
| 41 | + | if image, ok := readClipboardImageX11(ctx); ok { | |
| 42 | + | return image, true, nil | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | return ClipboardImage{}, false, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ReadClipboardImagePNG preserves the legacy Linux behavior. Linux image | |
| 49 | + | // reads retain their clipboard MIME type through ReadClipboardImage instead. | |
| 50 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 51 | + | return "", nil, false, nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) { | |
| 55 | + | types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |
| 56 | + | name: "wl-paste", | |
| 57 | + | args: []string{"--list-types"}, | |
| 58 | + | }) | |
| 59 | + | if !ok { | |
| 60 | + | return ClipboardImage{}, false | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | available := make(map[string]bool) | |
| 64 | + | for _, line := range strings.Split(string(types), "\n") { | |
| 65 | + | mime := strings.ToLower(strings.TrimSpace(line)) | |
| 66 | + | if isSupportedClipboardImageMIME(mime) { | |
| 67 | + | available[mime] = true | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | for _, mime := range supportedClipboardImageMIMEs { | |
| 71 | + | if !available[mime] { | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |
| 75 | + | name: "wl-paste", | |
| 76 | + | args: []string{"--type", mime}, | |
| 77 | + | }) | |
| 78 | + | if ok { | |
| 79 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return ClipboardImage{}, false | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) { | |
| 86 | + | for _, mime := range supportedClipboardImageMIMEs { | |
| 87 | + | data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |
| 88 | + | name: "xclip", | |
| 89 | + | args: []string{"-selection", "clipboard", "-out", "-target", mime}, | |
| 90 | + | }) | |
| 91 | + | if ok { | |
| 92 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return ClipboardImage{}, false | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func isSupportedClipboardImageMIME(mime string) bool { | |
| 99 | + | for _, supported := range supportedClipboardImageMIMEs { | |
| 100 | + | if mime == supported { | |
| 101 | + | return true | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) { | |
| 108 | + | path, err := exec.LookPath(command.name) | |
| 109 | + | if err != nil { | |
| 110 | + | return nil, false | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | cmd := exec.CommandContext(ctx, path, command.args...) | |
| 114 | + | stdout, err := cmd.StdoutPipe() | |
| 115 | + | if err != nil { | |
| 116 | + | return nil, false | |
| 117 | + | } | |
| 118 | + | if err := cmd.Start(); err != nil { | |
| 119 | + | return nil, false | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | data, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxClipboardImageBytes)+1)) | |
| 123 | + | if readErr != nil || len(data) > maxClipboardImageBytes { | |
| 124 | + | _ = cmd.Process.Kill() | |
| 125 | + | _ = cmd.Wait() | |
| 126 | + | return nil, false | |
| 127 | + | } | |
| 128 | + | if err := cmd.Wait(); err != nil || len(data) == 0 { | |
| 129 | + | return nil, false | |
| 130 | + | } | |
| 131 | + | return data, true | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "strings" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var supportedClipboardImageMIMEs = []string{ | |||
| 14 | + | "image/png", | |||
| 15 | + | "image/jpeg", | |||
| 16 | + | "image/gif", | |||
| 17 | + | "image/webp", | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type clipboardImageCommand struct { | |||
| 21 | + | name string | |||
| 22 | + | args []string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // ReadClipboardImage reads an image from the Wayland or X11 system clipboard. | |||
| 26 | + | // Clipboard helper failures are intentionally treated as an empty clipboard so | |||
| 27 | + | // that optional desktop integrations do not affect paste handling. | |||
| 28 | + | func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) { | |||
| 29 | + | if ctx == nil { | |||
| 30 | + | ctx = context.Background() | |||
| 31 | + | } | |||
| 32 | + | ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout) | |||
| 33 | + | defer cancel() | |||
| 34 | + | ||||
| 35 | + | if os.Getenv("WAYLAND_DISPLAY") != "" { | |||
| 36 | + | if image, ok := readClipboardImageWayland(ctx); ok { | |||
| 37 | + | return image, true, nil | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | if os.Getenv("DISPLAY") != "" { | |||
| 41 | + | if image, ok := readClipboardImageX11(ctx); ok { | |||
| 42 | + | return image, true, nil | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | return ClipboardImage{}, false, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // ReadClipboardImagePNG preserves the legacy Linux behavior. Linux image | |||
| 49 | + | // reads retain their clipboard MIME type through ReadClipboardImage instead. | |||
| 50 | + | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 51 | + | return "", nil, false, nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func readClipboardImageWayland(ctx context.Context) (ClipboardImage, bool) { | |||
| 55 | + | types, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |||
| 56 | + | name: "wl-paste", | |||
| 57 | + | args: []string{"--list-types"}, | |||
| 58 | + | }) | |||
| 59 | + | if !ok { | |||
| 60 | + | return ClipboardImage{}, false | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | available := make(map[string]bool) | |||
| 64 | + | for _, line := range strings.Split(string(types), "\n") { | |||
| 65 | + | mime := strings.ToLower(strings.TrimSpace(line)) | |||
| 66 | + | if isSupportedClipboardImageMIME(mime) { | |||
| 67 | + | available[mime] = true | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | for _, mime := range supportedClipboardImageMIMEs { | |||
| 71 | + | if !available[mime] { | |||
| 72 | + | continue | |||
| 73 | + | } | |||
| 74 | + | data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |||
| 75 | + | name: "wl-paste", | |||
| 76 | + | args: []string{"--type", mime}, | |||
| 77 | + | }) | |||
| 78 | + | if ok { | |||
| 79 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return ClipboardImage{}, false | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func readClipboardImageX11(ctx context.Context) (ClipboardImage, bool) { | |||
| 86 | + | for _, mime := range supportedClipboardImageMIMEs { | |||
| 87 | + | data, ok := runClipboardImageCommand(ctx, clipboardImageCommand{ | |||
| 88 | + | name: "xclip", | |||
| 89 | + | args: []string{"-selection", "clipboard", "-out", "-target", mime}, | |||
| 90 | + | }) | |||
| 91 | + | if ok { | |||
| 92 | + | return ClipboardImage{MimeType: mime, Data: data}, true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return ClipboardImage{}, false | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func isSupportedClipboardImageMIME(mime string) bool { | |||
| 99 | + | for _, supported := range supportedClipboardImageMIMEs { | |||
| 100 | + | if mime == supported { | |||
| 101 | + | return true | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func runClipboardImageCommand(ctx context.Context, command clipboardImageCommand) ([]byte, bool) { | |||
| 108 | + | path, err := exec.LookPath(command.name) | |||
| 109 | + | if err != nil { | |||
| 110 | + | return nil, false | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | cmd := exec.CommandContext(ctx, path, command.args...) | |||
| 114 | + | stdout, err := cmd.StdoutPipe() | |||
| 115 | + | if err != nil { | |||
| 116 | + | return nil, false | |||
| 117 | + | } | |||
| 118 | + | if err := cmd.Start(); err != nil { | |||
| 119 | + | return nil, false | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | data, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxClipboardImageBytes)+1)) | |||
| 123 | + | if readErr != nil || len(data) > maxClipboardImageBytes { | |||
| 124 | + | _ = cmd.Process.Kill() | |||
| 125 | + | _ = cmd.Wait() | |||
| 126 | + | return nil, false | |||
| 127 | + | } | |||
| 128 | + | if err := cmd.Wait(); err != nil || len(data) == 0 { | |||
| 129 | + | return nil, false | |||
| 130 | + | } | |||
| 131 | + | return data, true | |||
| 132 | + | } | |||
A packages/tui/clipboard_linux_test.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestReadClipboardImageWaylandUsesMIMEPriority(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | state := filepath.Join(dir, "listed") | |
| 16 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |
| 17 | + | if [ "$1" = "--list-types" ]; then | |
| 18 | + | : > "$CLIPBOARD_TEST_STATE" | |
| 19 | + | printf 'image/jpeg\nimage/png\nimage/bmp\n' | |
| 20 | + | exit 0 | |
| 21 | + | fi | |
| 22 | + | if [ "$1" = "--type" ]; then | |
| 23 | + | if [ ! -f "$CLIPBOARD_TEST_STATE" ]; then | |
| 24 | + | exit 1 | |
| 25 | + | fi | |
| 26 | + | case "$2" in | |
| 27 | + | image/png) printf 'png-bytes' ;; | |
| 28 | + | image/jpeg) printf 'jpeg-bytes' ;; | |
| 29 | + | *) exit 1 ;; | |
| 30 | + | esac | |
| 31 | + | exit 0 | |
| 32 | + | fi | |
| 33 | + | exit 1 | |
| 34 | + | `) | |
| 35 | + | t.Setenv("PATH", dir) | |
| 36 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 37 | + | t.Setenv("DISPLAY", "") | |
| 38 | + | t.Setenv("CLIPBOARD_TEST_STATE", state) | |
| 39 | + | ||
| 40 | + | image, ok, err := ReadClipboardImage(context.Background()) | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |
| 43 | + | } | |
| 44 | + | if !ok { | |
| 45 | + | t.Fatal("ReadClipboardImage() reported no image") | |
| 46 | + | } | |
| 47 | + | if image.MimeType != "image/png" { | |
| 48 | + | t.Fatalf("MIME type = %q, want image/png", image.MimeType) | |
| 49 | + | } | |
| 50 | + | if string(image.Data) != "png-bytes" { | |
| 51 | + | t.Fatalf("image data = %q, want png-bytes", image.Data) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestReadClipboardImageFallsBackToX11Helpers(t *testing.T) { | |
| 56 | + | dir := t.TempDir() | |
| 57 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |
| 58 | + | if [ "$1" = "--list-types" ]; then | |
| 59 | + | printf 'text/plain\n' | |
| 60 | + | fi | |
| 61 | + | `) | |
| 62 | + | writeClipboardTestHelper(t, dir, "xclip", `#!/bin/sh | |
| 63 | + | case "$*" in | |
| 64 | + | *"image/gif"*) printf 'gif-bytes' ;; | |
| 65 | + | *) exit 1 ;; | |
| 66 | + | esac | |
| 67 | + | `) | |
| 68 | + | t.Setenv("PATH", dir) | |
| 69 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 70 | + | t.Setenv("DISPLAY", ":test") | |
| 71 | + | ||
| 72 | + | image, ok, err := ReadClipboardImage(context.Background()) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |
| 75 | + | } | |
| 76 | + | if !ok { | |
| 77 | + | t.Fatal("ReadClipboardImage() reported no image") | |
| 78 | + | } | |
| 79 | + | if image.MimeType != "image/gif" { | |
| 80 | + | t.Fatalf("MIME type = %q, want image/gif", image.MimeType) | |
| 81 | + | } | |
| 82 | + | if string(image.Data) != "gif-bytes" { | |
| 83 | + | t.Fatalf("image data = %q, want gif-bytes", image.Data) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestReadClipboardImageUnavailableIsNoImage(t *testing.T) { | |
| 88 | + | dir := t.TempDir() | |
| 89 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |
| 90 | + | printf 'clipboard helper failure' >&2 | |
| 91 | + | exit 1 | |
| 92 | + | `) | |
| 93 | + | t.Setenv("PATH", dir) | |
| 94 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |
| 95 | + | t.Setenv("DISPLAY", "") | |
| 96 | + | ||
| 97 | + | image, ok, err := ReadClipboardImage(context.Background()) | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatalf("ReadClipboardImage() error = %v, want nil", err) | |
| 100 | + | } | |
| 101 | + | if ok { | |
| 102 | + | t.Fatalf("ReadClipboardImage() returned image %#v", image) | |
| 103 | + | } | |
| 104 | + | if image.MimeType != "" || len(image.Data) != 0 { | |
| 105 | + | t.Fatalf("empty image = %#v", image) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | path, data, ok, err := ReadClipboardImagePNG() | |
| 109 | + | if err != nil || ok || path != "" || data != nil { | |
| 110 | + | t.Fatalf("ReadClipboardImagePNG() = (%q, %v, %v, %v), want empty result", path, data, ok, err) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) { | |
| 115 | + | dir := t.TempDir() | |
| 116 | + | payload := filepath.Join(dir, "payload") | |
| 117 | + | writeClipboardTestHelper(t, dir, "clipboard-test-helper", `#!/bin/sh | |
| 118 | + | exec /bin/cat "$CLIPBOARD_TEST_PAYLOAD" | |
| 119 | + | `) | |
| 120 | + | t.Setenv("PATH", dir) | |
| 121 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |
| 122 | + | ||
| 123 | + | if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil { | |
| 124 | + | t.Fatal(err) | |
| 125 | + | } | |
| 126 | + | data, ok := runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"}) | |
| 127 | + | if !ok || len(data) != maxClipboardImageBytes { | |
| 128 | + | t.Fatalf("exact-limit command result = (%d bytes, %v), want (%d bytes, true)", len(data), ok, maxClipboardImageBytes) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | data, ok = runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"}) | |
| 135 | + | if ok || data != nil { | |
| 136 | + | t.Fatalf("over-limit command result = (%d bytes, %v), want (0 bytes, false)", len(data), ok) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func writeClipboardTestHelper(t *testing.T, dir, name, script string) { | |
| 141 | + | t.Helper() | |
| 142 | + | path := filepath.Join(dir, name) | |
| 143 | + | if err := os.WriteFile(path, []byte(script), 0o700); err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | } | |
| @@ -0,0 +1,146 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "testing" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestReadClipboardImageWaylandUsesMIMEPriority(t *testing.T) { | |||
| 14 | + | dir := t.TempDir() | |||
| 15 | + | state := filepath.Join(dir, "listed") | |||
| 16 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |||
| 17 | + | if [ "$1" = "--list-types" ]; then | |||
| 18 | + | : > "$CLIPBOARD_TEST_STATE" | |||
| 19 | + | printf 'image/jpeg\nimage/png\nimage/bmp\n' | |||
| 20 | + | exit 0 | |||
| 21 | + | fi | |||
| 22 | + | if [ "$1" = "--type" ]; then | |||
| 23 | + | if [ ! -f "$CLIPBOARD_TEST_STATE" ]; then | |||
| 24 | + | exit 1 | |||
| 25 | + | fi | |||
| 26 | + | case "$2" in | |||
| 27 | + | image/png) printf 'png-bytes' ;; | |||
| 28 | + | image/jpeg) printf 'jpeg-bytes' ;; | |||
| 29 | + | *) exit 1 ;; | |||
| 30 | + | esac | |||
| 31 | + | exit 0 | |||
| 32 | + | fi | |||
| 33 | + | exit 1 | |||
| 34 | + | `) | |||
| 35 | + | t.Setenv("PATH", dir) | |||
| 36 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 37 | + | t.Setenv("DISPLAY", "") | |||
| 38 | + | t.Setenv("CLIPBOARD_TEST_STATE", state) | |||
| 39 | + | ||||
| 40 | + | image, ok, err := ReadClipboardImage(context.Background()) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |||
| 43 | + | } | |||
| 44 | + | if !ok { | |||
| 45 | + | t.Fatal("ReadClipboardImage() reported no image") | |||
| 46 | + | } | |||
| 47 | + | if image.MimeType != "image/png" { | |||
| 48 | + | t.Fatalf("MIME type = %q, want image/png", image.MimeType) | |||
| 49 | + | } | |||
| 50 | + | if string(image.Data) != "png-bytes" { | |||
| 51 | + | t.Fatalf("image data = %q, want png-bytes", image.Data) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestReadClipboardImageFallsBackToX11Helpers(t *testing.T) { | |||
| 56 | + | dir := t.TempDir() | |||
| 57 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |||
| 58 | + | if [ "$1" = "--list-types" ]; then | |||
| 59 | + | printf 'text/plain\n' | |||
| 60 | + | fi | |||
| 61 | + | `) | |||
| 62 | + | writeClipboardTestHelper(t, dir, "xclip", `#!/bin/sh | |||
| 63 | + | case "$*" in | |||
| 64 | + | *"image/gif"*) printf 'gif-bytes' ;; | |||
| 65 | + | *) exit 1 ;; | |||
| 66 | + | esac | |||
| 67 | + | `) | |||
| 68 | + | t.Setenv("PATH", dir) | |||
| 69 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 70 | + | t.Setenv("DISPLAY", ":test") | |||
| 71 | + | ||||
| 72 | + | image, ok, err := ReadClipboardImage(context.Background()) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatalf("ReadClipboardImage() error = %v", err) | |||
| 75 | + | } | |||
| 76 | + | if !ok { | |||
| 77 | + | t.Fatal("ReadClipboardImage() reported no image") | |||
| 78 | + | } | |||
| 79 | + | if image.MimeType != "image/gif" { | |||
| 80 | + | t.Fatalf("MIME type = %q, want image/gif", image.MimeType) | |||
| 81 | + | } | |||
| 82 | + | if string(image.Data) != "gif-bytes" { | |||
| 83 | + | t.Fatalf("image data = %q, want gif-bytes", image.Data) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestReadClipboardImageUnavailableIsNoImage(t *testing.T) { | |||
| 88 | + | dir := t.TempDir() | |||
| 89 | + | writeClipboardTestHelper(t, dir, "wl-paste", `#!/bin/sh | |||
| 90 | + | printf 'clipboard helper failure' >&2 | |||
| 91 | + | exit 1 | |||
| 92 | + | `) | |||
| 93 | + | t.Setenv("PATH", dir) | |||
| 94 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-test") | |||
| 95 | + | t.Setenv("DISPLAY", "") | |||
| 96 | + | ||||
| 97 | + | image, ok, err := ReadClipboardImage(context.Background()) | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatalf("ReadClipboardImage() error = %v, want nil", err) | |||
| 100 | + | } | |||
| 101 | + | if ok { | |||
| 102 | + | t.Fatalf("ReadClipboardImage() returned image %#v", image) | |||
| 103 | + | } | |||
| 104 | + | if image.MimeType != "" || len(image.Data) != 0 { | |||
| 105 | + | t.Fatalf("empty image = %#v", image) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | path, data, ok, err := ReadClipboardImagePNG() | |||
| 109 | + | if err != nil || ok || path != "" || data != nil { | |||
| 110 | + | t.Fatalf("ReadClipboardImagePNG() = (%q, %v, %v, %v), want empty result", path, data, ok, err) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRunClipboardImageCommandEnforcesSizeLimit(t *testing.T) { | |||
| 115 | + | dir := t.TempDir() | |||
| 116 | + | payload := filepath.Join(dir, "payload") | |||
| 117 | + | writeClipboardTestHelper(t, dir, "clipboard-test-helper", `#!/bin/sh | |||
| 118 | + | exec /bin/cat "$CLIPBOARD_TEST_PAYLOAD" | |||
| 119 | + | `) | |||
| 120 | + | t.Setenv("PATH", dir) | |||
| 121 | + | t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload) | |||
| 122 | + | ||||
| 123 | + | if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes), 0o600); err != nil { | |||
| 124 | + | t.Fatal(err) | |||
| 125 | + | } | |||
| 126 | + | data, ok := runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"}) | |||
| 127 | + | if !ok || len(data) != maxClipboardImageBytes { | |||
| 128 | + | t.Fatalf("exact-limit command result = (%d bytes, %v), want (%d bytes, true)", len(data), ok, maxClipboardImageBytes) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | if err := os.WriteFile(payload, bytes.Repeat([]byte{'x'}, maxClipboardImageBytes+1), 0o600); err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | data, ok = runClipboardImageCommand(context.Background(), clipboardImageCommand{name: "clipboard-test-helper"}) | |||
| 135 | + | if ok || data != nil { | |||
| 136 | + | t.Fatalf("over-limit command result = (%d bytes, %v), want (0 bytes, false)", len(data), ok) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func writeClipboardTestHelper(t *testing.T, dir, name, script string) { | |||
| 141 | + | t.Helper() | |||
| 142 | + | path := filepath.Join(dir, name) | |||
| 143 | + | if err := os.WriteFile(path, []byte(script), 0o700); err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
M packages/tui/clipboard_stub.go +7 -1
| @@ -1,7 +1,13 @@ | |||
| 1 | - | //go:build !darwin | |
| 1 | + | //go:build !darwin && !linux | |
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | + | import "context" | |
| 6 | + | ||
| 7 | + | func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) { | |
| 8 | + | return ClipboardImage{}, false, nil | |
| 9 | + | } | |
| 10 | + | ||
| 5 | 11 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 6 | 12 | return "", nil, false, nil | |
| 7 | 13 | } | |
| @@ -1,7 +1,13 @@ | |||||
| 1 | - | //go:build !darwin | 1 | + | //go:build !darwin && !linux |
| 2 | 2 | ||||
| 3 | package tui | 3 | package tui | ||
| 4 | 4 | ||||
| 5 | + | import "context" | |||
| 6 | + | ||||
| 7 | + | func ReadClipboardImage(_ context.Context) (ClipboardImage, bool, error) { | |||
| 8 | + | return ClipboardImage{}, false, nil | |||
| 9 | + | } | |||
| 10 | + | ||||
| 5 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | 11 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | ||
| 6 | return "", nil, false, nil | 12 | return "", nil, false, nil | ||
| 7 | } | 13 | } | ||