1package client
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "log/slog"
8
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/keys/kittykey"
12)
13
14// ctrlV is the byte a Ctrl+V keypress sends on the wire.
15const ctrlV = 0x16
16
17// maxClipboardImagePush caps the image the client will send in one
18// ImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
19// both sshstdio and the datagram transport's fragmented payload ceiling.
20const maxClipboardImagePush = 1 << 20 // 1 MiB
21
22// clipboardIntercept sits between the terminal's theme scanner and the paste
23// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
24// (0x16): each occurrence triggers a clipboard image read, sent as one
25// ImagePush envelope instead of the keystroke. Everything else — ordinary
26// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
27// text may legitimately contain it) — is forwarded to next unchanged, so it
28// still gets the coalescer's marker-splitting treatment.
29type clipboardIntercept struct {
30 // ctx is the foreground lifetime owning this scanner. A slow
31 // workstation read (e.g. a hung wl-paste) must abort when the
32 // foreground is stopped for a route switch, so the pump cannot hold
33 // the switch waiting and a late result cannot reach a replacement
34 // owner. Adapters map cancellation onto ErrNoClipboardImage, which
35 // takes the existing forward path: the keystroke stays ordinary
36 // input and follows the standard undelivered-byte handoff.
37 ctx context.Context
38 coalescer *pasteCoalescer
39 reader ports.ClipboardReader
40 log *slog.Logger
41 sendImage func(mime string, data []byte)
42 sendNotice func(action uint8)
43 next func([]byte)
44}
45
46// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
47// can be used interchangeably as the scanner sink.
48//
49// It must never scan for Ctrl+V past the point where a bracketed paste
50// starts (or might start): pasted text can legitimately contain 0x16, and
51// the paste's opening marker can itself be split across two reads. So:
52//
53// 1. If the coalescer is already Buffering (mid-paste, or holding a
54// trailing strict prefix of the opening marker from a previous call),
55// the whole chunk is handed to next untouched.
56// 2. Otherwise, this chunk is scanned only up to the first byte that either
57// starts the opening marker in full or could be the start of a marker
58// split across this chunk's end (pasteMarkerBoundary); everything from
59// that point onward is handed to next as one piece, unscanned, letting
60// the coalescer's own state decide what happens to it (and to any
61// bytes trailing an in-chunk paste — see the package-level note below).
62func (c *clipboardIntercept) Scan(data []byte) {
63 if c.coalescer.Buffering() {
64 c.next(data)
65 return
66 }
67
68 boundary := pasteMarkerBoundary(data)
69 segment := data[:boundary]
70 for len(segment) > 0 {
71 idx, size := indexCtrlV(segment)
72 if idx < 0 {
73 c.next(segment)
74 break
75 }
76 if idx > 0 {
77 c.next(segment[:idx])
78 }
79 raw := segment[idx : idx+size]
80 segment = segment[idx+size:]
81 c.handleCtrlV(raw)
82 }
83
84 // Bytes at/after boundary are (the possible start of) a bracketed
85 // paste: handed off whole, so any Ctrl+V trailing a paste that closes
86 // within this same chunk is not intercepted either. In practice a real
87 // keypress never arrives concatenated with paste bytes in one read, so
88 // this is a deliberate, narrow trade-off in favor of never breaking
89 // paste content.
90 if tail := data[boundary:]; len(tail) > 0 {
91 c.next(tail)
92 }
93}
94
95// pasteMarkerBoundary returns the index in data at or after which bytes must
96// not be scanned for Ctrl+V because they start (or, at the very end of data,
97// might be a split-across-reads prefix of) the bracketed-paste opening
98// marker. It returns len(data) when neither is present.
99func pasteMarkerBoundary(data []byte) int {
100 if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
101 return idx
102 }
103 if p := trailingOpenPrefixLen(data); p > 0 {
104 return len(data) - p
105 }
106 return len(data)
107}
108
109// indexCtrlV finds the first Ctrl+V keypress: the legacy 0x16 byte or a
110// kitty keyboard protocol press of Ctrl+V on any layout.
111func indexCtrlV(data []byte) (idx, size int) {
112 for i, b := range data {
113 switch b {
114 case ctrlV:
115 return i, 1
116 case 0x1b:
117 if ev, n, ok, _ := kittykey.Parse(data[i:]); ok && isKittyCtrlV(ev) {
118 return i, n
119 }
120 }
121 }
122 return -1, 0
123}
124
125func isKittyCtrlV(ev kittykey.Event) bool {
126 return !ev.Release && ev.Mods == kittykey.ModCtrl && (ev.Code == 'v' || ev.Base == 'v')
127}
128
129func (c *clipboardIntercept) handleCtrlV(raw []byte) {
130 ctx := c.ctx
131 if ctx == nil {
132 ctx = context.Background()
133 }
134 mime, data, err := c.reader.ReadImage(ctx)
135 if err != nil {
136 if !errors.Is(err, ports.ErrNoClipboardImage) {
137 c.log.Warn("clipboard image read failed", "err", err)
138 if c.sendNotice != nil {
139 c.sendNotice(protocol.ClientNoticeClipboardFallback)
140 }
141 }
142 c.next(raw)
143 return
144 }
145 if len(data) > maxClipboardImagePush {
146 c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
147 if c.sendNotice != nil {
148 c.sendNotice(protocol.ClientNoticeClipboardTooLarge)
149 }
150 c.next(raw)
151 return
152 }
153 c.sendImage(mime, data)
154}