package client import ( "bytes" "context" "errors" "log/slog" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/keys/kittykey" ) // ctrlV is the byte a Ctrl+V keypress sends on the wire. const ctrlV = 0x16 // maxClipboardImagePush caps the image the client will send in one // ImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits // both sshstdio and the datagram transport's fragmented payload ceiling. const maxClipboardImagePush = 1 << 20 // 1 MiB // clipboardIntercept sits between the terminal's theme scanner and the paste // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V // (0x16): each occurrence triggers a clipboard image read, sent as one // ImagePush envelope instead of the keystroke. Everything else — ordinary // bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted // text may legitimately contain it) — is forwarded to next unchanged, so it // still gets the coalescer's marker-splitting treatment. type clipboardIntercept struct { // ctx is the foreground lifetime owning this scanner. A slow // workstation read (e.g. a hung wl-paste) must abort when the // foreground is stopped for a route switch, so the pump cannot hold // the switch waiting and a late result cannot reach a replacement // owner. Adapters map cancellation onto ErrNoClipboardImage, which // takes the existing forward path: the keystroke stays ordinary // input and follows the standard undelivered-byte handoff. ctx context.Context coalescer *pasteCoalescer reader ports.ClipboardReader log *slog.Logger sendImage func(mime string, data []byte) sendNotice func(action uint8) next func([]byte) } // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it // can be used interchangeably as the scanner sink. // // It must never scan for Ctrl+V past the point where a bracketed paste // starts (or might start): pasted text can legitimately contain 0x16, and // the paste's opening marker can itself be split across two reads. So: // // 1. If the coalescer is already Buffering (mid-paste, or holding a // trailing strict prefix of the opening marker from a previous call), // the whole chunk is handed to next untouched. // 2. Otherwise, this chunk is scanned only up to the first byte that either // starts the opening marker in full or could be the start of a marker // split across this chunk's end (pasteMarkerBoundary); everything from // that point onward is handed to next as one piece, unscanned, letting // the coalescer's own state decide what happens to it (and to any // bytes trailing an in-chunk paste — see the package-level note below). func (c *clipboardIntercept) Scan(data []byte) { if c.coalescer.Buffering() { c.next(data) return } boundary := pasteMarkerBoundary(data) segment := data[:boundary] for len(segment) > 0 { idx, size := indexCtrlV(segment) if idx < 0 { c.next(segment) break } if idx > 0 { c.next(segment[:idx]) } raw := segment[idx : idx+size] segment = segment[idx+size:] c.handleCtrlV(raw) } // Bytes at/after boundary are (the possible start of) a bracketed // paste: handed off whole, so any Ctrl+V trailing a paste that closes // within this same chunk is not intercepted either. In practice a real // keypress never arrives concatenated with paste bytes in one read, so // this is a deliberate, narrow trade-off in favor of never breaking // paste content. if tail := data[boundary:]; len(tail) > 0 { c.next(tail) } } // pasteMarkerBoundary returns the index in data at or after which bytes must // not be scanned for Ctrl+V because they start (or, at the very end of data, // might be a split-across-reads prefix of) the bracketed-paste opening // marker. It returns len(data) when neither is present. func pasteMarkerBoundary(data []byte) int { if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 { return idx } if p := trailingOpenPrefixLen(data); p > 0 { return len(data) - p } return len(data) } // indexCtrlV finds the first Ctrl+V keypress: the legacy 0x16 byte or a // kitty keyboard protocol press of Ctrl+V on any layout. func indexCtrlV(data []byte) (idx, size int) { for i, b := range data { switch b { case ctrlV: return i, 1 case 0x1b: if ev, n, ok, _ := kittykey.Parse(data[i:]); ok && isKittyCtrlV(ev) { return i, n } } } return -1, 0 } func isKittyCtrlV(ev kittykey.Event) bool { return !ev.Release && ev.Mods == kittykey.ModCtrl && (ev.Code == 'v' || ev.Base == 'v') } func (c *clipboardIntercept) handleCtrlV(raw []byte) { ctx := c.ctx if ctx == nil { ctx = context.Background() } mime, data, err := c.reader.ReadImage(ctx) if err != nil { if !errors.Is(err, ports.ErrNoClipboardImage) { c.log.Warn("clipboard image read failed", "err", err) if c.sendNotice != nil { c.sendNotice(protocol.ClientNoticeClipboardFallback) } } c.next(raw) return } if len(data) > maxClipboardImagePush { c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) if c.sendNotice != nil { c.sendNotice(protocol.ClientNoticeClipboardTooLarge) } c.next(raw) return } c.sendImage(mime, data) }