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