1package client
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "log/slog"
8
9 "github.com/bnema/vev/internal/ports"
10)
11
12// ctrlV is the byte a Ctrl+V keypress sends on the wire.
13const ctrlV = 0x16
14
15// maxClipboardImagePush caps the image the client will send in one
16// MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
17// both sshstdio and the datagram transport's fragmented payload ceiling.
18const maxClipboardImagePush = 1 << 20 // 1 MiB
19
20// clipboardIntercept sits between the terminal's theme scanner and the paste
21// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
22// (0x16): each occurrence triggers a clipboard image read, sent as one
23// MsgImagePush frame instead of the keystroke. Everything else — ordinary
24// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
25// text may legitimately contain it) — is forwarded to next unchanged, so it
26// still gets the coalescer's marker-splitting treatment.
27type clipboardIntercept struct {
28 coalescer *pasteCoalescer
29 reader ports.ClipboardReader
30 log *slog.Logger
31 sendImage func(mime string, data []byte)
32 sendNotice func(action uint8)
33 next func([]byte)
34}
35
36// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
37// can be used interchangeably as the scanner sink.
38//
39// It must never scan for Ctrl+V past the point where a bracketed paste
40// starts (or might start): pasted text can legitimately contain 0x16, and
41// the paste's opening marker can itself be split across two reads. So:
42//
43// 1. If the coalescer is already Buffering (mid-paste, or holding a
44// trailing strict prefix of the opening marker from a previous call),
45// the whole chunk is handed to next untouched.
46// 2. Otherwise, this chunk is scanned only up to the first byte that either
47// starts the opening marker in full or could be the start of a marker
48// split across this chunk's end (pasteMarkerBoundary); everything from
49// that point onward is handed to next as one piece, unscanned, letting
50// the coalescer's own state decide what happens to it (and to any
51// bytes trailing an in-chunk paste — see the package-level note below).
52func (c *clipboardIntercept) Scan(data []byte) {
53 if c.coalescer.Buffering() {
54 c.next(data)
55 return
56 }
57
58 boundary := pasteMarkerBoundary(data)
59 segment := data[:boundary]
60 for len(segment) > 0 {
61 idx := bytes.IndexByte(segment, ctrlV)
62 if idx < 0 {
63 c.next(segment)
64 break
65 }
66 if idx > 0 {
67 c.next(segment[:idx])
68 }
69 segment = segment[idx+1:]
70 c.handleCtrlV()
71 }
72
73 // Bytes at/after boundary are (the possible start of) a bracketed
74 // paste: handed off whole, so any Ctrl+V trailing a paste that closes
75 // within this same chunk is not intercepted either. In practice a real
76 // keypress never arrives concatenated with paste bytes in one read, so
77 // this is a deliberate, narrow trade-off in favor of never breaking
78 // paste content.
79 if tail := data[boundary:]; len(tail) > 0 {
80 c.next(tail)
81 }
82}
83
84// pasteMarkerBoundary returns the index in data at or after which bytes must
85// not be scanned for Ctrl+V because they start (or, at the very end of data,
86// might be a split-across-reads prefix of) the bracketed-paste opening
87// marker. It returns len(data) when neither is present.
88func pasteMarkerBoundary(data []byte) int {
89 if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
90 return idx
91 }
92 if p := trailingOpenPrefixLen(data); p > 0 {
93 return len(data) - p
94 }
95 return len(data)
96}
97
98func (c *clipboardIntercept) handleCtrlV() {
99 mime, data, err := c.reader.ReadImage(context.Background())
100 if err != nil {
101 if !errors.Is(err, ports.ErrNoClipboardImage) {
102 c.log.Warn("clipboard image read failed", "err", err)
103 if c.sendNotice != nil {
104 c.sendNotice(ports.ClientNoticeClipboardFallback)
105 }
106 }
107 c.next([]byte{ctrlV})
108 return
109 }
110 if len(data) > maxClipboardImagePush {
111 c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
112 if c.sendNotice != nil {
113 c.sendNotice(ports.ClientNoticeClipboardTooLarge)
114 }
115 c.next([]byte{ctrlV})
116 return
117 }
118 c.sendImage(mime, data)
119}