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