返回 DeepSeek-Reasonix
attachments.go
根目录 / internal / control / attachments.go
1 package control
2
3 import (
4 "bytes"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "runtime"
15 "slices"
16 "strconv"
17 "strings"
18 "sync/atomic"
19 "time"
20
21 "reasonix/internal/attachment"
22 "reasonix/internal/proc"
23 "reasonix/internal/secrets"
24 )
25
26 const maxImageAttachmentBytes = 64 * 1024 * 1024
27 const maxFileAttachmentBytes = 25 * 1024 * 1024
28 const maxAttachmentCreateAttempts = 1000
29
30 // ErrNoClipboardImage reports that the clipboard was read successfully but holds
31 // no supported image. It is distinct from a missing clipboard tool: callers
32 // offering an image-first paste shortcut use it to fall back to text before
33 // surfacing an image-specific diagnostic.
34 var ErrNoClipboardImage = errors.New("clipboard does not contain an image")
35
36 // ErrUnsupportedClipboardImage marks the more specific no-pasteable-image case
37 // where the clipboard advertised only image formats Reasonix cannot save.
38 var ErrUnsupportedClipboardImage = errors.New("clipboard image type is not supported")
39
40 type unsupportedClipboardImageError struct {
41 tool string
42 types []string
43 }
44
45 func (e unsupportedClipboardImageError) Error() string {
46 return fmt.Sprintf("%s offers unsupported image types: %s", e.tool, strings.Join(e.types, ", "))
47 }
48
49 // Unsupported image formats still mean there is no image Reasonix can paste.
50 // Wrapping the sentinel lets image-first shortcuts try their normal text
51 // fallback before surfacing the more specific diagnostic.
52 func (e unsupportedClipboardImageError) Unwrap() []error {
53 return []error{ErrNoClipboardImage, ErrUnsupportedClipboardImage}
54 }
55
56 var (
57 lookClipboardTool = exec.LookPath
58 runClipboardTool = func(path string, args ...string) ([]byte, []byte, error) {
59 cmd := proc.Command(path, args...)
60 cmd.Env = secrets.ProcessEnv()
61 var stderr bytes.Buffer
62 cmd.Stderr = &stderr
63 out, err := cmd.Output()
64 return out, stderr.Bytes(), err
65 }
66 )
67
68 var attachmentPathSeq atomic.Uint64
69 var attachmentNow = time.Now
70 var safeAttachmentExt = regexp.MustCompile(`^\.[a-z0-9]{1,12}$`)
71
72 // SaveAttachmentDataURL stores a non-image file (dropped/pasted in the desktop
73 // app, where the browser exposes bytes but not a real path) under
74 // .reasonix/attachments and returns its repo-relative path for @referencing.
75 // origName supplies only the extension; the stored name is generated.
76 func SaveAttachmentDataURL(origName, dataURL string) (string, error) {
77 return SaveAttachmentDataURLInRoot(".", origName, dataURL)
78 }
79
80 func SaveAttachmentDataURLInRoot(root, origName, dataURL string) (string, error) {
81 const marker = ";base64,"
82 _, after, ok := strings.Cut(dataURL, marker)
83 if !strings.HasPrefix(dataURL, "data:") || !ok {
84 return "", fmt.Errorf("unsupported pasted file")
85 }
86 raw, err := base64.StdEncoding.DecodeString(after)
87 if err != nil {
88 return "", fmt.Errorf("decode pasted file: %w", err)
89 }
90 return SaveAttachmentBytesInRoot(root, origName, raw)
91 }
92
93 func SaveAttachmentBytes(origName string, raw []byte) (string, error) {
94 return SaveAttachmentBytesInRoot(".", origName, raw)
95 }
96
97 func SaveAttachmentBytesInRoot(root, origName string, raw []byte) (string, error) {
98 if len(raw) == 0 || len(raw) > maxFileAttachmentBytes {
99 return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
100 }
101 ext := strings.ToLower(filepath.Ext(origName))
102 if !safeAttachmentExt.MatchString(ext) {
103 ext = ".bin"
104 }
105 return saveAttachmentBytesInRoot(root, ext, raw)
106 }
107
108 func SaveImageDataURL(dataURL string) (string, error) {
109 return SaveImageDataURLInRoot(".", dataURL)
110 }
111
112 func SaveImageDataURLInRoot(root, dataURL string) (string, error) {
113 const prefix = "data:"
114 const marker = ";base64,"
115 if !strings.HasPrefix(dataURL, prefix) {
116 return "", fmt.Errorf("unsupported pasted image")
117 }
118 i := strings.Index(dataURL, marker)
119 if i <= len(prefix) {
120 return "", fmt.Errorf("unsupported pasted image")
121 }
122 mime := strings.ToLower(dataURL[len(prefix):i])
123 raw, err := base64.StdEncoding.DecodeString(dataURL[i+len(marker):])
124 if err != nil {
125 return "", fmt.Errorf("decode pasted image: %w", err)
126 }
127 return SaveImageBytesInRoot(root, mime, raw)
128 }
129
130 func SaveImageBytes(declaredMime string, raw []byte) (string, error) {
131 return SaveImageBytesInRoot(".", declaredMime, raw)
132 }
133
134 func SaveImageBytesInRoot(root, declaredMime string, raw []byte) (string, error) {
135 if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
136 return "", fmt.Errorf("pasted image must be between 1 byte and 64 MB")
137 }
138 mime := detectedImageMime(raw)
139 if mime == "" {
140 return "", fmt.Errorf("pasted data is not a supported image")
141 }
142 if declaredMime != "" && imageExt(declaredMime) == "" {
143 return "", fmt.Errorf("unsupported image type: %s", declaredMime)
144 }
145 ext := imageExt(mime)
146 return saveAttachmentBytesInRoot(root, ext, raw)
147 }
148
149 func saveAttachmentBytesInRoot(root, ext string, raw []byte) (string, error) {
150 if strings.TrimSpace(root) == "" {
151 root = "."
152 }
153 absRoot, err := filepath.Abs(root)
154 if err != nil {
155 return "", err
156 }
157 if err := ensureAttachmentRootIn(absRoot); err != nil {
158 return "", err
159 }
160 rel, f, err := createAttachmentFileIn(absRoot, ext)
161 if err != nil {
162 return "", err
163 }
164 if n, err := f.Write(raw); err != nil {
165 _ = f.Close()
166 _ = os.Remove(filepath.Join(absRoot, rel))
167 return "", err
168 } else if n != len(raw) {
169 _ = f.Close()
170 _ = os.Remove(filepath.Join(absRoot, rel))
171 return "", io.ErrShortWrite
172 }
173 if err := f.Close(); err != nil {
174 _ = os.Remove(filepath.Join(absRoot, rel))
175 return "", err
176 }
177 return filepath.ToSlash(rel), nil
178 }
179
180 func SaveImageFile(path string) (string, error) {
181 return SaveImageFileInRoot(".", path)
182 }
183
184 func SaveImageFileInRoot(root, path string) (string, error) {
185 info, err := os.Lstat(path)
186 if err != nil {
187 return "", err
188 }
189 if info.Mode()&os.ModeSymlink != 0 {
190 return "", fmt.Errorf("pasted image path must not be a symlink")
191 }
192 if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
193 return "", fmt.Errorf("pasted image must be between 1 byte and 64 MB")
194 }
195 f, err := os.Open(path)
196 if err != nil {
197 return "", err
198 }
199 defer f.Close()
200 opened, err := f.Stat()
201 if err != nil {
202 return "", err
203 }
204 if !os.SameFile(info, opened) {
205 return "", fmt.Errorf("pasted image changed while opening")
206 }
207 raw, err := io.ReadAll(io.LimitReader(f, maxImageAttachmentBytes+1))
208 if err != nil {
209 return "", err
210 }
211 if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
212 return "", fmt.Errorf("pasted image must be between 1 byte and 64 MB")
213 }
214 if after, err := f.Stat(); err != nil {
215 return "", err
216 } else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
217 return "", fmt.Errorf("pasted image changed while reading")
218 }
219 return SaveImageBytesInRoot(root, "", raw)
220 }
221
222 func SaveAttachmentFile(path string) (string, error) {
223 return SaveAttachmentFileInRoot(".", path)
224 }
225
226 func SaveAttachmentFileInRoot(root, path string) (string, error) {
227 info, err := os.Lstat(path)
228 if err != nil {
229 return "", err
230 }
231 if info.Mode()&os.ModeSymlink != 0 {
232 return "", fmt.Errorf("attachment path must not be a symlink")
233 }
234 if info.IsDir() || info.Size() <= 0 || info.Size() > maxFileAttachmentBytes {
235 return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
236 }
237 f, err := os.Open(path)
238 if err != nil {
239 return "", err
240 }
241 defer f.Close()
242 opened, err := f.Stat()
243 if err != nil {
244 return "", err
245 }
246 if !os.SameFile(info, opened) {
247 return "", fmt.Errorf("attachment changed while opening")
248 }
249 raw, err := io.ReadAll(io.LimitReader(f, maxFileAttachmentBytes+1))
250 if err != nil {
251 return "", err
252 }
253 if len(raw) == 0 || len(raw) > maxFileAttachmentBytes {
254 return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
255 }
256 if after, err := f.Stat(); err != nil {
257 return "", err
258 } else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
259 return "", fmt.Errorf("attachment changed while reading")
260 }
261 ext := strings.ToLower(filepath.Ext(path))
262 if !safeAttachmentExt.MatchString(ext) {
263 ext = ".bin"
264 }
265 return saveAttachmentBytesInRoot(root, ext, raw)
266 }
267
268 func SaveClipboardImage() (string, error) {
269 return SaveClipboardImageInRoot(".")
270 }
271
272 func SaveClipboardImageInRoot(root string) (string, error) {
273 switch runtime.GOOS {
274 case "darwin":
275 return saveDarwinClipboardImageInRoot(root)
276 case "windows":
277 return saveWindowsClipboardImageInRoot(root)
278 case "linux":
279 return saveLinuxClipboardImageInRoot(root)
280 default:
281 return "", fmt.Errorf("clipboard image paste is not supported on %s yet", runtime.GOOS)
282 }
283 }
284
285 func saveWindowsClipboardImageInRoot(root string) (string, error) {
286 // Windows PowerShell 5.1 (preinstalled) reaches the GUI clipboard; pwsh (Core)
287 // lacks Get-Clipboard -Format Image, so invoke powershell.exe. The PNG is
288 // returned as base64 on stdout so no temp file is involved.
289 script := `Add-Type -AssemblyName System.Drawing
290 $img = Get-Clipboard -Format Image
291 if ($null -eq $img) { [Console]::Error.WriteLine('clipboard has no image'); exit 1 }
292 $ms = New-Object System.IO.MemoryStream
293 $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
294 [Convert]::ToBase64String($ms.ToArray())`
295 cmd := proc.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
296 cmd.Env = secrets.ProcessEnv()
297 proc.HideWindow(cmd)
298 out, err := cmd.Output()
299 if err != nil {
300 var ee *exec.ExitError
301 if errors.As(err, &ee) && len(ee.Stderr) > 0 {
302 return "", fmt.Errorf("read clipboard image: %s", strings.TrimSpace(string(ee.Stderr)))
303 }
304 return "", fmt.Errorf("read clipboard image: %w", err)
305 }
306 raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out)))
307 if err != nil {
308 return "", fmt.Errorf("decode clipboard image: %w", err)
309 }
310 return SaveImageBytesInRoot(root, "", raw)
311 }
312
313 // clipboardImageTypes lists the image mimes we can save, most preferred
314 // first; Wayland compositors and screenshot apps offer any of these.
315 var clipboardImageTypes = []string{"image/png", "image/jpeg", "image/gif", "image/webp"}
316
317 func clipboardImageReadArgs(tool, mime string) []string {
318 if tool == "wl-paste" {
319 return []string{"--type", mime, "--no-newline"}
320 }
321 return []string{"-selection", "clipboard", "-t", mime, "-o"}
322 }
323
324 func saveLinuxClipboardImage() (string, error) {
325 return saveLinuxClipboardImageInRoot(".")
326 }
327
328 func saveLinuxClipboardImageInRoot(root string) (string, error) {
329 type clipboardTool struct {
330 name string
331 typesArgs []string
332 }
333 tools := []clipboardTool{
334 {name: "wl-paste", typesArgs: []string{"--list-types"}},
335 {name: "xclip", typesArgs: []string{"-selection", "clipboard", "-t", "TARGETS", "-o"}},
336 }
337 foundTool := false
338 confirmedNoImage := false
339 var probeFailures, readFailures []error
340 for _, tool := range tools {
341 path, err := lookClipboardTool(tool.name)
342 if err != nil {
343 continue
344 }
345 foundTool = true
346 types, stderr, err := runClipboardTool(path, tool.typesArgs...)
347 if err != nil {
348 if clipboardProbeMeansNoImage(tool.name, stderr) {
349 confirmedNoImage = true
350 continue
351 }
352 probeFailures = append(probeFailures, fmt.Errorf("probe %s clipboard types: %w", tool.name, err))
353 continue
354 }
355 mime := ""
356 for _, want := range clipboardImageTypes {
357 if clipboardTypeListed(types, want) {
358 mime = want
359 break
360 }
361 }
362 if mime == "" {
363 if offered := offeredImageTypes(types); len(offered) > 0 {
364 readFailures = append(readFailures, unsupportedClipboardImageError{tool: tool.name, types: offered})
365 continue
366 }
367 confirmedNoImage = true
368 continue
369 }
370 out, _, err := runClipboardTool(path, clipboardImageReadArgs(tool.name, mime)...)
371 if err != nil {
372 readFailures = append(readFailures, fmt.Errorf("read clipboard image with %s: %w", tool.name, err))
373 continue
374 }
375 if len(out) == 0 {
376 readFailures = append(readFailures, fmt.Errorf("read clipboard image with %s: empty image data", tool.name))
377 continue
378 }
379 rel, err := SaveImageBytesInRoot(root, "", out)
380 if err != nil {
381 readFailures = append(readFailures, fmt.Errorf("save clipboard image from %s: %w", tool.name, err))
382 continue
383 }
384 return rel, nil
385 }
386 if !foundTool {
387 return "", fmt.Errorf("clipboard image paste needs wl-paste (Wayland) or xclip (X11)")
388 }
389 if len(readFailures) > 0 {
390 return "", fmt.Errorf("read clipboard image: %w", errors.Join(readFailures...))
391 }
392 if confirmedNoImage {
393 return "", ErrNoClipboardImage
394 }
395 return "", fmt.Errorf("read clipboard image: %w", errors.Join(probeFailures...))
396 }
397
398 func clipboardTypeListed(raw []byte, want string) bool {
399 for field := range strings.FieldsSeq(string(raw)) {
400 if strings.EqualFold(field, want) {
401 return true
402 }
403 }
404 return false
405 }
406
407 // offeredImageTypes returns safely quoted image/* MIME names that Reasonix
408 // cannot save. Clipboard owners control these strings, so errors must never
409 // contain their terminal control sequences verbatim.
410 func offeredImageTypes(raw []byte) []string {
411 var offered []string
412 for field := range strings.FieldsSeq(string(raw)) {
413 lower := strings.ToLower(field)
414 if strings.HasPrefix(lower, "image/") && !slices.Contains(clipboardImageTypes, lower) {
415 offered = append(offered, strconv.QuoteToASCII(field))
416 }
417 }
418 return offered
419 }
420
421 func clipboardProbeMeansNoImage(tool string, stderr []byte) bool {
422 message := string(stderr)
423 switch tool {
424 case "wl-paste":
425 return strings.Contains(message, "Nothing is copied")
426 case "xclip":
427 return strings.Contains(message, "There is no owner for the") && strings.Contains(message, "selection")
428 default:
429 return false
430 }
431 }
432
433 func ImageDataURL(path string) (string, error) {
434 return ImageDataURLInRoot(".", path)
435 }
436
437 func ImageDataURLInRoot(root, path string) (string, error) {
438 raw, mime, err := readAttachmentImageInRoot(root, path)
439 if err != nil {
440 return "", err
441 }
442 return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
443 }
444
445 // visionImageDataURLInRoot prepares a bounded request image from a workspace attachment.
446 func visionImageDataURLInRoot(root, path string) (string, error) {
447 raw, mime, err := readAttachmentImageInRoot(root, path)
448 if err != nil {
449 return "", err
450 }
451 raw, mime = compressForVision(raw, mime)
452 return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
453 }
454
455 func readAttachmentImageInRoot(root, path string) (raw []byte, mime string, err error) {
456 if _, err := cleanAttachmentPathInRoot(root, path); err != nil {
457 return nil, "", err
458 }
459 raw, err = attachment.ReadWorkspaceImageBytes(root, path, maxImageAttachmentBytes)
460 if err != nil {
461 return nil, "", err
462 }
463 mime = detectedImageMime(raw)
464 if mime == "" {
465 return nil, "", fmt.Errorf("attachment is not an image")
466 }
467 return raw, mime, nil
468 }
469
470 // ValidateAttachmentInRoot checks a persisted attachment without loading its
471 // contents. The bounded root prevents parent replacement from escaping the
472 // workspace and Lstat rejects a final symlink.
473 func ValidateAttachmentInRoot(root, path string) error {
474 clean, err := cleanAttachmentPathInRoot(root, path)
475 if err != nil {
476 return err
477 }
478 absRoot, err := filepath.Abs(root)
479 if err != nil {
480 return err
481 }
482 rel, err := filepath.Rel(absRoot, clean)
483 if err != nil || !filepath.IsLocal(rel) {
484 return fmt.Errorf("attachment path is outside workspace")
485 }
486 bounded, err := os.OpenRoot(absRoot)
487 if err != nil {
488 return err
489 }
490 defer bounded.Close()
491 info, err := bounded.Lstat(rel)
492 if err != nil {
493 return err
494 }
495 if info.Mode()&os.ModeSymlink != 0 {
496 return fmt.Errorf("attachment path must not be a symlink")
497 }
498 if !info.Mode().IsRegular() || info.Size() <= 0 {
499 return fmt.Errorf("attachment is missing or empty")
500 }
501 f, err := bounded.OpenFile(rel, os.O_RDONLY, 0)
502 if err != nil {
503 return err
504 }
505 defer f.Close()
506 opened, err := f.Stat()
507 if err != nil {
508 return err
509 }
510 if !os.SameFile(info, opened) {
511 return fmt.Errorf("attachment changed while opening")
512 }
513 return nil
514 }
515
516 func cleanAttachmentPathInRoot(base, path string) (string, error) {
517 if filepath.IsAbs(path) {
518 return "", fmt.Errorf("attachment path must be relative")
519 }
520 clean := filepath.Clean(filepath.FromSlash(path))
521 relRoot := filepath.Join(".reasonix", "attachments")
522 if clean == "." || clean == relRoot || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !strings.HasPrefix(clean, relRoot+string(filepath.Separator)) {
523 return "", fmt.Errorf("attachment path is outside .reasonix/attachments")
524 }
525 if strings.TrimSpace(base) == "" {
526 base = "."
527 }
528 absBase, err := filepath.Abs(base)
529 if err != nil {
530 return "", err
531 }
532 root := filepath.Join(absBase, relRoot)
533 if err := validateAttachmentDirectories(absBase); err != nil {
534 return "", err
535 }
536 absPath := filepath.Join(absBase, clean)
537 if err := rejectSymlinkComponents(absPath, root); err != nil {
538 return "", err
539 }
540 return absPath, nil
541 }
542
543 func rejectSymlinkComponents(path, root string) error {
544 rel, err := filepath.Rel(root, path)
545 if err != nil {
546 return err
547 }
548 if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
549 return fmt.Errorf("attachment path is outside .reasonix/attachments")
550 }
551 cur := root
552 for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
553 if part == "" || part == "." {
554 continue
555 }
556 cur = filepath.Join(cur, part)
557 info, err := os.Lstat(cur)
558 if err != nil {
559 return err
560 }
561 if info.Mode()&os.ModeSymlink != 0 {
562 return fmt.Errorf("attachment path must not contain symlinks")
563 }
564 }
565 return nil
566 }
567
568 func ensureAttachmentRoot() error {
569 return ensureAttachmentRootIn(".")
570 }
571
572 func ensureAttachmentRootIn(base string) error {
573 if info, err := os.Lstat(filepath.Join(base, ".reasonix")); err == nil {
574 if info.Mode()&os.ModeSymlink != 0 {
575 return fmt.Errorf("attachment path must not contain symlinks")
576 }
577 if !info.IsDir() {
578 return fmt.Errorf("attachment path exists but is not a directory")
579 }
580 } else if !os.IsNotExist(err) {
581 return err
582 }
583 root := filepath.Join(base, ".reasonix", "attachments")
584 if info, err := os.Lstat(root); err == nil {
585 if info.Mode()&os.ModeSymlink != 0 {
586 return fmt.Errorf("attachment directory must not be a symlink")
587 }
588 if !info.IsDir() {
589 return fmt.Errorf("attachment path exists but is not a directory")
590 }
591 return nil
592 } else if !os.IsNotExist(err) {
593 return err
594 }
595 if err := os.MkdirAll(root, 0o755); err != nil {
596 return err
597 }
598 info, err := os.Lstat(root)
599 if err != nil {
600 return err
601 }
602 if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
603 return fmt.Errorf("attachment directory is invalid")
604 }
605 return nil
606 }
607
608 func validateAttachmentDirectories(base string) error {
609 for _, path := range []string{
610 filepath.Join(base, ".reasonix"),
611 filepath.Join(base, ".reasonix", "attachments"),
612 } {
613 info, err := os.Lstat(path)
614 if err != nil {
615 return err
616 }
617 if info.Mode()&os.ModeSymlink != 0 {
618 return fmt.Errorf("attachment path must not contain symlinks")
619 }
620 if !info.IsDir() {
621 return fmt.Errorf("attachment path exists but is not a directory")
622 }
623 }
624 return nil
625 }
626
627 func saveDarwinClipboardImageInRoot(root string) (string, error) {
628 return saveDarwinClipboardImageWith(func(class string) (string, error) {
629 return saveDarwinClipboardClassInRoot(root, class)
630 })
631 }
632
633 func saveDarwinClipboardImageWith(readClass func(string) (string, error)) (string, error) {
634 for _, class := range []string{"PNGf", "JPEG"} {
635 rel, err := readClass(class)
636 if err == nil {
637 return rel, nil
638 }
639 if !errors.Is(err, ErrNoClipboardImage) {
640 return "", err
641 }
642 }
643 return "", ErrNoClipboardImage
644 }
645
646 func saveDarwinClipboardClassInRoot(root, class string) (string, error) {
647 if strings.TrimSpace(root) == "" {
648 root = "."
649 }
650 absRoot, err := filepath.Abs(root)
651 if err != nil {
652 return "", err
653 }
654 if err := ensureAttachmentRootIn(absRoot); err != nil {
655 return "", err
656 }
657 rel, f, err := createAttachmentFileIn(absRoot, ".bin")
658 if err != nil {
659 return "", err
660 }
661 if err := f.Close(); err != nil {
662 _ = os.Remove(filepath.Join(absRoot, rel))
663 return "", err
664 }
665 abs := filepath.Join(absRoot, rel)
666 const noImageMarker = "__REASONIX_NO_CLIPBOARD_IMAGE__"
667 script := fmt.Sprintf(`
668 set hasImageType to false
669 repeat with typeEntry in (clipboard info)
670 if (item 1 of typeEntry) is «class %s» then
671 set hasImageType to true
672 exit repeat
673 end if
674 end repeat
675 if not hasImageType then return %q
676 set outPath to POSIX file %q
677 set img to the clipboard as «class %s»
678 set f to open for access outPath with write permission
679 try
680 set eof f to 0
681 write img to f
682 close access f
683 on error errMsg
684 try
685 close access f
686 end try
687 error errMsg
688 end try
689 `, class, noImageMarker, abs, class)
690 clip := proc.Command("osascript", "-e", script)
691 clip.Env = secrets.ProcessEnv()
692 out, runErr := clip.CombinedOutput()
693 if err := classifyDarwinClipboardResult(out, runErr, noImageMarker); err != nil {
694 _ = os.Remove(abs)
695 return "", err
696 }
697 raw, err := os.ReadFile(abs)
698 _ = os.Remove(abs)
699 if err != nil {
700 return "", err
701 }
702 return SaveImageBytesInRoot(absRoot, "", raw)
703 }
704
705 func classifyDarwinClipboardResult(out []byte, runErr error, noImageMarker string) error {
706 detail := strings.TrimSpace(string(out))
707 if runErr == nil {
708 if detail == noImageMarker {
709 return ErrNoClipboardImage
710 }
711 return nil
712 }
713 if detail == "" {
714 return fmt.Errorf("read clipboard image: %w", runErr)
715 }
716 return fmt.Errorf("read clipboard image: %s: %w", detail, runErr)
717 }
718
719 func createAttachmentFile(ext string) (string, *os.File, error) {
720 return createAttachmentFileIn(".", ext)
721 }
722
723 func createAttachmentFileIn(base, ext string) (string, *os.File, error) {
724 for range maxAttachmentCreateAttempts {
725 rel := attachmentPath(ext)
726 f, err := os.OpenFile(filepath.Join(base, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
727 if os.IsExist(err) {
728 continue
729 }
730 if err != nil {
731 return "", nil, err
732 }
733 return rel, f, nil
734 }
735 return "", nil, fmt.Errorf("create unique attachment path")
736 }
737
738 func attachmentPath(ext string) string {
739 seq := attachmentPathSeq.Add(1)
740 name := fmt.Sprintf("clipboard-%s-%06d%s", attachmentNow().Format("20060102-150405.000000"), seq, ext)
741 return filepath.Join(".reasonix", "attachments", name)
742 }
743
744 func detectedImageMime(raw []byte) string {
745 if len(raw) == 0 {
746 return ""
747 }
748 mime := http.DetectContentType(raw[:min(len(raw), 512)])
749 if imageExt(mime) == "" {
750 return ""
751 }
752 return mime
753 }
754
755 func imageExt(mime string) string {
756 switch strings.ToLower(strings.TrimSpace(mime)) {
757 case "image/png":
758 return ".png"
759 case "image/jpeg":
760 return ".jpg"
761 case "image/gif":
762 return ".gif"
763 case "image/webp":
764 return ".webp"
765 }
766 return ""
767 }
768
768 lines GO