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`, | |||
| 898 | 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 | 899 | | `ctrl+d` | Exit on empty input. | | |
| 900 | 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 | 902 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, glob outputs over ~12 lines). | | |
| 903 | 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 | 904 | | `@` | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 4 | 6 | "strings" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -11,6 +13,18 @@ type clipboardImageAttachment struct { | |||
| 11 | 13 | 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 | 28 | func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) { | |
| 15 | 29 | if len(pending) == 0 { | |
| 16 | 30 | return text, nil | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package modes | 1 | 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.ImageBlock | 13 | 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, nil | 30 | return text, nil | ||
M packages/agent/modes/clipboard_images_test.go +22 -0
| @@ -1,12 +1,34 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 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 | 32 | func testClipboardImage(marker string, data string) clipboardImageAttachment { | |
| 11 | 33 | return clipboardImageAttachment{ | |
| 12 | 34 | Marker: marker, | |
| @@ -1,12 +1,34 @@ | |||||
| 1 | package modes | 1 | 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) { | |||
| 2815 | 2815 | } | |
| 2816 | 2816 | ||
| 2817 | 2817 | func (i *Interactive) pasteClipboard() { | |
| 2818 | - | _, data, ok, err := tui.ReadClipboardImagePNG() | |
| 2818 | + | data, ok, err := readClipboardImageAndCleanup(tui.ReadClipboardImagePNG) | |
| 2819 | 2819 | if err != nil { | |
| 2820 | 2820 | i.mu.Lock() | |
| 2821 | 2821 | 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 @@ | |||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "crypto/rand" | |
| 7 | 8 | "encoding/hex" | |
| 8 | 9 | "fmt" | |
| 9 | - | "image/png" | |
| 10 | 10 | "os" | |
| 11 | 11 | "os/exec" | |
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strings" | |
| 14 | 14 | "time" | |
| 15 | - | ||
| 16 | - | "golang.org/x/image/tiff" | |
| 17 | 15 | ) | |
| 18 | 16 | ||
| 19 | 17 | const readClipboardImageScript = ` | |
| @@ -50,7 +48,10 @@ end run | |||
| 50 | 48 | func ReadClipboardImagePNG() (string, []byte, bool, error) { | |
| 51 | 49 | dir := clipboardImageDir() | |
| 52 | 50 | 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) | |
| 54 | 55 | } | |
| 55 | 56 | path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png") | |
| 56 | 57 | rawPath := path + ".raw" | |
| @@ -64,36 +65,37 @@ func ReadClipboardImagePNG() (string, []byte, bool, error) { | |||
| 64 | 65 | return "", nil, false, nil | |
| 65 | 66 | } | |
| 66 | 67 | ||
| 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) | |
| 78 | 72 | if !ok { | |
| 79 | 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 | - | ||
| 86 | - | data, err := os.ReadFile(path) | |
| 76 | + | raw, err := readClipboardImageFile(sourcePath) | |
| 87 | 77 | 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) | |
| 89 | 86 | } | |
| 90 | 87 | return path, data, true, nil | |
| 91 | 88 | } | |
| 92 | 89 | ||
| 93 | 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 | 94 | out, err := cmd.CombinedOutput() | |
| 96 | 95 | trimmed := strings.TrimSpace(string(out)) | |
| 96 | + | if ctx.Err() != nil { | |
| 97 | + | return "", fmt.Errorf("osascript timed out: %w", ctx.Err()) | |
| 98 | + | } | |
| 97 | 99 | if err != nil { | |
| 98 | 100 | if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") { | |
| 99 | 101 | return "", nil | |
| @@ -130,21 +132,6 @@ func clipboardImageKind(s string) (string, bool) { | |||
| 130 | 132 | 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 | 135 | func findClipboardImagePath(s string) (string, bool) { | |
| 149 | 136 | if p, ok := clipboardImagePath(s); ok { | |
| 150 | 137 | 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 | 182 | func clipboardImageDir() string { | |
| 217 | 183 | if info, err := os.Stat("/tmp"); err == nil && info.IsDir() { | |
| 218 | 184 | return filepath.Join("/tmp", "zot-clipboard-images") | |
| @@ -3,17 +3,15 @@ | |||||
| 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 | "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, 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) | |||
| 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, nil | 65 | 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, err | 71 | + | 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, 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) | |||
| 89 | } | 86 | } | ||
| 90 | return path, data, true, nil | 87 | 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 "", nil | 101 | return "", nil | ||
| @@ -130,21 +132,6 @@ func clipboardImageKind(s string) (string, bool) { | |||||
| 130 | return "", false | 132 | 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, true | 137 | 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 | |
| 2 | 2 | ||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | //go:build !darwin | 1 | + | //go:build !darwin && !linux && !windows |
| 2 | 2 | ||||
| 3 | package tui | 3 | 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 | + | } | |||