1//go:build darwin
2
3package tui
4
5import (
6 "context"
7 "crypto/rand"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "image/png"
12 "io"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "strings"
17 "time"
18
19 "golang.org/x/image/tiff"
20)
21
22var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit")
23
24const readClipboardImageScript = `
25on run argv
26 set outPath to item 1 of argv
27 try
28 set imgData to the clipboard as «class PNGf»
29 set imgKind to "png"
30 on error
31 try
32 set imgData to the clipboard as «class TIFF»
33 set imgKind to "tiff"
34 on error
35 return "NO_IMAGE"
36 end try
37 end try
38
39 set outFile to POSIX file outPath
40 set fileRef to open for access outFile with write permission
41 try
42 set eof of fileRef to 0
43 write imgData to fileRef
44 close access fileRef
45 on error errMsg number errNum
46 try
47 close access fileRef
48 end try
49 error errMsg number errNum
50 end try
51 return imgKind
52end run
53`
54
55func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
56 if ctx == nil {
57 ctx = context.Background()
58 }
59 ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
60 defer cancel()
61
62 path, data, ok, err := readClipboardImagePNG(ctx)
63 if err != nil {
64 return ClipboardImage{}, false, err
65 }
66 if !ok {
67 return ClipboardImage{}, false, nil
68 }
69 _ = removeClipboardImageFile(path)
70 return ClipboardImage{MimeType: "image/png", Data: data}, true, nil
71}
72
73func ReadClipboardImagePNG() (string, []byte, bool, error) {
74 return readClipboardImagePNG(context.Background())
75}
76
77func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) {
78 dir := clipboardImageDir()
79 if err := os.MkdirAll(dir, 0o700); err != nil {
80 return "", nil, false, err
81 }
82 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
83 rawPath := path + ".raw"
84 defer os.Remove(rawPath)
85
86 kind, err := writeClipboardImageData(ctx, rawPath)
87 if err != nil {
88 return "", nil, false, err
89 }
90 if kind == "" {
91 return "", nil, false, nil
92 }
93
94 switch kind {
95 case "png":
96 if err := os.Rename(rawPath, path); err != nil {
97 return "", nil, false, err
98 }
99 case "tiff":
100 if err := convertTIFFFileToPNG(rawPath, path); err != nil {
101 return "", nil, false, err
102 }
103 default:
104 clipPath, ok := findClipboardImagePath(kind)
105 if !ok {
106 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
107 }
108 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
109 if err == errClipboardImageTooLarge {
110 return "", nil, false, nil
111 }
112 return "", nil, false, err
113 }
114 }
115
116 data, err := readClipboardImageFile(path)
117 if err == errClipboardImageTooLarge {
118 if err := removeClipboardImageFile(path); err != nil {
119 return "", nil, false, err
120 }
121 return "", nil, false, nil
122 }
123 if err != nil {
124 return "", nil, false, err
125 }
126 return path, data, true, nil
127}
128
129func writeClipboardImageData(ctx context.Context, path string) (string, error) {
130 cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
131 out, err := cmd.CombinedOutput()
132 trimmed := strings.TrimSpace(string(out))
133 if err != nil {
134 if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
135 return "", nil
136 }
137 if kind, ok := clipboardImageKind(trimmed); ok {
138 return kind, nil
139 }
140 if path, ok := findClipboardImagePath(trimmed); ok {
141 return path, nil
142 }
143 if trimmed == "" {
144 return "", fmt.Errorf("osascript failed: %w", err)
145 }
146 return "", fmt.Errorf("osascript failed: %s", trimmed)
147 }
148 if trimmed == "NO_IMAGE" {
149 return "", nil
150 }
151 if kind, ok := clipboardImageKind(trimmed); ok {
152 return kind, nil
153 }
154 return trimmed, nil
155}
156
157func clipboardImageKind(s string) (string, bool) {
158 for _, line := range strings.Split(s, "\n") {
159 switch strings.TrimSpace(line) {
160 case "png":
161 return "png", true
162 case "tiff":
163 return "tiff", true
164 }
165 }
166 return "", false
167}
168
169func readClipboardImageFile(path string) ([]byte, error) {
170 file, err := os.Open(path)
171 if err != nil {
172 return nil, err
173 }
174 defer file.Close()
175
176 data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1))
177 if err != nil {
178 return nil, err
179 }
180 if len(data) > maxClipboardImageBytes {
181 return nil, errClipboardImageTooLarge
182 }
183 return data, nil
184}
185
186func removeClipboardImageFile(path string) error {
187 if err := os.Remove(path); err != nil {
188 return fmt.Errorf("remove temporary clipboard image: %w", err)
189 }
190 return nil
191}
192
193func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
194 switch strings.ToLower(filepath.Ext(srcPath)) {
195 case ".png":
196 data, err := readClipboardImageFile(srcPath)
197 if err != nil {
198 return err
199 }
200 return os.WriteFile(dstPath, data, 0o600)
201 case ".tif", ".tiff":
202 return convertTIFFFileToPNG(srcPath, dstPath)
203 default:
204 return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath)
205 }
206}
207
208func findClipboardImagePath(s string) (string, bool) {
209 if p, ok := clipboardImagePath(s); ok {
210 return p, true
211 }
212 for _, ext := range []string{".png", ".tiff", ".tif"} {
213 lower := strings.ToLower(s)
214 end := strings.Index(lower, ext)
215 if end < 0 {
216 continue
217 }
218 end += len(ext)
219 start := strings.LastIndex(s[:end], "/")
220 if start < 0 {
221 continue
222 }
223 for start > 0 {
224 c := s[start-1]
225 if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' {
226 break
227 }
228 start--
229 }
230 if p, ok := clipboardImagePath(s[start:end]); ok {
231 return p, true
232 }
233 }
234 return "", false
235}
236
237func clipboardImagePath(s string) (string, bool) {
238 p := strings.TrimSpace(s)
239 p = strings.Trim(p, "'\"")
240 if p == "" {
241 return "", false
242 }
243 info, err := os.Stat(p)
244 if err != nil || info.IsDir() {
245 return "", false
246 }
247 switch strings.ToLower(filepath.Ext(p)) {
248 case ".png", ".tif", ".tiff":
249 return p, true
250 default:
251 return "", false
252 }
253}
254
255func convertTIFFFileToPNG(srcPath, dstPath string) error {
256 in, err := os.Open(srcPath)
257 if err != nil {
258 return err
259 }
260 defer in.Close()
261
262 img, err := tiff.Decode(in)
263 if err != nil {
264 return err
265 }
266
267 out, err := os.Create(dstPath)
268 if err != nil {
269 return err
270 }
271 defer out.Close()
272
273 return png.Encode(out, img)
274}
275
276func clipboardImageDir() string {
277 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
278 return filepath.Join("/tmp", "zut-clipboard-images")
279 }
280 return filepath.Join(os.TempDir(), "zut-clipboard-images")
281}
282
283func randomHex(n int) string {
284 buf := make([]byte, n)
285 if _, err := rand.Read(buf); err != nil {
286 return fmt.Sprintf("%d", time.Now().UnixNano())
287 }
288 return hex.EncodeToString(buf)
289}