1//go:build linux
2
3// Package clipboard implements ports.ClipboardReader over Wayland's
4// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
5// attach forward a locally clipped image to the daemon (see
6// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
7// xclip or OSC-52 backend could implement the same interface later without
8// touching the wire protocol.
9package clipboard
10
11import (
12 "bytes"
13 "context"
14 "os/exec"
15 "strings"
16 "time"
17
18 "github.com/bnema/vev/internal/ports"
19)
20
21// mimePreference orders candidate image MIME types the same way pi (the
22// reference TUI agent whose local Ctrl+V behavior this replicates) does:
23// png first, then the other common web/image formats.
24var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
25
26// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
27// missing wl-paste never stalls the stdin pump for long.
28const listTypesTimeout = 1 * time.Second
29
30// defaultReadTimeout bounds the actual clipboard data read. Without this, a
31// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
32// which listTypesTimeout already covers) would stall the client's stdin pump
33// indefinitely, since ReadImage is called with context.Background() from the
34// interceptor. Keep this short so a slow clipboard read cannot noticeably
35// freeze typing for longer than the image-transfer MVP's small payload cap.
36const defaultReadTimeout = time.Second
37
38// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
39// holds no state and is safe for concurrent use; every call runs a fresh
40// subprocess.
41type WlPaste struct {
42 // execCommand constructs the command to run; overridable in tests so
43 // command construction can be verified without requiring wl-paste (or
44 // even Wayland) to be installed.
45 execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
46 // readTimeout bounds the data-read command; zero means defaultReadTimeout
47 // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
48 // still gets a sane default). Tests needing a short deadline set it
49 // explicitly.
50 readTimeout time.Duration
51}
52
53// New returns a WlPaste clipboard reader that shells out to the real
54// wl-paste binary.
55func New() ports.ClipboardReader {
56 return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
57}
58
59func (w *WlPaste) readTimeoutOrDefault() time.Duration {
60 if w.readTimeout <= 0 {
61 return defaultReadTimeout
62 }
63 return w.readTimeout
64}
65
66// ReadImage implements ports.ClipboardReader. It lists the clipboard's
67// offered MIME types, picks the best supported image type (if any), and
68// reads it. Any failure (wl-paste missing, no image on the clipboard, a
69// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
70// uniformly fall back to forwarding the keystroke that triggered the read.
71func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
72 listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
73 defer cancel()
74
75 listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
76 out, err := listCmd.Output()
77 if err != nil {
78 return "", nil, ports.ErrNoClipboardImage
79 }
80
81 mime := pickImageMime(string(out))
82 if mime == "" {
83 return "", nil, ports.ErrNoClipboardImage
84 }
85
86 readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
87 defer readCancel()
88
89 readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
90 var buf bytes.Buffer
91 readCmd.Stdout = &buf
92 if err := readCmd.Run(); err != nil {
93 return "", nil, ports.ErrNoClipboardImage
94 }
95 return mime, buf.Bytes(), nil
96}
97
98// pickImageMime scans wl-paste --list-types output (one MIME type per line)
99// and returns the first match in mimePreference order, or "" if the
100// clipboard offers no supported image type.
101func pickImageMime(listTypesOutput string) string {
102 offered := make(map[string]bool)
103 for _, line := range strings.Split(listTypesOutput, "\n") {
104 line = strings.TrimSpace(line)
105 if line != "" {
106 offered[line] = true
107 }
108 }
109 for _, mime := range mimePreference {
110 if offered[mime] {
111 return mime
112 }
113 }
114 return ""
115}