1//go:build darwin
2
3package tui
4
5import (
6 "crypto/rand"
7 "encoding/hex"
8 "fmt"
9 "image/png"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "golang.org/x/image/tiff"
17)
18
19const readClipboardImageScript = `
20on run argv
21 set outPath to item 1 of argv
22 try
23 set imgData to the clipboard as «class PNGf»
24 set imgKind to "png"
25 on error
26 try
27 set imgData to the clipboard as «class TIFF»
28 set imgKind to "tiff"
29 on error
30 return "NO_IMAGE"
31 end try
32 end try
33
34 set outFile to POSIX file outPath
35 set fileRef to open for access outFile with write permission
36 try
37 set eof of fileRef to 0
38 write imgData to fileRef
39 close access fileRef
40 on error errMsg number errNum
41 try
42 close access fileRef
43 end try
44 error errMsg number errNum
45 end try
46 return imgKind
47end run
48`
49
50func ReadClipboardImagePNG() (string, []byte, bool, error) {
51 dir := clipboardImageDir()
52 if err := os.MkdirAll(dir, 0o700); err != nil {
53 return "", nil, false, err
54 }
55 path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
56 rawPath := path + ".raw"
57 defer os.Remove(rawPath)
58
59 kind, err := writeClipboardImageData(rawPath)
60 if err != nil {
61 return "", nil, false, err
62 }
63 if kind == "" {
64 return "", nil, false, nil
65 }
66
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)
78 if !ok {
79 return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
80 }
81 if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
82 return "", nil, false, err
83 }
84 }
85
86 data, err := os.ReadFile(path)
87 if err != nil {
88 return "", nil, false, err
89 }
90 return path, data, true, nil
91}
92
93func writeClipboardImageData(path string) (string, error) {
94 cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
95 out, err := cmd.CombinedOutput()
96 trimmed := strings.TrimSpace(string(out))
97 if err != nil {
98 if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
99 return "", nil
100 }
101 if kind, ok := clipboardImageKind(trimmed); ok {
102 return kind, nil
103 }
104 if path, ok := findClipboardImagePath(trimmed); ok {
105 return path, nil
106 }
107 if trimmed == "" {
108 return "", fmt.Errorf("osascript failed: %w", err)
109 }
110 return "", fmt.Errorf("osascript failed: %s", trimmed)
111 }
112 if trimmed == "NO_IMAGE" {
113 return "", nil
114 }
115 if kind, ok := clipboardImageKind(trimmed); ok {
116 return kind, nil
117 }
118 return trimmed, nil
119}
120
121func clipboardImageKind(s string) (string, bool) {
122 for _, line := range strings.Split(s, "\n") {
123 switch strings.TrimSpace(line) {
124 case "png":
125 return "png", true
126 case "tiff":
127 return "tiff", true
128 }
129 }
130 return "", false
131}
132
133func 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
148func findClipboardImagePath(s string) (string, bool) {
149 if p, ok := clipboardImagePath(s); ok {
150 return p, true
151 }
152 for _, ext := range []string{".png", ".tiff", ".tif"} {
153 lower := strings.ToLower(s)
154 end := strings.Index(lower, ext)
155 if end < 0 {
156 continue
157 }
158 end += len(ext)
159 start := strings.LastIndex(s[:end], "/")
160 if start < 0 {
161 continue
162 }
163 for start > 0 {
164 c := s[start-1]
165 if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' {
166 break
167 }
168 start--
169 }
170 if p, ok := clipboardImagePath(s[start:end]); ok {
171 return p, true
172 }
173 }
174 return "", false
175}
176
177func clipboardImagePath(s string) (string, bool) {
178 p := strings.TrimSpace(s)
179 p = strings.Trim(p, "'\"")
180 if p == "" {
181 return "", false
182 }
183 info, err := os.Stat(p)
184 if err != nil || info.IsDir() {
185 return "", false
186 }
187 switch strings.ToLower(filepath.Ext(p)) {
188 case ".png", ".tif", ".tiff":
189 return p, true
190 default:
191 return "", false
192 }
193}
194
195func 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
216func clipboardImageDir() string {
217 if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
218 return filepath.Join("/tmp", "zot-clipboard-images")
219 }
220 return filepath.Join(os.TempDir(), "zot-clipboard-images")
221}
222
223func randomHex(n int) string {
224 buf := make([]byte, n)
225 if _, err := rand.Read(buf); err != nil {
226 return fmt.Sprintf("%d", time.Now().UnixNano())
227 }
228 return hex.EncodeToString(buf)
229}