feat(client,daemon): transfer clipboard image to remote pane as temp-file path
On a remote attach, the stdin pump intercepts a bare Ctrl+V (0x16): if wl-paste reports an image on the clipboard (png > jpeg > webp > gif preference), it's read and sent as one MsgImagePush frame instead of the keystroke; otherwise 0x16 is forwarded exactly as today. The interceptor never reinterprets bytes while the paste coalescer is buffering (mid-paste or holding a split marker prefix), so pasted text containing 0x16 is never mistaken for a keypress. Local attaches are unaffected — 0x16 always passes through so the local agent's own clipboard handling applies. The daemon writes MsgImagePush bytes to a 0600 temp file (path recorded on the session and cleaned up at session end), then injects the path into the focused pane through the normal write path, wrapped in bracketed-paste markers iff the pane has DEC mode 2004 enabled. Both sides independently cap payloads at 10 MiB. Adds the internal/adapters/clipboard wl-paste adapter behind the new ports.ClipboardReader interface. Includes the approved design doc.
- commit
- f71e65c81fc590bab55211762be488a6e74a747c
- author
- brice · · 2026-07-04 14:48 UTC
- committer
- brice · · 2026-07-04 14:51 UTC
- parent
- 383c02c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +1174 -18
A docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | # Clipboard image transfer (local → remote pane) | |
| 2 | + | ||
| 3 | + | Date: 2026-07-04 · Status: approved (approach A) | |
| 4 | + | ||
| 5 | + | ## Problem | |
| 6 | + | ||
| 7 | + | When attached to a remote session, pasting an image for a TUI coding agent (pi, claude) is impossible: the agent's Ctrl+V handler reads the *remote* clipboard, which doesn't exist on a headless host (pi's chain — `wl-paste` → `xclip` → native — returns null in every branch without `WAYLAND_DISPLAY`/`DISPLAY`; no OSC 52 image path exists). The image lives on the laptop. | |
| 8 | + | ||
| 9 | + | ## Approach | |
| 10 | + | ||
| 11 | + | Replicate the *end state* of pi's local Ctrl+V, which is: write the image to a temp file and insert the literal file path as text at the editor cursor (pi-mono `interactive-mode.ts:2536-2555`). vev does the same across the wire: | |
| 12 | + | ||
| 13 | + | 1. **Client (remote attach only):** intercept `0x16` (Ctrl+V) on stdin. Run `wl-paste --list-types` (1s timeout). If an `image/*` type is present, pick by preference `png > jpeg > webp > gif` (same order as pi), read it with `wl-paste --type <mime> --no-newline`, and send one `ImagePush` frame. If no image / wl-paste missing / any error: forward `0x16` verbatim (today's behavior). | |
| 14 | + | 2. **Daemon:** on `ImagePush`, write the bytes to `os.TempDir()/vev-clip-<unixts>.<ext>` with mode 0600, remember the path on the session, and inject the path text into the focused pane's PTY — wrapped in `\x1b[200~…\x1b[201~` iff the pane's `Screen.BracketedPasteMode()` is true (first production use of the mode-2004 tracking), bare text otherwise. Injection goes through the same write path as regular input (`writeToPane`). | |
| 15 | + | 3. **Cleanup:** files recorded on the session are removed when the session ends (same lifecycle as the session teardown path). Best-effort; `/tmp` is the backstop. | |
| 16 | + | ||
| 17 | + | ## Wire protocol | |
| 18 | + | ||
| 19 | + | - New client-originated message `MsgImagePush` (next free type in the 1–15 range) in `internal/ports`: `{ Mime string, Data []byte }`, hand-marshalled big-endian like the rest of `wire.go`. | |
| 20 | + | - Bump `ProtocolVersion` (strict-equality negotiation; `Hello.Version` stays first field). | |
| 21 | + | - Byte-for-byte golden tests + truncated-prefix / trailing-garbage invariants in `wire_test.go`, per repo protocol rules. | |
| 22 | + | - Caps: client refuses images > 10 MiB (notifies via stderr/status); frame limit is 16 MiB so one frame always suffices. Daemon independently rejects > 10 MiB payloads (defense against old/foreign clients). | |
| 23 | + | ||
| 24 | + | ## Scope decisions | |
| 25 | + | ||
| 26 | + | - **Remote attaches only.** On local attach, `0x16` passes through untouched — the agent reads the local clipboard natively and its own temp-file flow works. | |
| 27 | + | - **Wayland only** on the client side (user's environment). The clipboard read sits behind a small client-side seam (interface in the client package or `ports` if it must cross layers) so `xclip`/OSC-52 backends can be added later without protocol changes. | |
| 28 | + | - **No image conversion.** Bytes are transferred as-is; unsupported exotic formats are the agent's problem (pi converts BMP itself locally; that path simply isn't reachable here). | |
| 29 | + | - **Known caveat:** while an image is on the local clipboard, Ctrl+V won't reach remote pane apps that bind it themselves (e.g. vim visual block). Text/empty clipboard → passthrough unchanged. | |
| 30 | + | ||
| 31 | + | ## Testing | |
| 32 | + | ||
| 33 | + | - Client: fake clipboard runner (seam above) — image present → one ImagePush frame, no `0x16` forwarded; no image → `0x16` forwarded; oversized → refused + `0x16` not swallowed silently. | |
| 34 | + | - Ports: golden wire tests as above. | |
| 35 | + | - Daemon: ImagePush → file created 0600 with exact bytes, path injected with/without bracketed-paste wrapping depending on pane mode; session end removes the file; oversized rejected. | |
| 36 | + | - Manual: end-to-end against the scratchpad harness (fake-ssh `_stdio` path) with a real PNG. | |
| 37 | + | ||
| 38 | + | ## Out of scope | |
| 39 | + | ||
| 40 | + | Remote→local image transfer, multi-image batching, X11 client support, progress UI for large images. | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | # Clipboard image transfer (local → remote pane) | |||
| 2 | + | ||||
| 3 | + | Date: 2026-07-04 · Status: approved (approach A) | |||
| 4 | + | ||||
| 5 | + | ## Problem | |||
| 6 | + | ||||
| 7 | + | When attached to a remote session, pasting an image for a TUI coding agent (pi, claude) is impossible: the agent's Ctrl+V handler reads the *remote* clipboard, which doesn't exist on a headless host (pi's chain — `wl-paste` → `xclip` → native — returns null in every branch without `WAYLAND_DISPLAY`/`DISPLAY`; no OSC 52 image path exists). The image lives on the laptop. | |||
| 8 | + | ||||
| 9 | + | ## Approach | |||
| 10 | + | ||||
| 11 | + | Replicate the *end state* of pi's local Ctrl+V, which is: write the image to a temp file and insert the literal file path as text at the editor cursor (pi-mono `interactive-mode.ts:2536-2555`). vev does the same across the wire: | |||
| 12 | + | ||||
| 13 | + | 1. **Client (remote attach only):** intercept `0x16` (Ctrl+V) on stdin. Run `wl-paste --list-types` (1s timeout). If an `image/*` type is present, pick by preference `png > jpeg > webp > gif` (same order as pi), read it with `wl-paste --type <mime> --no-newline`, and send one `ImagePush` frame. If no image / wl-paste missing / any error: forward `0x16` verbatim (today's behavior). | |||
| 14 | + | 2. **Daemon:** on `ImagePush`, write the bytes to `os.TempDir()/vev-clip-<unixts>.<ext>` with mode 0600, remember the path on the session, and inject the path text into the focused pane's PTY — wrapped in `\x1b[200~…\x1b[201~` iff the pane's `Screen.BracketedPasteMode()` is true (first production use of the mode-2004 tracking), bare text otherwise. Injection goes through the same write path as regular input (`writeToPane`). | |||
| 15 | + | 3. **Cleanup:** files recorded on the session are removed when the session ends (same lifecycle as the session teardown path). Best-effort; `/tmp` is the backstop. | |||
| 16 | + | ||||
| 17 | + | ## Wire protocol | |||
| 18 | + | ||||
| 19 | + | - New client-originated message `MsgImagePush` (next free type in the 1–15 range) in `internal/ports`: `{ Mime string, Data []byte }`, hand-marshalled big-endian like the rest of `wire.go`. | |||
| 20 | + | - Bump `ProtocolVersion` (strict-equality negotiation; `Hello.Version` stays first field). | |||
| 21 | + | - Byte-for-byte golden tests + truncated-prefix / trailing-garbage invariants in `wire_test.go`, per repo protocol rules. | |||
| 22 | + | - Caps: client refuses images > 10 MiB (notifies via stderr/status); frame limit is 16 MiB so one frame always suffices. Daemon independently rejects > 10 MiB payloads (defense against old/foreign clients). | |||
| 23 | + | ||||
| 24 | + | ## Scope decisions | |||
| 25 | + | ||||
| 26 | + | - **Remote attaches only.** On local attach, `0x16` passes through untouched — the agent reads the local clipboard natively and its own temp-file flow works. | |||
| 27 | + | - **Wayland only** on the client side (user's environment). The clipboard read sits behind a small client-side seam (interface in the client package or `ports` if it must cross layers) so `xclip`/OSC-52 backends can be added later without protocol changes. | |||
| 28 | + | - **No image conversion.** Bytes are transferred as-is; unsupported exotic formats are the agent's problem (pi converts BMP itself locally; that path simply isn't reachable here). | |||
| 29 | + | - **Known caveat:** while an image is on the local clipboard, Ctrl+V won't reach remote pane apps that bind it themselves (e.g. vim visual block). Text/empty clipboard → passthrough unchanged. | |||
| 30 | + | ||||
| 31 | + | ## Testing | |||
| 32 | + | ||||
| 33 | + | - Client: fake clipboard runner (seam above) — image present → one ImagePush frame, no `0x16` forwarded; no image → `0x16` forwarded; oversized → refused + `0x16` not swallowed silently. | |||
| 34 | + | - Ports: golden wire tests as above. | |||
| 35 | + | - Daemon: ImagePush → file created 0600 with exact bytes, path injected with/without bracketed-paste wrapping depending on pane mode; session end removes the file; oversized rejected. | |||
| 36 | + | - Manual: end-to-end against the scratchpad harness (fake-ssh `_stdio` path) with a real PNG. | |||
| 37 | + | ||||
| 38 | + | ## Out of scope | |||
| 39 | + | ||||
| 40 | + | Remote→local image transfer, multi-image batching, X11 client support, progress UI for large images. | |||
A internal/adapters/clipboard/wlpaste.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | // Package clipboard implements ports.ClipboardReader over Wayland's | |
| 2 | + | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | |
| 3 | + | // attach forward a locally clipped image to the daemon (see | |
| 4 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an | |
| 5 | + | // xclip or OSC-52 backend could implement the same interface later without | |
| 6 | + | // touching the wire protocol. | |
| 7 | + | package clipboard | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "bytes" | |
| 11 | + | "context" | |
| 12 | + | "os/exec" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // mimePreference orders candidate image MIME types the same way pi (the | |
| 20 | + | // reference TUI agent whose local Ctrl+V behavior this replicates) does: | |
| 21 | + | // png first, then the other common web/image formats. | |
| 22 | + | var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"} | |
| 23 | + | ||
| 24 | + | // listTypesTimeout bounds the "what's on the clipboard" probe so a hung or | |
| 25 | + | // missing wl-paste never stalls the stdin pump for long. | |
| 26 | + | const listTypesTimeout = 1 * time.Second | |
| 27 | + | ||
| 28 | + | // defaultReadTimeout bounds the actual clipboard data read. Without this, a | |
| 29 | + | // wl-paste that hangs mid-read (as opposed to during the --list-types probe, | |
| 30 | + | // which listTypesTimeout already covers) would stall the client's stdin pump | |
| 31 | + | // indefinitely, since ReadImage is called with context.Background() from the | |
| 32 | + | // interceptor. Generous for a read of up to the 10 MiB cap. | |
| 33 | + | const defaultReadTimeout = 10 * 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. | |
| 38 | + | type 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. | |
| 52 | + | func New() *WlPaste { | |
| 53 | + | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (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. | |
| 68 | + | func (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. | |
| 98 | + | func 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 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | // Package clipboard implements ports.ClipboardReader over Wayland's | |||
| 2 | + | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | |||
| 3 | + | // attach forward a locally clipped image to the daemon (see | |||
| 4 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an | |||
| 5 | + | // xclip or OSC-52 backend could implement the same interface later without | |||
| 6 | + | // touching the wire protocol. | |||
| 7 | + | package clipboard | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "bytes" | |||
| 11 | + | "context" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "strings" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // mimePreference orders candidate image MIME types the same way pi (the | |||
| 20 | + | // reference TUI agent whose local Ctrl+V behavior this replicates) does: | |||
| 21 | + | // png first, then the other common web/image formats. | |||
| 22 | + | var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"} | |||
| 23 | + | ||||
| 24 | + | // listTypesTimeout bounds the "what's on the clipboard" probe so a hung or | |||
| 25 | + | // missing wl-paste never stalls the stdin pump for long. | |||
| 26 | + | const listTypesTimeout = 1 * time.Second | |||
| 27 | + | ||||
| 28 | + | // defaultReadTimeout bounds the actual clipboard data read. Without this, a | |||
| 29 | + | // wl-paste that hangs mid-read (as opposed to during the --list-types probe, | |||
| 30 | + | // which listTypesTimeout already covers) would stall the client's stdin pump | |||
| 31 | + | // indefinitely, since ReadImage is called with context.Background() from the | |||
| 32 | + | // interceptor. Generous for a read of up to the 10 MiB cap. | |||
| 33 | + | const defaultReadTimeout = 10 * 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. | |||
| 38 | + | type 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. | |||
| 52 | + | func New() *WlPaste { | |||
| 53 | + | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (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. | |||
| 68 | + | func (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. | |||
| 98 | + | func 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 | + | } | |||
A internal/adapters/clipboard/wlpaste_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os/exec" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) { | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | output string | |
| 17 | + | want string | |
| 18 | + | }{ | |
| 19 | + | {"no types", "", ""}, | |
| 20 | + | {"text only", "text/plain\nUTF8_STRING\n", ""}, | |
| 21 | + | {"png present", "text/plain\nimage/png\n", "image/png"}, | |
| 22 | + | {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"}, | |
| 23 | + | {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"}, | |
| 24 | + | {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"}, | |
| 25 | + | {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"}, | |
| 26 | + | {"unsupported image type only", "image/bmp\n", ""}, | |
| 27 | + | {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"}, | |
| 28 | + | } | |
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | if got := pickImageMime(tt.output); got != tt.want { | |
| 32 | + | t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want) | |
| 33 | + | } | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // fakeExecCommand returns a substitute execCommand that never shells out to | |
| 39 | + | // wl-paste itself: it inspects the requested command/args and swaps in a | |
| 40 | + | // safe, universally available binary (printf) that produces the same | |
| 41 | + | // stdout a real wl-paste invocation would, so command construction and the | |
| 42 | + | // read pipeline can be verified without wl-paste installed. | |
| 43 | + | func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) { | |
| 44 | + | t.Helper() | |
| 45 | + | var got [][]string | |
| 46 | + | fn := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 47 | + | call := append([]string{name}, args...) | |
| 48 | + | got = append(got, call) | |
| 49 | + | if name != "wl-paste" { | |
| 50 | + | return exec.CommandContext(ctx, "false") | |
| 51 | + | } | |
| 52 | + | if len(args) > 0 && args[0] == "--list-types" { | |
| 53 | + | return exec.CommandContext(ctx, "printf", "%s", listTypesOutput) | |
| 54 | + | } | |
| 55 | + | return exec.CommandContext(ctx, "printf", "%s", imageData) | |
| 56 | + | } | |
| 57 | + | return fn, &got | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) { | |
| 61 | + | execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES") | |
| 62 | + | w := &WlPaste{execCommand: execCommand} | |
| 63 | + | ||
| 64 | + | mime, data, err := w.ReadImage(context.Background()) | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatalf("ReadImage() error = %v", err) | |
| 67 | + | } | |
| 68 | + | if mime != "image/png" { | |
| 69 | + | t.Fatalf("mime = %q, want image/png", mime) | |
| 70 | + | } | |
| 71 | + | if string(data) != "PNGBYTES" { | |
| 72 | + | t.Fatalf("data = %q, want PNGBYTES", data) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | want := [][]string{ | |
| 76 | + | {"wl-paste", "--list-types"}, | |
| 77 | + | {"wl-paste", "--type", "image/png", "--no-newline"}, | |
| 78 | + | } | |
| 79 | + | got := *calls | |
| 80 | + | if len(got) != len(want) { | |
| 81 | + | t.Fatalf("calls = %#v, want %#v", got, want) | |
| 82 | + | } | |
| 83 | + | for i := range want { | |
| 84 | + | if len(got[i]) != len(want[i]) { | |
| 85 | + | t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i]) | |
| 86 | + | } | |
| 87 | + | for j := range want[i] { | |
| 88 | + | if got[i][j] != want[i][j] { | |
| 89 | + | t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i]) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestReadImageNoImageOnClipboard(t *testing.T) { | |
| 96 | + | execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "") | |
| 97 | + | w := &WlPaste{execCommand: execCommand} | |
| 98 | + | ||
| 99 | + | _, _, err := w.ReadImage(context.Background()) | |
| 100 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 101 | + | t.Fatalf("err = %v, want ErrNoClipboardImage", err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) { | |
| 106 | + | execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 107 | + | return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing | |
| 108 | + | } | |
| 109 | + | w := &WlPaste{execCommand: execCommand} | |
| 110 | + | ||
| 111 | + | _, _, err := w.ReadImage(context.Background()) | |
| 112 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 113 | + | t.Fatalf("err = %v, want ErrNoClipboardImage", err) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data | |
| 118 | + | // read (not just the --list-types probe) runs under a context carrying a | |
| 119 | + | // deadline: called with context.Background() (as the client interceptor | |
| 120 | + | // does), a wl-paste that hangs mid-read must not stall the caller forever. | |
| 121 | + | func TestReadImageDataReadCommandHasDeadline(t *testing.T) { | |
| 122 | + | var readCtx context.Context | |
| 123 | + | execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 124 | + | if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" { | |
| 125 | + | return exec.CommandContext(ctx, "printf", "%s", "image/png\n") | |
| 126 | + | } | |
| 127 | + | readCtx = ctx | |
| 128 | + | return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES") | |
| 129 | + | } | |
| 130 | + | w := &WlPaste{execCommand: execCommand} | |
| 131 | + | ||
| 132 | + | _, _, err := w.ReadImage(context.Background()) | |
| 133 | + | if err != nil { | |
| 134 | + | t.Fatalf("ReadImage() error = %v", err) | |
| 135 | + | } | |
| 136 | + | if readCtx == nil { | |
| 137 | + | t.Fatal("data read command was never constructed") | |
| 138 | + | } | |
| 139 | + | if _, ok := readCtx.Deadline(); !ok { | |
| 140 | + | t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while | |
| 145 | + | // streaming the actual clipboard bytes (as opposed to during --list-types, | |
| 146 | + | // covered by listTypesTimeout) is bounded by readTimeout instead of stalling | |
| 147 | + | // the caller indefinitely. | |
| 148 | + | func TestReadImageHungDataReadTimesOut(t *testing.T) { | |
| 149 | + | execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 150 | + | if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" { | |
| 151 | + | return exec.CommandContext(ctx, "printf", "%s", "image/png\n") | |
| 152 | + | } | |
| 153 | + | return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read | |
| 154 | + | } | |
| 155 | + | w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond} | |
| 156 | + | ||
| 157 | + | start := time.Now() | |
| 158 | + | _, _, err := w.ReadImage(context.Background()) | |
| 159 | + | elapsed := time.Since(start) | |
| 160 | + | ||
| 161 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 162 | + | t.Fatalf("err = %v, want ErrNoClipboardImage", err) | |
| 163 | + | } | |
| 164 | + | if elapsed > 2*time.Second { | |
| 165 | + | t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // TestReadImageAgainstRealWlPaste is a best-effort integration check against | |
| 170 | + | // the real binary; it skips itself when wl-paste (or a Wayland clipboard) is | |
| 171 | + | // not available, which is the common case in CI/sandbox environments. | |
| 172 | + | func TestReadImageAgainstRealWlPaste(t *testing.T) { | |
| 173 | + | if _, err := exec.LookPath("wl-paste"); err != nil { | |
| 174 | + | t.Skip("wl-paste not installed") | |
| 175 | + | } | |
| 176 | + | w := New() | |
| 177 | + | _, _, err := w.ReadImage(context.Background()) | |
| 178 | + | // No clipboard image is a perfectly valid outcome in CI; just confirm | |
| 179 | + | // the call doesn't panic and errors are of the expected shape. | |
| 180 | + | if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 181 | + | t.Fatalf("ReadImage() unexpected error = %v", err) | |
| 182 | + | } | |
| 183 | + | } | |
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) { | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | output string | |||
| 17 | + | want string | |||
| 18 | + | }{ | |||
| 19 | + | {"no types", "", ""}, | |||
| 20 | + | {"text only", "text/plain\nUTF8_STRING\n", ""}, | |||
| 21 | + | {"png present", "text/plain\nimage/png\n", "image/png"}, | |||
| 22 | + | {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"}, | |||
| 23 | + | {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"}, | |||
| 24 | + | {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"}, | |||
| 25 | + | {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"}, | |||
| 26 | + | {"unsupported image type only", "image/bmp\n", ""}, | |||
| 27 | + | {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"}, | |||
| 28 | + | } | |||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | if got := pickImageMime(tt.output); got != tt.want { | |||
| 32 | + | t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want) | |||
| 33 | + | } | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // fakeExecCommand returns a substitute execCommand that never shells out to | |||
| 39 | + | // wl-paste itself: it inspects the requested command/args and swaps in a | |||
| 40 | + | // safe, universally available binary (printf) that produces the same | |||
| 41 | + | // stdout a real wl-paste invocation would, so command construction and the | |||
| 42 | + | // read pipeline can be verified without wl-paste installed. | |||
| 43 | + | func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) { | |||
| 44 | + | t.Helper() | |||
| 45 | + | var got [][]string | |||
| 46 | + | fn := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 47 | + | call := append([]string{name}, args...) | |||
| 48 | + | got = append(got, call) | |||
| 49 | + | if name != "wl-paste" { | |||
| 50 | + | return exec.CommandContext(ctx, "false") | |||
| 51 | + | } | |||
| 52 | + | if len(args) > 0 && args[0] == "--list-types" { | |||
| 53 | + | return exec.CommandContext(ctx, "printf", "%s", listTypesOutput) | |||
| 54 | + | } | |||
| 55 | + | return exec.CommandContext(ctx, "printf", "%s", imageData) | |||
| 56 | + | } | |||
| 57 | + | return fn, &got | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) { | |||
| 61 | + | execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES") | |||
| 62 | + | w := &WlPaste{execCommand: execCommand} | |||
| 63 | + | ||||
| 64 | + | mime, data, err := w.ReadImage(context.Background()) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatalf("ReadImage() error = %v", err) | |||
| 67 | + | } | |||
| 68 | + | if mime != "image/png" { | |||
| 69 | + | t.Fatalf("mime = %q, want image/png", mime) | |||
| 70 | + | } | |||
| 71 | + | if string(data) != "PNGBYTES" { | |||
| 72 | + | t.Fatalf("data = %q, want PNGBYTES", data) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | want := [][]string{ | |||
| 76 | + | {"wl-paste", "--list-types"}, | |||
| 77 | + | {"wl-paste", "--type", "image/png", "--no-newline"}, | |||
| 78 | + | } | |||
| 79 | + | got := *calls | |||
| 80 | + | if len(got) != len(want) { | |||
| 81 | + | t.Fatalf("calls = %#v, want %#v", got, want) | |||
| 82 | + | } | |||
| 83 | + | for i := range want { | |||
| 84 | + | if len(got[i]) != len(want[i]) { | |||
| 85 | + | t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i]) | |||
| 86 | + | } | |||
| 87 | + | for j := range want[i] { | |||
| 88 | + | if got[i][j] != want[i][j] { | |||
| 89 | + | t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i]) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestReadImageNoImageOnClipboard(t *testing.T) { | |||
| 96 | + | execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "") | |||
| 97 | + | w := &WlPaste{execCommand: execCommand} | |||
| 98 | + | ||||
| 99 | + | _, _, err := w.ReadImage(context.Background()) | |||
| 100 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |||
| 101 | + | t.Fatalf("err = %v, want ErrNoClipboardImage", err) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) { | |||
| 106 | + | execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 107 | + | return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing | |||
| 108 | + | } | |||
| 109 | + | w := &WlPaste{execCommand: execCommand} | |||
| 110 | + | ||||
| 111 | + | _, _, err := w.ReadImage(context.Background()) | |||
| 112 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |||
| 113 | + | t.Fatalf("err = %v, want ErrNoClipboardImage", err) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data | |||
| 118 | + | // read (not just the --list-types probe) runs under a context carrying a | |||
| 119 | + | // deadline: called with context.Background() (as the client interceptor | |||
| 120 | + | // does), a wl-paste that hangs mid-read must not stall the caller forever. | |||
| 121 | + | func TestReadImageDataReadCommandHasDeadline(t *testing.T) { | |||
| 122 | + | var readCtx context.Context | |||
| 123 | + | execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 124 | + | if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" { | |||
| 125 | + | return exec.CommandContext(ctx, "printf", "%s", "image/png\n") | |||
| 126 | + | } | |||
| 127 | + | readCtx = ctx | |||
| 128 | + | return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES") | |||
| 129 | + | } | |||
| 130 | + | w := &WlPaste{execCommand: execCommand} | |||
| 131 | + | ||||
| 132 | + | _, _, err := w.ReadImage(context.Background()) | |||
| 133 | + | if err != nil { | |||
| 134 | + | t.Fatalf("ReadImage() error = %v", err) | |||
| 135 | + | } | |||
| 136 | + | if readCtx == nil { | |||
| 137 | + | t.Fatal("data read command was never constructed") | |||
| 138 | + | } | |||
| 139 | + | if _, ok := readCtx.Deadline(); !ok { | |||
| 140 | + | t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()") | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while | |||
| 145 | + | // streaming the actual clipboard bytes (as opposed to during --list-types, | |||
| 146 | + | // covered by listTypesTimeout) is bounded by readTimeout instead of stalling | |||
| 147 | + | // the caller indefinitely. | |||
| 148 | + | func TestReadImageHungDataReadTimesOut(t *testing.T) { | |||
| 149 | + | execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 150 | + | if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" { | |||
| 151 | + | return exec.CommandContext(ctx, "printf", "%s", "image/png\n") | |||
| 152 | + | } | |||
| 153 | + | return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read | |||
| 154 | + | } | |||
| 155 | + | w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond} | |||
| 156 | + | ||||
| 157 | + | start := time.Now() | |||
| 158 | + | _, _, err := w.ReadImage(context.Background()) | |||
| 159 | + | elapsed := time.Since(start) | |||
| 160 | + | ||||
| 161 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |||
| 162 | + | t.Fatalf("err = %v, want ErrNoClipboardImage", err) | |||
| 163 | + | } | |||
| 164 | + | if elapsed > 2*time.Second { | |||
| 165 | + | t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // TestReadImageAgainstRealWlPaste is a best-effort integration check against | |||
| 170 | + | // the real binary; it skips itself when wl-paste (or a Wayland clipboard) is | |||
| 171 | + | // not available, which is the common case in CI/sandbox environments. | |||
| 172 | + | func TestReadImageAgainstRealWlPaste(t *testing.T) { | |||
| 173 | + | if _, err := exec.LookPath("wl-paste"); err != nil { | |||
| 174 | + | t.Skip("wl-paste not installed") | |||
| 175 | + | } | |||
| 176 | + | w := New() | |||
| 177 | + | _, _, err := w.ReadImage(context.Background()) | |||
| 178 | + | // No clipboard image is a perfectly valid outcome in CI; just confirm | |||
| 179 | + | // the call doesn't panic and errors are of the expected shape. | |||
| 180 | + | if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) { | |||
| 181 | + | t.Fatalf("ReadImage() unexpected error = %v", err) | |||
| 182 | + | } | |||
| 183 | + | } | |||
M internal/app/run.go +10 -3
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | 21 | "text/tabwriter" | |
| 22 | 22 | "time" | |
| 23 | 23 | ||
| 24 | + | "github.com/bnema/vev/internal/adapters/clipboard" | |
| 24 | 25 | "github.com/bnema/vev/internal/adapters/clock" | |
| 25 | 26 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 26 | 27 | "github.com/bnema/vev/internal/adapters/ipc" | |
| @@ -272,6 +273,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 272 | 273 | remoteDialer: defaultRemoteDialer, | |
| 273 | 274 | runClient: client.Run, | |
| 274 | 275 | createDetached: createDetachedLocalSession, | |
| 276 | + | clipboard: clipboard.New(), | |
| 275 | 277 | }) | |
| 276 | 278 | } | |
| 277 | 279 | ||
| @@ -285,8 +287,13 @@ type runAttachDeps struct { | |||
| 285 | 287 | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | |
| 286 | 288 | localDialer func() ports.Dialer | |
| 287 | 289 | remoteDialer func(target, session string) ports.Dialer | |
| 288 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error | |
| 290 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader) error | |
| 289 | 291 | createDetached func(context.Context, string) error | |
| 292 | + | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |
| 293 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 294 | + | // Only used for the remote-dialer branch below; local attaches never | |
| 295 | + | // intercept Ctrl+V regardless of this field. | |
| 296 | + | clipboard ports.ClipboardReader | |
| 290 | 297 | } | |
| 291 | 298 | ||
| 292 | 299 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error { | |
| @@ -306,7 +313,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 306 | 313 | if remoteDialer == nil { | |
| 307 | 314 | remoteDialer = defaultRemoteDialer | |
| 308 | 315 | } | |
| 309 | - | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name) | |
| 316 | + | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name, true, deps.clipboard) | |
| 310 | 317 | } | |
| 311 | 318 | ||
| 312 | 319 | attachLocal := deps.attachLocal | |
| @@ -316,7 +323,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 316 | 323 | localDialer = defaultLocalDialer | |
| 317 | 324 | } | |
| 318 | 325 | attachLocal = func(ctx context.Context, intent uint8, name string) error { | |
| 319 | - | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name) | |
| 326 | + | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil) | |
| 320 | 327 | } | |
| 321 | 328 | } | |
| 322 | 329 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| @@ -21,6 +21,7 @@ import ( | |||||
| 21 | "text/tabwriter" | 21 | "text/tabwriter" | ||
| 22 | "time" | 22 | "time" | ||
| 23 | 23 | ||||
| 24 | + | "github.com/bnema/vev/internal/adapters/clipboard" | |||
| 24 | "github.com/bnema/vev/internal/adapters/clock" | 25 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 25 | "github.com/bnema/vev/internal/adapters/dgram" | 26 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 26 | "github.com/bnema/vev/internal/adapters/ipc" | 27 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| @@ -272,6 +273,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 272 | remoteDialer: defaultRemoteDialer, | 273 | remoteDialer: defaultRemoteDialer, | ||
| 273 | runClient: client.Run, | 274 | runClient: client.Run, | ||
| 274 | createDetached: createDetachedLocalSession, | 275 | createDetached: createDetachedLocalSession, | ||
| 276 | + | clipboard: clipboard.New(), | |||
| 275 | }) | 277 | }) | ||
| 276 | } | 278 | } | ||
| 277 | 279 | ||||
| @@ -285,8 +287,13 @@ type runAttachDeps struct { | |||||
| 285 | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | 287 | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | ||
| 286 | localDialer func() ports.Dialer | 288 | localDialer func() ports.Dialer | ||
| 287 | remoteDialer func(target, session string) ports.Dialer | 289 | remoteDialer func(target, session string) ports.Dialer | ||
| 288 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error | 290 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader) error |
| 289 | createDetached func(context.Context, string) error | 291 | createDetached func(context.Context, string) error | ||
| 292 | + | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |||
| 293 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |||
| 294 | + | // Only used for the remote-dialer branch below; local attaches never | |||
| 295 | + | // intercept Ctrl+V regardless of this field. | |||
| 296 | + | clipboard ports.ClipboardReader | |||
| 290 | } | 297 | } | ||
| 291 | 298 | ||||
| 292 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error { | 299 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error { | ||
| @@ -306,7 +313,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 306 | if remoteDialer == nil { | 313 | if remoteDialer == nil { | ||
| 307 | remoteDialer = defaultRemoteDialer | 314 | remoteDialer = defaultRemoteDialer | ||
| 308 | } | 315 | } | ||
| 309 | - | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name) | 316 | + | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name, true, deps.clipboard) |
| 310 | } | 317 | } | ||
| 311 | 318 | ||||
| 312 | attachLocal := deps.attachLocal | 319 | attachLocal := deps.attachLocal | ||
| @@ -316,7 +323,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 316 | localDialer = defaultLocalDialer | 323 | localDialer = defaultLocalDialer | ||
| 317 | } | 324 | } | ||
| 318 | attachLocal = func(ctx context.Context, intent uint8, name string) error { | 325 | attachLocal = func(ctx context.Context, intent uint8, name string) error { | ||
| 319 | - | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name) | 326 | + | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil) |
| 320 | } | 327 | } | ||
| 321 | } | 328 | } | ||
| 322 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | 329 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | ||
M internal/app/run_test.go +30 -2
| @@ -393,15 +393,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 393 | 393 | return nil, errors.New("not used") | |
| 394 | 394 | } | |
| 395 | 395 | ||
| 396 | + | // fakeClipboardReader is a distinguishable ports.ClipboardReader used only to | |
| 397 | + | // verify identity (that runAttachWithDeps threads the *same* reader through), | |
| 398 | + | // never actually invoked in these wiring tests. | |
| 399 | + | type fakeClipboardReader struct{ ports.ClipboardReader } | |
| 400 | + | ||
| 396 | 401 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |
| 397 | 402 | var gotTarget, gotSession, gotDialer string | |
| 403 | + | var gotRemote bool | |
| 404 | + | var gotClipboard ports.ClipboardReader | |
| 405 | + | clip := &fakeClipboardReader{} | |
| 398 | 406 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{ | |
| 399 | 407 | remoteDialer: func(target, session string) ports.Dialer { | |
| 400 | 408 | gotTarget, gotSession = target, session | |
| 401 | 409 | return namedDialer{name: "remote"} | |
| 402 | 410 | }, | |
| 403 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { | |
| 411 | + | clipboard: clip, | |
| 412 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error { | |
| 404 | 413 | gotDialer = d.(namedDialer).name | |
| 414 | + | gotRemote = remote | |
| 415 | + | gotClipboard = clipboard | |
| 405 | 416 | if intent != ports.IntentAttach || name != "work" { | |
| 406 | 417 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |
| 407 | 418 | } | |
| @@ -414,14 +425,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||
| 414 | 425 | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | |
| 415 | 426 | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | |
| 416 | 427 | } | |
| 428 | + | if !gotRemote { | |
| 429 | + | t.Fatal("remote attach must pass remote=true to runClient") | |
| 430 | + | } | |
| 431 | + | if gotClipboard != ports.ClipboardReader(clip) { | |
| 432 | + | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |
| 433 | + | } | |
| 417 | 434 | } | |
| 418 | 435 | ||
| 419 | 436 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |
| 420 | 437 | var gotDialer string | |
| 438 | + | gotRemote := true | |
| 439 | + | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |
| 421 | 440 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | |
| 422 | 441 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 423 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { | |
| 442 | + | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | |
| 443 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error { | |
| 424 | 444 | gotDialer = d.(namedDialer).name | |
| 445 | + | gotRemote = remote | |
| 446 | + | gotClipboard = clipboard | |
| 425 | 447 | if intent != ports.IntentEphemeral || name != "" { | |
| 426 | 448 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | |
| 427 | 449 | } | |
| @@ -434,4 +456,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 434 | 456 | if gotDialer != "local" { | |
| 435 | 457 | t.Fatalf("local dialer = %q, want local", gotDialer) | |
| 436 | 458 | } | |
| 459 | + | if gotRemote { | |
| 460 | + | t.Fatal("local attach must pass remote=false to runClient") | |
| 461 | + | } | |
| 462 | + | if gotClipboard != nil { | |
| 463 | + | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |
| 464 | + | } | |
| 437 | 465 | } | |
| @@ -393,15 +393,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||||
| 393 | return nil, errors.New("not used") | 393 | return nil, errors.New("not used") | ||
| 394 | } | 394 | } | ||
| 395 | 395 | ||||
| 396 | + | // fakeClipboardReader is a distinguishable ports.ClipboardReader used only to | |||
| 397 | + | // verify identity (that runAttachWithDeps threads the *same* reader through), | |||
| 398 | + | // never actually invoked in these wiring tests. | |||
| 399 | + | type fakeClipboardReader struct{ ports.ClipboardReader } | |||
| 400 | + | ||||
| 396 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | 401 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | ||
| 397 | var gotTarget, gotSession, gotDialer string | 402 | var gotTarget, gotSession, gotDialer string | ||
| 403 | + | var gotRemote bool | |||
| 404 | + | var gotClipboard ports.ClipboardReader | |||
| 405 | + | clip := &fakeClipboardReader{} | |||
| 398 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{ | 406 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{ | ||
| 399 | remoteDialer: func(target, session string) ports.Dialer { | 407 | remoteDialer: func(target, session string) ports.Dialer { | ||
| 400 | gotTarget, gotSession = target, session | 408 | gotTarget, gotSession = target, session | ||
| 401 | return namedDialer{name: "remote"} | 409 | return namedDialer{name: "remote"} | ||
| 402 | }, | 410 | }, | ||
| 403 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { | 411 | + | clipboard: clip, |
| 412 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error { | |||
| 404 | gotDialer = d.(namedDialer).name | 413 | gotDialer = d.(namedDialer).name | ||
| 414 | + | gotRemote = remote | |||
| 415 | + | gotClipboard = clipboard | |||
| 405 | if intent != ports.IntentAttach || name != "work" { | 416 | if intent != ports.IntentAttach || name != "work" { | ||
| 406 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | 417 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | ||
| 407 | } | 418 | } | ||
| @@ -414,14 +425,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||||
| 414 | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | 425 | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | ||
| 415 | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | 426 | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | ||
| 416 | } | 427 | } | ||
| 428 | + | if !gotRemote { | |||
| 429 | + | t.Fatal("remote attach must pass remote=true to runClient") | |||
| 430 | + | } | |||
| 431 | + | if gotClipboard != ports.ClipboardReader(clip) { | |||
| 432 | + | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |||
| 433 | + | } | |||
| 417 | } | 434 | } | ||
| 418 | 435 | ||||
| 419 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | 436 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | ||
| 420 | var gotDialer string | 437 | var gotDialer string | ||
| 438 | + | gotRemote := true | |||
| 439 | + | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |||
| 421 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | 440 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | ||
| 422 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | 441 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | ||
| 423 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { | 442 | + | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach |
| 443 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error { | |||
| 424 | gotDialer = d.(namedDialer).name | 444 | gotDialer = d.(namedDialer).name | ||
| 445 | + | gotRemote = remote | |||
| 446 | + | gotClipboard = clipboard | |||
| 425 | if intent != ports.IntentEphemeral || name != "" { | 447 | if intent != ports.IntentEphemeral || name != "" { | ||
| 426 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | 448 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | ||
| 427 | } | 449 | } | ||
| @@ -434,4 +456,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 434 | if gotDialer != "local" { | 456 | if gotDialer != "local" { | ||
| 435 | t.Fatalf("local dialer = %q, want local", gotDialer) | 457 | t.Fatalf("local dialer = %q, want local", gotDialer) | ||
| 436 | } | 458 | } | ||
| 459 | + | if gotRemote { | |||
| 460 | + | t.Fatal("local attach must pass remote=false to runClient") | |||
| 461 | + | } | |||
| 462 | + | if gotClipboard != nil { | |||
| 463 | + | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |||
| 464 | + | } | |||
| 437 | } | 465 | } | ||
M internal/usecase/client/client.go +37 -7
| @@ -119,7 +119,14 @@ const ( | |||
| 119 | 119 | // Run connects via dialer and runs the attach client. It owns the terminal | |
| 120 | 120 | // lifecycle above attach attempts so raw mode remains active while a live | |
| 121 | 121 | // client process redials a lost link. | |
| 122 | - | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) { | |
| 122 | + | // | |
| 123 | + | // remote and clipboard together gate the clipboard-image-transfer feature | |
| 124 | + | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md): | |
| 125 | + | // Ctrl+V is only intercepted on a remote attach (remote true) with a | |
| 126 | + | // ClipboardReader configured (clipboard non-nil) — local attaches forward | |
| 127 | + | // 0x16 untouched so the locally running agent's own clipboard handling | |
| 128 | + | // applies. | |
| 129 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) (retErr error) { | |
| 123 | 130 | log := slog.Default().With("component", "client") | |
| 124 | 131 | ms := milestones{} | |
| 125 | 132 | ||
| @@ -189,7 +196,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 189 | 196 | } | |
| 190 | 197 | ms.dialed = true | |
| 191 | 198 | ||
| 192 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | |
| 199 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard) | |
| 193 | 200 | if result.welcomed { | |
| 194 | 201 | backoff = defaultReconnectBackoff.initial | |
| 195 | 202 | } | |
| @@ -251,7 +258,7 @@ func shouldReconnect(err error) bool { | |||
| 251 | 258 | // | |
| 252 | 259 | // It is kept as a compatibility wrapper for callers that still own dialing. | |
| 253 | 260 | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | |
| 254 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name) | |
| 261 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil) | |
| 255 | 262 | } | |
| 256 | 263 | ||
| 257 | 264 | type singleTransportDialer struct{ transport ports.Transport } | |
| @@ -266,7 +273,7 @@ type attachResult struct { | |||
| 266 | 273 | err error | |
| 267 | 274 | } | |
| 268 | 275 | ||
| 269 | - | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult { | |
| 276 | + | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader) attachResult { | |
| 270 | 277 | // 1. Handshake: send Hello with our size and TERM. | |
| 271 | 278 | size, err := term.Size() | |
| 272 | 279 | if err != nil { | |
| @@ -331,8 +338,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 331 | 338 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 332 | 339 | sendErrCh := make(chan error, 1) | |
| 333 | 340 | ||
| 341 | + | var clip ports.ClipboardReader | |
| 342 | + | if remote { | |
| 343 | + | clip = clipboard | |
| 344 | + | } | |
| 334 | 345 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | |
| 335 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor) | |
| 346 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip) | |
| 336 | 347 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | |
| 337 | 348 | ||
| 338 | 349 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -446,7 +457,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 446 | 457 | // exits. That is harmless here — Attach has already returned and restored | |
| 447 | 458 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 448 | 459 | // closable stdin duplicate could lift it later if ever needed. | |
| 449 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) { | |
| 460 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader) { | |
| 450 | 461 | buf := make([]byte, stdinBufSize) | |
| 451 | 462 | var scanner theme.Scanner | |
| 452 | 463 | var inputSeq uint64 | |
| @@ -472,6 +483,25 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 472 | 483 | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |
| 473 | 484 | }) | |
| 474 | 485 | defer coalescer.Close() | |
| 486 | + | ||
| 487 | + | // On a remote attach with a ClipboardReader configured, splice in the | |
| 488 | + | // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16) | |
| 489 | + | // becomes a clipboard image push instead of reaching the coalescer (and | |
| 490 | + | // from there the remote pane) as an ordinary keystroke. | |
| 491 | + | sink := coalescer.Scan | |
| 492 | + | if clipboard != nil { | |
| 493 | + | ci := &clipboardIntercept{ | |
| 494 | + | coalescer: coalescer, | |
| 495 | + | reader: clipboard, | |
| 496 | + | log: slog.Default(), | |
| 497 | + | sendImage: func(mime string, data []byte) { | |
| 498 | + | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})}) | |
| 499 | + | }, | |
| 500 | + | next: coalescer.Scan, | |
| 501 | + | } | |
| 502 | + | sink = ci.Scan | |
| 503 | + | } | |
| 504 | + | ||
| 475 | 505 | for { | |
| 476 | 506 | n, rerr := in.Read(buf) | |
| 477 | 507 | if n > 0 { | |
| @@ -487,7 +517,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 487 | 517 | return | |
| 488 | 518 | } | |
| 489 | 519 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 490 | - | }, coalescer.Scan) | |
| 520 | + | }, sink) | |
| 491 | 521 | if !sendOK.Load() { | |
| 492 | 522 | return | |
| 493 | 523 | } | |
| @@ -119,7 +119,14 @@ const ( | |||||
| 119 | // Run connects via dialer and runs the attach client. It owns the terminal | 119 | // Run connects via dialer and runs the attach client. It owns the terminal | ||
| 120 | // lifecycle above attach attempts so raw mode remains active while a live | 120 | // lifecycle above attach attempts so raw mode remains active while a live | ||
| 121 | // client process redials a lost link. | 121 | // client process redials a lost link. | ||
| 122 | - | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) { | 122 | + | // |
| 123 | + | // remote and clipboard together gate the clipboard-image-transfer feature | |||
| 124 | + | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md): | |||
| 125 | + | // Ctrl+V is only intercepted on a remote attach (remote true) with a | |||
| 126 | + | // ClipboardReader configured (clipboard non-nil) — local attaches forward | |||
| 127 | + | // 0x16 untouched so the locally running agent's own clipboard handling | |||
| 128 | + | // applies. | |||
| 129 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) (retErr error) { | |||
| 123 | log := slog.Default().With("component", "client") | 130 | log := slog.Default().With("component", "client") | ||
| 124 | ms := milestones{} | 131 | ms := milestones{} | ||
| 125 | 132 | ||||
| @@ -189,7 +196,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 189 | } | 196 | } | ||
| 190 | ms.dialed = true | 197 | ms.dialed = true | ||
| 191 | 198 | ||||
| 192 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | 199 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard) |
| 193 | if result.welcomed { | 200 | if result.welcomed { | ||
| 194 | backoff = defaultReconnectBackoff.initial | 201 | backoff = defaultReconnectBackoff.initial | ||
| 195 | } | 202 | } | ||
| @@ -251,7 +258,7 @@ func shouldReconnect(err error) bool { | |||||
| 251 | // | 258 | // | ||
| 252 | // It is kept as a compatibility wrapper for callers that still own dialing. | 259 | // It is kept as a compatibility wrapper for callers that still own dialing. | ||
| 253 | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | 260 | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | ||
| 254 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name) | 261 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil) |
| 255 | } | 262 | } | ||
| 256 | 263 | ||||
| 257 | type singleTransportDialer struct{ transport ports.Transport } | 264 | type singleTransportDialer struct{ transport ports.Transport } | ||
| @@ -266,7 +273,7 @@ type attachResult struct { | |||||
| 266 | err error | 273 | err error | ||
| 267 | } | 274 | } | ||
| 268 | 275 | ||||
| 269 | - | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult { | 276 | + | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader) attachResult { |
| 270 | // 1. Handshake: send Hello with our size and TERM. | 277 | // 1. Handshake: send Hello with our size and TERM. | ||
| 271 | size, err := term.Size() | 278 | size, err := term.Size() | ||
| 272 | if err != nil { | 279 | if err != nil { | ||
| @@ -331,8 +338,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 331 | sendCh := make(chan ports.Frame, sendQueueDepth) | 338 | sendCh := make(chan ports.Frame, sendQueueDepth) | ||
| 332 | sendErrCh := make(chan error, 1) | 339 | sendErrCh := make(chan error, 1) | ||
| 333 | 340 | ||||
| 341 | + | var clip ports.ClipboardReader | |||
| 342 | + | if remote { | |||
| 343 | + | clip = clipboard | |||
| 344 | + | } | |||
| 334 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | 345 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | ||
| 335 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor) | 346 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip) |
| 336 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | 347 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | ||
| 337 | 348 | ||||
| 338 | // 5. Output/main loop: the only goroutine that touches the terminal. | 349 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -446,7 +457,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 446 | // exits. That is harmless here — Attach has already returned and restored | 457 | // exits. That is harmless here — Attach has already returned and restored | ||
| 447 | // the terminal — and matches the standard pattern for stdin pumps; a | 458 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 448 | // closable stdin duplicate could lift it later if ever needed. | 459 | // closable stdin duplicate could lift it later if ever needed. | ||
| 449 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) { | 460 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader) { |
| 450 | buf := make([]byte, stdinBufSize) | 461 | buf := make([]byte, stdinBufSize) | ||
| 451 | var scanner theme.Scanner | 462 | var scanner theme.Scanner | ||
| 452 | var inputSeq uint64 | 463 | var inputSeq uint64 | ||
| @@ -472,6 +483,25 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 472 | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | 483 | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | ||
| 473 | }) | 484 | }) | ||
| 474 | defer coalescer.Close() | 485 | defer coalescer.Close() | ||
| 486 | + | ||||
| 487 | + | // On a remote attach with a ClipboardReader configured, splice in the | |||
| 488 | + | // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16) | |||
| 489 | + | // becomes a clipboard image push instead of reaching the coalescer (and | |||
| 490 | + | // from there the remote pane) as an ordinary keystroke. | |||
| 491 | + | sink := coalescer.Scan | |||
| 492 | + | if clipboard != nil { | |||
| 493 | + | ci := &clipboardIntercept{ | |||
| 494 | + | coalescer: coalescer, | |||
| 495 | + | reader: clipboard, | |||
| 496 | + | log: slog.Default(), | |||
| 497 | + | sendImage: func(mime string, data []byte) { | |||
| 498 | + | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})}) | |||
| 499 | + | }, | |||
| 500 | + | next: coalescer.Scan, | |||
| 501 | + | } | |||
| 502 | + | sink = ci.Scan | |||
| 503 | + | } | |||
| 504 | + | ||||
| 475 | for { | 505 | for { | ||
| 476 | n, rerr := in.Read(buf) | 506 | n, rerr := in.Read(buf) | ||
| 477 | if n > 0 { | 507 | if n > 0 { | ||
| @@ -487,7 +517,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 487 | return | 517 | return | ||
| 488 | } | 518 | } | ||
| 489 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 519 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | ||
| 490 | - | }, coalescer.Scan) | 520 | + | }, sink) |
| 491 | if !sendOK.Load() { | 521 | if !sendOK.Load() { | ||
| 492 | return | 522 | return | ||
| 493 | } | 523 | } | ||
M internal/usecase/client/client_test.go +5 -5
| @@ -565,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 565 | 565 | d := portsmocks.NewMockDialer(t) | |
| 566 | 566 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 567 | 567 | ||
| 568 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") | |
| 568 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil) | |
| 569 | 569 | require.Error(t, err) | |
| 570 | 570 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | |
| 571 | 571 | } | |
| @@ -585,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 585 | 585 | d := portsmocks.NewMockDialer(t) | |
| 586 | 586 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 587 | 587 | ||
| 588 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main") | |
| 588 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil) | |
| 589 | 589 | require.Error(t, err) | |
| 590 | 590 | var pe *client.ProtocolError | |
| 591 | 591 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -597,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||
| 597 | 597 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | |
| 598 | 598 | tm := portsmocks.NewMockTerminal(t) | |
| 599 | 599 | ||
| 600 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") | |
| 600 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil) | |
| 601 | 601 | require.ErrorIs(t, err, dialErr) | |
| 602 | 602 | } | |
| 603 | 603 | ||
| @@ -686,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 686 | 686 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |
| 687 | 687 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | |
| 688 | 688 | ||
| 689 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") | |
| 689 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil) | |
| 690 | 690 | require.NoError(t, err) | |
| 691 | 691 | require.Equal(t, int32(3), d.calls.Load()) | |
| 692 | 692 | require.Equal(t, int32(1), term.rawCount.Load()) | |
| @@ -713,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 713 | 713 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |
| 714 | 714 | d := &sequenceDialer{trs: []ports.Transport{tr}} | |
| 715 | 715 | ||
| 716 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") | |
| 716 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil) | |
| 717 | 717 | require.Error(t, err) | |
| 718 | 718 | var de *client.DetachedError | |
| 719 | 719 | require.True(t, errors.As(err, &de)) | |
| @@ -565,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||||
| 565 | d := portsmocks.NewMockDialer(t) | 565 | d := portsmocks.NewMockDialer(t) | ||
| 566 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | 566 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | ||
| 567 | 567 | ||||
| 568 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") | 568 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil) |
| 569 | require.Error(t, err) | 569 | require.Error(t, err) | ||
| 570 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | 570 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | ||
| 571 | } | 571 | } | ||
| @@ -585,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||||
| 585 | d := portsmocks.NewMockDialer(t) | 585 | d := portsmocks.NewMockDialer(t) | ||
| 586 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | 586 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | ||
| 587 | 587 | ||||
| 588 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main") | 588 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil) |
| 589 | require.Error(t, err) | 589 | require.Error(t, err) | ||
| 590 | var pe *client.ProtocolError | 590 | var pe *client.ProtocolError | ||
| 591 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | 591 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | ||
| @@ -597,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||||
| 597 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | 597 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | ||
| 598 | tm := portsmocks.NewMockTerminal(t) | 598 | tm := portsmocks.NewMockTerminal(t) | ||
| 599 | 599 | ||||
| 600 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") | 600 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil) |
| 601 | require.ErrorIs(t, err, dialErr) | 601 | require.ErrorIs(t, err, dialErr) | ||
| 602 | } | 602 | } | ||
| 603 | 603 | ||||
| @@ -686,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||||
| 686 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | 686 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | ||
| 687 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | 687 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | ||
| 688 | 688 | ||||
| 689 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") | 689 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil) |
| 690 | require.NoError(t, err) | 690 | require.NoError(t, err) | ||
| 691 | require.Equal(t, int32(3), d.calls.Load()) | 691 | require.Equal(t, int32(3), d.calls.Load()) | ||
| 692 | require.Equal(t, int32(1), term.rawCount.Load()) | 692 | require.Equal(t, int32(1), term.rawCount.Load()) | ||
| @@ -713,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||||
| 713 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | 713 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | ||
| 714 | d := &sequenceDialer{trs: []ports.Transport{tr}} | 714 | d := &sequenceDialer{trs: []ports.Transport{tr}} | ||
| 715 | 715 | ||||
| 716 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") | 716 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil) |
| 717 | require.Error(t, err) | 717 | require.Error(t, err) | ||
| 718 | var de *client.DetachedError | 718 | var de *client.DetachedError | ||
| 719 | require.True(t, errors.As(err, &de)) | 719 | require.True(t, errors.As(err, &de)) | ||
A internal/usecase/client/clipboard_intercept.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 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. | |
| 13 | + | const ctrlV = 0x16 | |
| 14 | + | ||
| 15 | + | // maxClipboardImagePush caps the image the client will send in one | |
| 16 | + | // MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit | |
| 17 | + | // means one frame always suffices under this cap. | |
| 18 | + | const maxClipboardImagePush = 10 << 20 // 10 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. | |
| 27 | + | type clipboardIntercept struct { | |
| 28 | + | coalescer *pasteCoalescer | |
| 29 | + | reader ports.ClipboardReader | |
| 30 | + | log *slog.Logger | |
| 31 | + | sendImage func(mime string, data []byte) | |
| 32 | + | next func([]byte) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | |
| 36 | + | // can be used interchangeably as the scanner sink. | |
| 37 | + | // | |
| 38 | + | // It must never scan for Ctrl+V past the point where a bracketed paste | |
| 39 | + | // starts (or might start): pasted text can legitimately contain 0x16, and | |
| 40 | + | // the paste's opening marker can itself be split across two reads. So: | |
| 41 | + | // | |
| 42 | + | // 1. If the coalescer is already Buffering (mid-paste, or holding a | |
| 43 | + | // trailing strict prefix of the opening marker from a previous call), | |
| 44 | + | // the whole chunk is handed to next untouched. | |
| 45 | + | // 2. Otherwise, this chunk is scanned only up to the first byte that either | |
| 46 | + | // starts the opening marker in full or could be the start of a marker | |
| 47 | + | // split across this chunk's end (pasteMarkerBoundary); everything from | |
| 48 | + | // that point onward is handed to next as one piece, unscanned, letting | |
| 49 | + | // the coalescer's own state decide what happens to it (and to any | |
| 50 | + | // bytes trailing an in-chunk paste — see the package-level note below). | |
| 51 | + | func (c *clipboardIntercept) Scan(data []byte) { | |
| 52 | + | if c.coalescer.Buffering() { | |
| 53 | + | c.next(data) | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | boundary := pasteMarkerBoundary(data) | |
| 58 | + | segment := data[:boundary] | |
| 59 | + | for len(segment) > 0 { | |
| 60 | + | idx := bytes.IndexByte(segment, ctrlV) | |
| 61 | + | if idx < 0 { | |
| 62 | + | c.next(segment) | |
| 63 | + | break | |
| 64 | + | } | |
| 65 | + | if idx > 0 { | |
| 66 | + | c.next(segment[:idx]) | |
| 67 | + | } | |
| 68 | + | segment = segment[idx+1:] | |
| 69 | + | c.handleCtrlV() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Bytes at/after boundary are (the possible start of) a bracketed | |
| 73 | + | // paste: handed off whole, so any Ctrl+V trailing a paste that closes | |
| 74 | + | // within this same chunk is not intercepted either. In practice a real | |
| 75 | + | // keypress never arrives concatenated with paste bytes in one read, so | |
| 76 | + | // this is a deliberate, narrow trade-off in favor of never breaking | |
| 77 | + | // paste content. | |
| 78 | + | if tail := data[boundary:]; len(tail) > 0 { | |
| 79 | + | c.next(tail) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // pasteMarkerBoundary returns the index in data at or after which bytes must | |
| 84 | + | // not be scanned for Ctrl+V because they start (or, at the very end of data, | |
| 85 | + | // might be a split-across-reads prefix of) the bracketed-paste opening | |
| 86 | + | // marker. It returns len(data) when neither is present. | |
| 87 | + | func pasteMarkerBoundary(data []byte) int { | |
| 88 | + | if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 { | |
| 89 | + | return idx | |
| 90 | + | } | |
| 91 | + | if p := trailingOpenPrefixLen(data); p > 0 { | |
| 92 | + | return len(data) - p | |
| 93 | + | } | |
| 94 | + | return len(data) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (c *clipboardIntercept) handleCtrlV() { | |
| 98 | + | mime, data, err := c.reader.ReadImage(context.Background()) | |
| 99 | + | if err != nil { | |
| 100 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 101 | + | c.log.Warn("clipboard image read failed", "err", err) | |
| 102 | + | } | |
| 103 | + | c.next([]byte{ctrlV}) | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | if len(data) > maxClipboardImagePush { | |
| 107 | + | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | |
| 108 | + | c.next([]byte{ctrlV}) | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | c.sendImage(mime, data) | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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. | |||
| 13 | + | const ctrlV = 0x16 | |||
| 14 | + | ||||
| 15 | + | // maxClipboardImagePush caps the image the client will send in one | |||
| 16 | + | // MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit | |||
| 17 | + | // means one frame always suffices under this cap. | |||
| 18 | + | const maxClipboardImagePush = 10 << 20 // 10 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. | |||
| 27 | + | type clipboardIntercept struct { | |||
| 28 | + | coalescer *pasteCoalescer | |||
| 29 | + | reader ports.ClipboardReader | |||
| 30 | + | log *slog.Logger | |||
| 31 | + | sendImage func(mime string, data []byte) | |||
| 32 | + | next func([]byte) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | |||
| 36 | + | // can be used interchangeably as the scanner sink. | |||
| 37 | + | // | |||
| 38 | + | // It must never scan for Ctrl+V past the point where a bracketed paste | |||
| 39 | + | // starts (or might start): pasted text can legitimately contain 0x16, and | |||
| 40 | + | // the paste's opening marker can itself be split across two reads. So: | |||
| 41 | + | // | |||
| 42 | + | // 1. If the coalescer is already Buffering (mid-paste, or holding a | |||
| 43 | + | // trailing strict prefix of the opening marker from a previous call), | |||
| 44 | + | // the whole chunk is handed to next untouched. | |||
| 45 | + | // 2. Otherwise, this chunk is scanned only up to the first byte that either | |||
| 46 | + | // starts the opening marker in full or could be the start of a marker | |||
| 47 | + | // split across this chunk's end (pasteMarkerBoundary); everything from | |||
| 48 | + | // that point onward is handed to next as one piece, unscanned, letting | |||
| 49 | + | // the coalescer's own state decide what happens to it (and to any | |||
| 50 | + | // bytes trailing an in-chunk paste — see the package-level note below). | |||
| 51 | + | func (c *clipboardIntercept) Scan(data []byte) { | |||
| 52 | + | if c.coalescer.Buffering() { | |||
| 53 | + | c.next(data) | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | boundary := pasteMarkerBoundary(data) | |||
| 58 | + | segment := data[:boundary] | |||
| 59 | + | for len(segment) > 0 { | |||
| 60 | + | idx := bytes.IndexByte(segment, ctrlV) | |||
| 61 | + | if idx < 0 { | |||
| 62 | + | c.next(segment) | |||
| 63 | + | break | |||
| 64 | + | } | |||
| 65 | + | if idx > 0 { | |||
| 66 | + | c.next(segment[:idx]) | |||
| 67 | + | } | |||
| 68 | + | segment = segment[idx+1:] | |||
| 69 | + | c.handleCtrlV() | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Bytes at/after boundary are (the possible start of) a bracketed | |||
| 73 | + | // paste: handed off whole, so any Ctrl+V trailing a paste that closes | |||
| 74 | + | // within this same chunk is not intercepted either. In practice a real | |||
| 75 | + | // keypress never arrives concatenated with paste bytes in one read, so | |||
| 76 | + | // this is a deliberate, narrow trade-off in favor of never breaking | |||
| 77 | + | // paste content. | |||
| 78 | + | if tail := data[boundary:]; len(tail) > 0 { | |||
| 79 | + | c.next(tail) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // pasteMarkerBoundary returns the index in data at or after which bytes must | |||
| 84 | + | // not be scanned for Ctrl+V because they start (or, at the very end of data, | |||
| 85 | + | // might be a split-across-reads prefix of) the bracketed-paste opening | |||
| 86 | + | // marker. It returns len(data) when neither is present. | |||
| 87 | + | func pasteMarkerBoundary(data []byte) int { | |||
| 88 | + | if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 { | |||
| 89 | + | return idx | |||
| 90 | + | } | |||
| 91 | + | if p := trailingOpenPrefixLen(data); p > 0 { | |||
| 92 | + | return len(data) - p | |||
| 93 | + | } | |||
| 94 | + | return len(data) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (c *clipboardIntercept) handleCtrlV() { | |||
| 98 | + | mime, data, err := c.reader.ReadImage(context.Background()) | |||
| 99 | + | if err != nil { | |||
| 100 | + | if !errors.Is(err, ports.ErrNoClipboardImage) { | |||
| 101 | + | c.log.Warn("clipboard image read failed", "err", err) | |||
| 102 | + | } | |||
| 103 | + | c.next([]byte{ctrlV}) | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | if len(data) > maxClipboardImagePush { | |||
| 107 | + | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | |||
| 108 | + | c.next([]byte{ctrlV}) | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | c.sendImage(mime, data) | |||
| 112 | + | } | |||
A internal/usecase/client/clipboard_intercept_internal_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "log/slog" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ciFakeReader is a directly-controllable ports.ClipboardReader for | |
| 15 | + | // white-box interceptor tests (this file lives in package client, alongside | |
| 16 | + | // paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly). | |
| 17 | + | type ciFakeReader struct { | |
| 18 | + | mime string | |
| 19 | + | data []byte | |
| 20 | + | err error | |
| 21 | + | n int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) { | |
| 25 | + | r.n++ | |
| 26 | + | return r.mime, r.data, r.err | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) { | |
| 30 | + | clk := &pcFakeClock{} | |
| 31 | + | col := &pcCollector{} | |
| 32 | + | pc := newPasteCoalescer(clk, col.emit) | |
| 33 | + | var images []ports.ImagePush | |
| 34 | + | ci := &clipboardIntercept{ | |
| 35 | + | coalescer: pc, | |
| 36 | + | reader: reader, | |
| 37 | + | log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})), | |
| 38 | + | sendImage: func(mime string, data []byte) { | |
| 39 | + | images = append(images, ports.ImagePush{Mime: mime, Data: data}) | |
| 40 | + | }, | |
| 41 | + | next: pc.Scan, | |
| 42 | + | } | |
| 43 | + | return ci, pc, clk, col, &images | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) { | |
| 47 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")} | |
| 48 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 49 | + | defer pc.Close() | |
| 50 | + | ||
| 51 | + | ci.Scan([]byte("a")) | |
| 52 | + | ci.Scan([]byte{ctrlV}) | |
| 53 | + | ci.Scan([]byte("b")) | |
| 54 | + | ||
| 55 | + | require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V") | |
| 56 | + | require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images) | |
| 57 | + | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) { | |
| 61 | + | reader := &ciFakeReader{err: ports.ErrNoClipboardImage} | |
| 62 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 63 | + | defer pc.Close() | |
| 64 | + | ||
| 65 | + | ci.Scan([]byte("a\x16b")) | |
| 66 | + | ||
| 67 | + | require.Empty(t, *images) | |
| 68 | + | require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot()) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) { | |
| 72 | + | reader := &ciFakeReader{err: errors.New("boom")} | |
| 73 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 74 | + | defer pc.Close() | |
| 75 | + | ||
| 76 | + | ci.Scan([]byte{ctrlV}) | |
| 77 | + | ||
| 78 | + | require.Empty(t, *images) | |
| 79 | + | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) { | |
| 83 | + | huge := make([]byte, maxClipboardImagePush+1) | |
| 84 | + | reader := &ciFakeReader{mime: "image/png", data: huge} | |
| 85 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 86 | + | defer pc.Close() | |
| 87 | + | ||
| 88 | + | ci.Scan([]byte{ctrlV}) | |
| 89 | + | ||
| 90 | + | require.Empty(t, *images, "an oversized image must not be sent") | |
| 91 | + | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) { | |
| 95 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |
| 96 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 97 | + | defer pc.Close() | |
| 98 | + | ||
| 99 | + | ci.Scan([]byte("a\x16b\x16c")) | |
| 100 | + | ||
| 101 | + | require.Equal(t, 2, reader.n) | |
| 102 | + | require.Len(t, *images, 2) | |
| 103 | + | require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot()) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) { | |
| 107 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |
| 108 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 109 | + | defer pc.Close() | |
| 110 | + | ||
| 111 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~") | |
| 112 | + | ci.Scan(paste) | |
| 113 | + | ||
| 114 | + | require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste") | |
| 115 | + | require.Empty(t, *images) | |
| 116 | + | require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) { | |
| 120 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |
| 121 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 122 | + | defer pc.Close() | |
| 123 | + | ||
| 124 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~") | |
| 125 | + | for s := 1; s < len(paste); s++ { | |
| 126 | + | t.Run("", func(t *testing.T) { | |
| 127 | + | reader.n = 0 | |
| 128 | + | *images = nil | |
| 129 | + | col.emits = nil | |
| 130 | + | ci.Scan(paste[:s]) | |
| 131 | + | ci.Scan(paste[s:]) | |
| 132 | + | require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s) | |
| 133 | + | require.Empty(t, *images) | |
| 134 | + | require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s) | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) { | |
| 140 | + | // A trailing lone ESC (a strict prefix of the open marker) is held by | |
| 141 | + | // the coalescer as "pending" without yet flipping Buffering() via the | |
| 142 | + | // old (buffering-only) definition; Buffering() must also cover pending | |
| 143 | + | // so a Ctrl+V arriving before the flush timer fires is never | |
| 144 | + | // misinterpreted mid-marker. | |
| 145 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |
| 146 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |
| 147 | + | defer pc.Close() | |
| 148 | + | ||
| 149 | + | ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes | |
| 150 | + | require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering") | |
| 151 | + | ||
| 152 | + | ci.Scan([]byte{ctrlV}) | |
| 153 | + | ||
| 154 | + | require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending") | |
| 155 | + | require.Empty(t, *images) | |
| 156 | + | // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the | |
| 157 | + | // coalescer, which does not find a marker in "\x1bfamiliar" and emits | |
| 158 | + | // it as plain text once no further marker bytes follow. | |
| 159 | + | require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot()) | |
| 160 | + | } | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ciFakeReader is a directly-controllable ports.ClipboardReader for | |||
| 15 | + | // white-box interceptor tests (this file lives in package client, alongside | |||
| 16 | + | // paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly). | |||
| 17 | + | type ciFakeReader struct { | |||
| 18 | + | mime string | |||
| 19 | + | data []byte | |||
| 20 | + | err error | |||
| 21 | + | n int | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) { | |||
| 25 | + | r.n++ | |||
| 26 | + | return r.mime, r.data, r.err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) { | |||
| 30 | + | clk := &pcFakeClock{} | |||
| 31 | + | col := &pcCollector{} | |||
| 32 | + | pc := newPasteCoalescer(clk, col.emit) | |||
| 33 | + | var images []ports.ImagePush | |||
| 34 | + | ci := &clipboardIntercept{ | |||
| 35 | + | coalescer: pc, | |||
| 36 | + | reader: reader, | |||
| 37 | + | log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})), | |||
| 38 | + | sendImage: func(mime string, data []byte) { | |||
| 39 | + | images = append(images, ports.ImagePush{Mime: mime, Data: data}) | |||
| 40 | + | }, | |||
| 41 | + | next: pc.Scan, | |||
| 42 | + | } | |||
| 43 | + | return ci, pc, clk, col, &images | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) { | |||
| 47 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")} | |||
| 48 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 49 | + | defer pc.Close() | |||
| 50 | + | ||||
| 51 | + | ci.Scan([]byte("a")) | |||
| 52 | + | ci.Scan([]byte{ctrlV}) | |||
| 53 | + | ci.Scan([]byte("b")) | |||
| 54 | + | ||||
| 55 | + | require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V") | |||
| 56 | + | require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images) | |||
| 57 | + | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) { | |||
| 61 | + | reader := &ciFakeReader{err: ports.ErrNoClipboardImage} | |||
| 62 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 63 | + | defer pc.Close() | |||
| 64 | + | ||||
| 65 | + | ci.Scan([]byte("a\x16b")) | |||
| 66 | + | ||||
| 67 | + | require.Empty(t, *images) | |||
| 68 | + | require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot()) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) { | |||
| 72 | + | reader := &ciFakeReader{err: errors.New("boom")} | |||
| 73 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 74 | + | defer pc.Close() | |||
| 75 | + | ||||
| 76 | + | ci.Scan([]byte{ctrlV}) | |||
| 77 | + | ||||
| 78 | + | require.Empty(t, *images) | |||
| 79 | + | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) { | |||
| 83 | + | huge := make([]byte, maxClipboardImagePush+1) | |||
| 84 | + | reader := &ciFakeReader{mime: "image/png", data: huge} | |||
| 85 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 86 | + | defer pc.Close() | |||
| 87 | + | ||||
| 88 | + | ci.Scan([]byte{ctrlV}) | |||
| 89 | + | ||||
| 90 | + | require.Empty(t, *images, "an oversized image must not be sent") | |||
| 91 | + | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it") | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) { | |||
| 95 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |||
| 96 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 97 | + | defer pc.Close() | |||
| 98 | + | ||||
| 99 | + | ci.Scan([]byte("a\x16b\x16c")) | |||
| 100 | + | ||||
| 101 | + | require.Equal(t, 2, reader.n) | |||
| 102 | + | require.Len(t, *images, 2) | |||
| 103 | + | require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot()) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) { | |||
| 107 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |||
| 108 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 109 | + | defer pc.Close() | |||
| 110 | + | ||||
| 111 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~") | |||
| 112 | + | ci.Scan(paste) | |||
| 113 | + | ||||
| 114 | + | require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste") | |||
| 115 | + | require.Empty(t, *images) | |||
| 116 | + | require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) { | |||
| 120 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |||
| 121 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 122 | + | defer pc.Close() | |||
| 123 | + | ||||
| 124 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~") | |||
| 125 | + | for s := 1; s < len(paste); s++ { | |||
| 126 | + | t.Run("", func(t *testing.T) { | |||
| 127 | + | reader.n = 0 | |||
| 128 | + | *images = nil | |||
| 129 | + | col.emits = nil | |||
| 130 | + | ci.Scan(paste[:s]) | |||
| 131 | + | ci.Scan(paste[s:]) | |||
| 132 | + | require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s) | |||
| 133 | + | require.Empty(t, *images) | |||
| 134 | + | require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s) | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) { | |||
| 140 | + | // A trailing lone ESC (a strict prefix of the open marker) is held by | |||
| 141 | + | // the coalescer as "pending" without yet flipping Buffering() via the | |||
| 142 | + | // old (buffering-only) definition; Buffering() must also cover pending | |||
| 143 | + | // so a Ctrl+V arriving before the flush timer fires is never | |||
| 144 | + | // misinterpreted mid-marker. | |||
| 145 | + | reader := &ciFakeReader{mime: "image/png", data: []byte("X")} | |||
| 146 | + | ci, pc, _, col, images := newTestClipboardIntercept(reader) | |||
| 147 | + | defer pc.Close() | |||
| 148 | + | ||||
| 149 | + | ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes | |||
| 150 | + | require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering") | |||
| 151 | + | ||||
| 152 | + | ci.Scan([]byte{ctrlV}) | |||
| 153 | + | ||||
| 154 | + | require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending") | |||
| 155 | + | require.Empty(t, *images) | |||
| 156 | + | // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the | |||
| 157 | + | // coalescer, which does not find a marker in "\x1bfamiliar" and emits | |||
| 158 | + | // it as plain text once no further marker bytes follow. | |||
| 159 | + | require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot()) | |||
| 160 | + | } | |||
A internal/usecase/client/clipboard_intercept_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // clipboardRunTestDeps wires a Run() attempt with an input reader that | |
| 20 | + | // delivers data then blocks (so the pump stays parked until detach), a | |
| 21 | + | // transport that captures every MsgInput/MsgImagePush send, and a Recv that | |
| 22 | + | // answers Welcome then, once triggered, Detached — mirroring the pattern used | |
| 23 | + | // by the OSC-color-response test elsewhere in this package. | |
| 24 | + | func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) { | |
| 25 | + | t.Helper() | |
| 26 | + | var out bytes.Buffer | |
| 27 | + | var restoreCount int32 | |
| 28 | + | resizeCh := make(chan domain.Size) | |
| 29 | + | input := newOneShotBlockingReader(stdin) | |
| 30 | + | defer input.unblock() | |
| 31 | + | ||
| 32 | + | tm := portsmocks.NewMockTerminal(t) | |
| 33 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 34 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | |
| 35 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 36 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 37 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 38 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 39 | + | ||
| 40 | + | gotInput = make(chan []byte, 8) | |
| 41 | + | gotImage = make(chan ports.ImagePush, 1) | |
| 42 | + | allowDetach := make(chan struct{}) | |
| 43 | + | ||
| 44 | + | tr := portsmocks.NewMockTransport(t) | |
| 45 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 46 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 47 | + | in, err := ports.UnmarshalInput(f.Payload) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | gotInput <- append([]byte(nil), in.Data...) | |
| 50 | + | if bytes.IndexByte(in.Data, triggerByte) >= 0 { | |
| 51 | + | closeOnce(allowDetach) | |
| 52 | + | } | |
| 53 | + | return nil | |
| 54 | + | }).Maybe() | |
| 55 | + | tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error { | |
| 56 | + | ip, err := ports.UnmarshalImagePush(f.Payload) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | gotImage <- ip | |
| 59 | + | return nil | |
| 60 | + | }).Maybe() | |
| 61 | + | ||
| 62 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 63 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 64 | + | recvCh := make(chan recvItem, 1) | |
| 65 | + | recvCh <- recvItem{f: welcome} | |
| 66 | + | closed := make(chan struct{}) | |
| 67 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 68 | + | select { | |
| 69 | + | case it := <-recvCh: | |
| 70 | + | return it.f, it.err | |
| 71 | + | case <-allowDetach: | |
| 72 | + | select { | |
| 73 | + | case <-closed: | |
| 74 | + | return ports.Frame{}, io.EOF | |
| 75 | + | default: | |
| 76 | + | close(closed) | |
| 77 | + | return detached, nil | |
| 78 | + | } | |
| 79 | + | case <-closed: | |
| 80 | + | return ports.Frame{}, io.EOF | |
| 81 | + | } | |
| 82 | + | }).Maybe() | |
| 83 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 84 | + | ||
| 85 | + | d := portsmocks.NewMockDialer(t) | |
| 86 | + | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 87 | + | ||
| 88 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | return gotInput, gotImage | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func closeOnce(ch chan struct{}) { | |
| 94 | + | select { | |
| 95 | + | case <-ch: | |
| 96 | + | default: | |
| 97 | + | close(ch) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func drainInput(gotInput chan []byte) []byte { | |
| 102 | + | var b []byte | |
| 103 | + | for { | |
| 104 | + | select { | |
| 105 | + | case chunk := <-gotInput: | |
| 106 | + | b = append(b, chunk...) | |
| 107 | + | default: | |
| 108 | + | return b | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) { | |
| 114 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 115 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once() | |
| 116 | + | ||
| 117 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |
| 118 | + | ||
| 119 | + | select { | |
| 120 | + | case ip := <-gotImage: | |
| 121 | + | require.Equal(t, "image/png", ip.Mime) | |
| 122 | + | require.Equal(t, []byte("PNGDATA"), ip.Data) | |
| 123 | + | case <-time.After(2 * time.Second): | |
| 124 | + | t.Fatal("MsgImagePush was not sent") | |
| 125 | + | } | |
| 126 | + | require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |
| 130 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 131 | + | clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once() | |
| 132 | + | ||
| 133 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |
| 134 | + | ||
| 135 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |
| 139 | + | huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap | |
| 140 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 141 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | |
| 142 | + | ||
| 143 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |
| 144 | + | ||
| 145 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) { | |
| 149 | + | // A MockClipboardReader with no .EXPECT() set fails the test if | |
| 150 | + | // ReadImage is ever called — which must never happen for a 0x16 that | |
| 151 | + | // arrives as part of pasted content, even when the whole paste (open | |
| 152 | + | // marker, content, close marker) lands in a single read alongside it. | |
| 153 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 154 | + | ||
| 155 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z") | |
| 156 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z') | |
| 157 | + | ||
| 158 | + | require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit") | |
| 159 | + | select { | |
| 160 | + | case ip := <-gotImage: | |
| 161 | + | t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip) | |
| 162 | + | default: | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) { | |
| 167 | + | // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage | |
| 168 | + | // is ever called, which is exactly what a local (non-remote) attach must | |
| 169 | + | // never do. | |
| 170 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 171 | + | ||
| 172 | + | gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b') | |
| 173 | + | ||
| 174 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched") | |
| 175 | + | } | |
| @@ -0,0 +1,175 @@ | |||||
| 1 | + | package client_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 16 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // clipboardRunTestDeps wires a Run() attempt with an input reader that | |||
| 20 | + | // delivers data then blocks (so the pump stays parked until detach), a | |||
| 21 | + | // transport that captures every MsgInput/MsgImagePush send, and a Recv that | |||
| 22 | + | // answers Welcome then, once triggered, Detached — mirroring the pattern used | |||
| 23 | + | // by the OSC-color-response test elsewhere in this package. | |||
| 24 | + | func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) { | |||
| 25 | + | t.Helper() | |||
| 26 | + | var out bytes.Buffer | |||
| 27 | + | var restoreCount int32 | |||
| 28 | + | resizeCh := make(chan domain.Size) | |||
| 29 | + | input := newOneShotBlockingReader(stdin) | |||
| 30 | + | defer input.unblock() | |||
| 31 | + | ||||
| 32 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 33 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 34 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | |||
| 35 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 36 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 37 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 38 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 39 | + | ||||
| 40 | + | gotInput = make(chan []byte, 8) | |||
| 41 | + | gotImage = make(chan ports.ImagePush, 1) | |||
| 42 | + | allowDetach := make(chan struct{}) | |||
| 43 | + | ||||
| 44 | + | tr := portsmocks.NewMockTransport(t) | |||
| 45 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 46 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |||
| 47 | + | in, err := ports.UnmarshalInput(f.Payload) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | gotInput <- append([]byte(nil), in.Data...) | |||
| 50 | + | if bytes.IndexByte(in.Data, triggerByte) >= 0 { | |||
| 51 | + | closeOnce(allowDetach) | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | }).Maybe() | |||
| 55 | + | tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error { | |||
| 56 | + | ip, err := ports.UnmarshalImagePush(f.Payload) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | gotImage <- ip | |||
| 59 | + | return nil | |||
| 60 | + | }).Maybe() | |||
| 61 | + | ||||
| 62 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 63 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 64 | + | recvCh := make(chan recvItem, 1) | |||
| 65 | + | recvCh <- recvItem{f: welcome} | |||
| 66 | + | closed := make(chan struct{}) | |||
| 67 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 68 | + | select { | |||
| 69 | + | case it := <-recvCh: | |||
| 70 | + | return it.f, it.err | |||
| 71 | + | case <-allowDetach: | |||
| 72 | + | select { | |||
| 73 | + | case <-closed: | |||
| 74 | + | return ports.Frame{}, io.EOF | |||
| 75 | + | default: | |||
| 76 | + | close(closed) | |||
| 77 | + | return detached, nil | |||
| 78 | + | } | |||
| 79 | + | case <-closed: | |||
| 80 | + | return ports.Frame{}, io.EOF | |||
| 81 | + | } | |||
| 82 | + | }).Maybe() | |||
| 83 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 84 | + | ||||
| 85 | + | d := portsmocks.NewMockDialer(t) | |||
| 86 | + | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |||
| 87 | + | ||||
| 88 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | return gotInput, gotImage | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func closeOnce(ch chan struct{}) { | |||
| 94 | + | select { | |||
| 95 | + | case <-ch: | |||
| 96 | + | default: | |||
| 97 | + | close(ch) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func drainInput(gotInput chan []byte) []byte { | |||
| 102 | + | var b []byte | |||
| 103 | + | for { | |||
| 104 | + | select { | |||
| 105 | + | case chunk := <-gotInput: | |||
| 106 | + | b = append(b, chunk...) | |||
| 107 | + | default: | |||
| 108 | + | return b | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) { | |||
| 114 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 115 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once() | |||
| 116 | + | ||||
| 117 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |||
| 118 | + | ||||
| 119 | + | select { | |||
| 120 | + | case ip := <-gotImage: | |||
| 121 | + | require.Equal(t, "image/png", ip.Mime) | |||
| 122 | + | require.Equal(t, []byte("PNGDATA"), ip.Data) | |||
| 123 | + | case <-time.After(2 * time.Second): | |||
| 124 | + | t.Fatal("MsgImagePush was not sent") | |||
| 125 | + | } | |||
| 126 | + | require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent") | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |||
| 130 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 131 | + | clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once() | |||
| 132 | + | ||||
| 133 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |||
| 134 | + | ||||
| 135 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |||
| 139 | + | huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap | |||
| 140 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 141 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | |||
| 142 | + | ||||
| 143 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |||
| 144 | + | ||||
| 145 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) { | |||
| 149 | + | // A MockClipboardReader with no .EXPECT() set fails the test if | |||
| 150 | + | // ReadImage is ever called — which must never happen for a 0x16 that | |||
| 151 | + | // arrives as part of pasted content, even when the whole paste (open | |||
| 152 | + | // marker, content, close marker) lands in a single read alongside it. | |||
| 153 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 154 | + | ||||
| 155 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z") | |||
| 156 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z') | |||
| 157 | + | ||||
| 158 | + | require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit") | |||
| 159 | + | select { | |||
| 160 | + | case ip := <-gotImage: | |||
| 161 | + | t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip) | |||
| 162 | + | default: | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) { | |||
| 167 | + | // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage | |||
| 168 | + | // is ever called, which is exactly what a local (non-remote) attach must | |||
| 169 | + | // never do. | |||
| 170 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 171 | + | ||||
| 172 | + | gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b') | |||
| 173 | + | ||||
| 174 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched") | |||
| 175 | + | } | |||
M internal/usecase/client/paste_coalescer.go +14 -0
| @@ -144,6 +144,20 @@ func (c *pasteCoalescer) resetBuffering() { | |||
| 144 | 144 | c.stopSafetyTimer() | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | // Buffering reports whether it is currently unsafe to reinterpret incoming | |
| 148 | + | // bytes as ordinary keystrokes: either a bracketed paste is being | |
| 149 | + | // accumulated, or a trailing strict prefix of the opening marker is being | |
| 150 | + | // held across a read boundary (pending, awaiting either completion into a | |
| 151 | + | // real paste or the flush timer proving it was not one). The clipboard | |
| 152 | + | // Ctrl+V interceptor uses this to avoid treating bytes that are, or might | |
| 153 | + | // turn out to be, part of an in-flight paste as a standalone keystroke — | |
| 154 | + | // pasted text may legitimately contain 0x16. | |
| 155 | + | func (c *pasteCoalescer) Buffering() bool { | |
| 156 | + | c.mu.Lock() | |
| 157 | + | defer c.mu.Unlock() | |
| 158 | + | return c.buffering || len(c.pending) > 0 | |
| 159 | + | } | |
| 160 | + | ||
| 147 | 161 | // Close stops any pending timers and their goroutines. Held bytes are dropped: | |
| 148 | 162 | // Close runs only as the stdin pump unwinds on detach, when there is nowhere | |
| 149 | 163 | // left to deliver them. | |
| @@ -144,6 +144,20 @@ func (c *pasteCoalescer) resetBuffering() { | |||||
| 144 | c.stopSafetyTimer() | 144 | c.stopSafetyTimer() | ||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | + | // Buffering reports whether it is currently unsafe to reinterpret incoming | |||
| 148 | + | // bytes as ordinary keystrokes: either a bracketed paste is being | |||
| 149 | + | // accumulated, or a trailing strict prefix of the opening marker is being | |||
| 150 | + | // held across a read boundary (pending, awaiting either completion into a | |||
| 151 | + | // real paste or the flush timer proving it was not one). The clipboard | |||
| 152 | + | // Ctrl+V interceptor uses this to avoid treating bytes that are, or might | |||
| 153 | + | // turn out to be, part of an in-flight paste as a standalone keystroke — | |||
| 154 | + | // pasted text may legitimately contain 0x16. | |||
| 155 | + | func (c *pasteCoalescer) Buffering() bool { | |||
| 156 | + | c.mu.Lock() | |||
| 157 | + | defer c.mu.Unlock() | |||
| 158 | + | return c.buffering || len(c.pending) > 0 | |||
| 159 | + | } | |||
| 160 | + | ||||
| 147 | // Close stops any pending timers and their goroutines. Held bytes are dropped: | 161 | // Close stops any pending timers and their goroutines. Held bytes are dropped: | ||
| 148 | // Close runs only as the stdin pump unwinds on detach, when there is nowhere | 162 | // Close runs only as the stdin pump unwinds on detach, when there is nowhere | ||
| 149 | // left to deliver them. | 163 | // left to deliver them. | ||
M internal/usecase/daemon/client.go +4 -0
| @@ -481,6 +481,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 481 | 481 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 482 | 482 | d.applyTheme(sess, ac, th) | |
| 483 | 483 | } | |
| 484 | + | case ports.MsgImagePush: | |
| 485 | + | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 486 | + | d.handleImagePush(sess, ip) | |
| 487 | + | } | |
| 484 | 488 | case ports.MsgDetach: | |
| 485 | 489 | d.clientGone(sess, ac, tr, true) | |
| 486 | 490 | return | |
| @@ -481,6 +481,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 481 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | 481 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | ||
| 482 | d.applyTheme(sess, ac, th) | 482 | d.applyTheme(sess, ac, th) | ||
| 483 | } | 483 | } | ||
| 484 | + | case ports.MsgImagePush: | |||
| 485 | + | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |||
| 486 | + | d.handleImagePush(sess, ip) | |||
| 487 | + | } | |||
| 484 | case ports.MsgDetach: | 488 | case ports.MsgDetach: | ||
| 485 | d.clientGone(sess, ac, tr, true) | 489 | d.clientGone(sess, ac, tr, true) | ||
| 486 | return | 490 | return | ||
A internal/usecase/daemon/clipboard.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case: the | |
| 2 | + | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | + | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | + | package daemon | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // maxImagePushSize independently caps an accepted MsgImagePush payload, | |
| 15 | + | // defending against an old or foreign client even though the client is | |
| 16 | + | // expected to enforce the same cap before sending | |
| 17 | + | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 18 | + | const maxImagePushSize = 10 << 20 // 10 MiB | |
| 19 | + | ||
| 20 | + | // Bracketed-paste markers used to wrap the injected clip path when the | |
| 21 | + | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | |
| 22 | + | // pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a | |
| 23 | + | // separate copy since the daemon has no dependency on the client package. | |
| 24 | + | var ( | |
| 25 | + | clipPasteOpenMarker = []byte("\x1b[200~") | |
| 26 | + | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |
| 30 | + | // file's path into the session's focused pane, as if typed/pasted there. | |
| 31 | + | // Oversized or empty payloads are rejected without panicking. A write | |
| 32 | + | // failure is logged and the push is dropped: the client has already | |
| 33 | + | // consumed the Ctrl+V keystroke that triggered it, so there is nothing left | |
| 34 | + | // to re-trigger from here. | |
| 35 | + | func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) { | |
| 36 | + | if len(ip.Data) == 0 { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | if len(ip.Data) > maxImagePushSize { | |
| 40 | + | d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize) | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | path, err := d.writeClipboardImage(sess, ip) | |
| 44 | + | if err != nil { | |
| 45 | + | d.log.Error("writing clipboard image failed", "err", err) | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | d.injectClipboardPath(sess, path) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // writeClipboardImage writes ip's bytes to a new 0600 temp file and records | |
| 52 | + | // the path on sess for best-effort cleanup at session end (killSession). | |
| 53 | + | func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) { | |
| 54 | + | dir := d.tempDir | |
| 55 | + | if dir == "" { | |
| 56 | + | dir = os.TempDir() | |
| 57 | + | } | |
| 58 | + | name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime)) | |
| 59 | + | path := filepath.Join(dir, name) | |
| 60 | + | if err := os.WriteFile(path, ip.Data, 0o600); err != nil { | |
| 61 | + | return "", err | |
| 62 | + | } | |
| 63 | + | sess.mu.Lock() | |
| 64 | + | sess.clipFiles = append(sess.clipFiles, path) | |
| 65 | + | sess.mu.Unlock() | |
| 66 | + | return path, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // injectClipboardPath writes path's text into sess's focused pane, through | |
| 70 | + | // the same PTY write path as ordinary input, wrapped in bracketed-paste | |
| 71 | + | // markers iff the pane's Screen reports bracketed-paste mode enabled. | |
| 72 | + | func (d *Daemon) injectClipboardPath(sess *session, path string) { | |
| 73 | + | tb := sess.activeTab() | |
| 74 | + | if tb == nil { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | tb.mu.Lock() | |
| 78 | + | p := tb.focusedPane() | |
| 79 | + | tb.mu.Unlock() | |
| 80 | + | if p == nil { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | p.mu.Lock() | |
| 85 | + | bracketed := p.screen.BracketedPasteMode() | |
| 86 | + | p.mu.Unlock() | |
| 87 | + | ||
| 88 | + | data := []byte(path) | |
| 89 | + | if bracketed { | |
| 90 | + | wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker)) | |
| 91 | + | wrapped = append(wrapped, clipPasteOpenMarker...) | |
| 92 | + | wrapped = append(wrapped, data...) | |
| 93 | + | wrapped = append(wrapped, clipPasteCloseMarker...) | |
| 94 | + | data = wrapped | |
| 95 | + | } | |
| 96 | + | d.writeToPane(sess, p, data) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // clipboardExt maps an ImagePush MIME type to a filename extension. Unknown | |
| 100 | + | // types fall back to "bin" rather than rejecting the push: the daemon copies | |
| 101 | + | // bytes as-is regardless (see the design doc's "no image conversion" scope | |
| 102 | + | // decision). | |
| 103 | + | func clipboardExt(mime string) string { | |
| 104 | + | switch mime { | |
| 105 | + | case "image/png": | |
| 106 | + | return "png" | |
| 107 | + | case "image/jpeg": | |
| 108 | + | return "jpg" | |
| 109 | + | case "image/webp": | |
| 110 | + | return "webp" | |
| 111 | + | case "image/gif": | |
| 112 | + | return "gif" | |
| 113 | + | default: | |
| 114 | + | return "bin" | |
| 115 | + | } | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case: the | |||
| 2 | + | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | + | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | + | package daemon | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // maxImagePushSize independently caps an accepted MsgImagePush payload, | |||
| 15 | + | // defending against an old or foreign client even though the client is | |||
| 16 | + | // expected to enforce the same cap before sending | |||
| 17 | + | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |||
| 18 | + | const maxImagePushSize = 10 << 20 // 10 MiB | |||
| 19 | + | ||||
| 20 | + | // Bracketed-paste markers used to wrap the injected clip path when the | |||
| 21 | + | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | |||
| 22 | + | // pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a | |||
| 23 | + | // separate copy since the daemon has no dependency on the client package. | |||
| 24 | + | var ( | |||
| 25 | + | clipPasteOpenMarker = []byte("\x1b[200~") | |||
| 26 | + | clipPasteCloseMarker = []byte("\x1b[201~") | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |||
| 30 | + | // file's path into the session's focused pane, as if typed/pasted there. | |||
| 31 | + | // Oversized or empty payloads are rejected without panicking. A write | |||
| 32 | + | // failure is logged and the push is dropped: the client has already | |||
| 33 | + | // consumed the Ctrl+V keystroke that triggered it, so there is nothing left | |||
| 34 | + | // to re-trigger from here. | |||
| 35 | + | func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) { | |||
| 36 | + | if len(ip.Data) == 0 { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | if len(ip.Data) > maxImagePushSize { | |||
| 40 | + | d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize) | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | path, err := d.writeClipboardImage(sess, ip) | |||
| 44 | + | if err != nil { | |||
| 45 | + | d.log.Error("writing clipboard image failed", "err", err) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | d.injectClipboardPath(sess, path) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // writeClipboardImage writes ip's bytes to a new 0600 temp file and records | |||
| 52 | + | // the path on sess for best-effort cleanup at session end (killSession). | |||
| 53 | + | func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) { | |||
| 54 | + | dir := d.tempDir | |||
| 55 | + | if dir == "" { | |||
| 56 | + | dir = os.TempDir() | |||
| 57 | + | } | |||
| 58 | + | name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime)) | |||
| 59 | + | path := filepath.Join(dir, name) | |||
| 60 | + | if err := os.WriteFile(path, ip.Data, 0o600); err != nil { | |||
| 61 | + | return "", err | |||
| 62 | + | } | |||
| 63 | + | sess.mu.Lock() | |||
| 64 | + | sess.clipFiles = append(sess.clipFiles, path) | |||
| 65 | + | sess.mu.Unlock() | |||
| 66 | + | return path, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // injectClipboardPath writes path's text into sess's focused pane, through | |||
| 70 | + | // the same PTY write path as ordinary input, wrapped in bracketed-paste | |||
| 71 | + | // markers iff the pane's Screen reports bracketed-paste mode enabled. | |||
| 72 | + | func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 73 | + | tb := sess.activeTab() | |||
| 74 | + | if tb == nil { | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | tb.mu.Lock() | |||
| 78 | + | p := tb.focusedPane() | |||
| 79 | + | tb.mu.Unlock() | |||
| 80 | + | if p == nil { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | p.mu.Lock() | |||
| 85 | + | bracketed := p.screen.BracketedPasteMode() | |||
| 86 | + | p.mu.Unlock() | |||
| 87 | + | ||||
| 88 | + | data := []byte(path) | |||
| 89 | + | if bracketed { | |||
| 90 | + | wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker)) | |||
| 91 | + | wrapped = append(wrapped, clipPasteOpenMarker...) | |||
| 92 | + | wrapped = append(wrapped, data...) | |||
| 93 | + | wrapped = append(wrapped, clipPasteCloseMarker...) | |||
| 94 | + | data = wrapped | |||
| 95 | + | } | |||
| 96 | + | d.writeToPane(sess, p, data) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // clipboardExt maps an ImagePush MIME type to a filename extension. Unknown | |||
| 100 | + | // types fall back to "bin" rather than rejecting the push: the daemon copies | |||
| 101 | + | // bytes as-is regardless (see the design doc's "no image conversion" scope | |||
| 102 | + | // decision). | |||
| 103 | + | func clipboardExt(mime string) string { | |||
| 104 | + | switch mime { | |||
| 105 | + | case "image/png": | |||
| 106 | + | return "png" | |||
| 107 | + | case "image/jpeg": | |||
| 108 | + | return "jpg" | |||
| 109 | + | case "image/webp": | |||
| 110 | + | return "webp" | |||
| 111 | + | case "image/gif": | |||
| 112 | + | return "gif" | |||
| 113 | + | default: | |||
| 114 | + | return "bin" | |||
| 115 | + | } | |||
| 116 | + | } | |||
A internal/usecase/daemon/clipboard_test.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |
| 14 | + | writes := make(chan []byte, 1) | |
| 15 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 16 | + | defer releasePTY() | |
| 17 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 18 | + | d.tempDir = t.TempDir() | |
| 19 | + | ||
| 20 | + | data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03} | |
| 21 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data}) | |
| 22 | + | ||
| 23 | + | injected := <-writes | |
| 24 | + | path := string(injected) | |
| 25 | + | require.FileExists(t, path) | |
| 26 | + | require.Equal(t, ".png", filepath.Ext(path)) | |
| 27 | + | require.True(t, filepath.IsAbs(path)) | |
| 28 | + | ||
| 29 | + | got, err := os.ReadFile(path) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | require.Equal(t, data, got) | |
| 32 | + | ||
| 33 | + | info, err := os.Stat(path) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) { | |
| 39 | + | writes := make(chan []byte, 1) | |
| 40 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 41 | + | defer releasePTY() | |
| 42 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 43 | + | d.tempDir = t.TempDir() | |
| 44 | + | pane := sess.tabs[0].focusedPane() | |
| 45 | + | require.False(t, pane.screen.BracketedPasteMode()) | |
| 46 | + | ||
| 47 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |
| 48 | + | ||
| 49 | + | injected := <-writes | |
| 50 | + | require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode") | |
| 51 | + | require.FileExists(t, string(injected)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) { | |
| 55 | + | writes := make(chan []byte, 1) | |
| 56 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 57 | + | defer releasePTY() | |
| 58 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 59 | + | d.tempDir = t.TempDir() | |
| 60 | + | pane := sess.tabs[0].focusedPane() | |
| 61 | + | pane.screen.Write([]byte("\x1b[?2004h")) | |
| 62 | + | require.True(t, pane.screen.BracketedPasteMode()) | |
| 63 | + | ||
| 64 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |
| 65 | + | ||
| 66 | + | injected := <-writes | |
| 67 | + | require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker)) | |
| 68 | + | require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)]) | |
| 69 | + | require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):]) | |
| 70 | + | path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)]) | |
| 71 | + | require.FileExists(t, path) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func hasBracketedPasteMarkers(b []byte) bool { | |
| 75 | + | return len(b) >= len(clipPasteOpenMarker) && | |
| 76 | + | string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) { | |
| 80 | + | writes := make(chan []byte, 1) | |
| 81 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 82 | + | defer releasePTY() | |
| 83 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 84 | + | dir := t.TempDir() | |
| 85 | + | d.tempDir = dir | |
| 86 | + | ||
| 87 | + | huge := make([]byte, maxImagePushSize+1) | |
| 88 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge}) | |
| 89 | + | ||
| 90 | + | select { | |
| 91 | + | case w := <-writes: | |
| 92 | + | t.Fatalf("oversized push must not be injected into the pane, got %q", w) | |
| 93 | + | default: | |
| 94 | + | } | |
| 95 | + | entries, err := os.ReadDir(dir) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | require.Empty(t, entries, "oversized push must not write a temp file") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) { | |
| 101 | + | writes := make(chan []byte, 1) | |
| 102 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 103 | + | defer releasePTY() | |
| 104 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 105 | + | dir := t.TempDir() | |
| 106 | + | d.tempDir = dir | |
| 107 | + | ||
| 108 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil}) | |
| 109 | + | ||
| 110 | + | select { | |
| 111 | + | case w := <-writes: | |
| 112 | + | t.Fatalf("empty push must not be injected, got %q", w) | |
| 113 | + | default: | |
| 114 | + | } | |
| 115 | + | entries, err := os.ReadDir(dir) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.Empty(t, entries) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) { | |
| 121 | + | cases := map[string]string{ | |
| 122 | + | "image/png": "png", | |
| 123 | + | "image/jpeg": "jpg", | |
| 124 | + | "image/webp": "webp", | |
| 125 | + | "image/gif": "gif", | |
| 126 | + | "image/bmp": "bin", | |
| 127 | + | "": "bin", | |
| 128 | + | "application/octet-stream": "bin", | |
| 129 | + | } | |
| 130 | + | for mime, want := range cases { | |
| 131 | + | require.Equal(t, want, clipboardExt(mime), "mime %q", mime) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestKillSessionRemovesClipboardTempFiles(t *testing.T) { | |
| 136 | + | writes := make(chan []byte, 1) | |
| 137 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 138 | + | defer releasePTY() | |
| 139 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 140 | + | d.tempDir = t.TempDir() | |
| 141 | + | // killSession requires the session to be registered in the daemon's | |
| 142 | + | // registry (newManualSessionWithPTYs already does this via d.sessions). | |
| 143 | + | ||
| 144 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |
| 145 | + | injected := <-writes | |
| 146 | + | path := string(injected) | |
| 147 | + | require.FileExists(t, path) | |
| 148 | + | ||
| 149 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 150 | + | require.NoFileExists(t, path) | |
| 151 | + | } | |
| @@ -0,0 +1,151 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |||
| 14 | + | writes := make(chan []byte, 1) | |||
| 15 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 16 | + | defer releasePTY() | |||
| 17 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 18 | + | d.tempDir = t.TempDir() | |||
| 19 | + | ||||
| 20 | + | data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03} | |||
| 21 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data}) | |||
| 22 | + | ||||
| 23 | + | injected := <-writes | |||
| 24 | + | path := string(injected) | |||
| 25 | + | require.FileExists(t, path) | |||
| 26 | + | require.Equal(t, ".png", filepath.Ext(path)) | |||
| 27 | + | require.True(t, filepath.IsAbs(path)) | |||
| 28 | + | ||||
| 29 | + | got, err := os.ReadFile(path) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | require.Equal(t, data, got) | |||
| 32 | + | ||||
| 33 | + | info, err := os.Stat(path) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) { | |||
| 39 | + | writes := make(chan []byte, 1) | |||
| 40 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 41 | + | defer releasePTY() | |||
| 42 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 43 | + | d.tempDir = t.TempDir() | |||
| 44 | + | pane := sess.tabs[0].focusedPane() | |||
| 45 | + | require.False(t, pane.screen.BracketedPasteMode()) | |||
| 46 | + | ||||
| 47 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |||
| 48 | + | ||||
| 49 | + | injected := <-writes | |||
| 50 | + | require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode") | |||
| 51 | + | require.FileExists(t, string(injected)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) { | |||
| 55 | + | writes := make(chan []byte, 1) | |||
| 56 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 57 | + | defer releasePTY() | |||
| 58 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 59 | + | d.tempDir = t.TempDir() | |||
| 60 | + | pane := sess.tabs[0].focusedPane() | |||
| 61 | + | pane.screen.Write([]byte("\x1b[?2004h")) | |||
| 62 | + | require.True(t, pane.screen.BracketedPasteMode()) | |||
| 63 | + | ||||
| 64 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |||
| 65 | + | ||||
| 66 | + | injected := <-writes | |||
| 67 | + | require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker)) | |||
| 68 | + | require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)]) | |||
| 69 | + | require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):]) | |||
| 70 | + | path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)]) | |||
| 71 | + | require.FileExists(t, path) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func hasBracketedPasteMarkers(b []byte) bool { | |||
| 75 | + | return len(b) >= len(clipPasteOpenMarker) && | |||
| 76 | + | string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) { | |||
| 80 | + | writes := make(chan []byte, 1) | |||
| 81 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 82 | + | defer releasePTY() | |||
| 83 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 84 | + | dir := t.TempDir() | |||
| 85 | + | d.tempDir = dir | |||
| 86 | + | ||||
| 87 | + | huge := make([]byte, maxImagePushSize+1) | |||
| 88 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge}) | |||
| 89 | + | ||||
| 90 | + | select { | |||
| 91 | + | case w := <-writes: | |||
| 92 | + | t.Fatalf("oversized push must not be injected into the pane, got %q", w) | |||
| 93 | + | default: | |||
| 94 | + | } | |||
| 95 | + | entries, err := os.ReadDir(dir) | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | require.Empty(t, entries, "oversized push must not write a temp file") | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) { | |||
| 101 | + | writes := make(chan []byte, 1) | |||
| 102 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 103 | + | defer releasePTY() | |||
| 104 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 105 | + | dir := t.TempDir() | |||
| 106 | + | d.tempDir = dir | |||
| 107 | + | ||||
| 108 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil}) | |||
| 109 | + | ||||
| 110 | + | select { | |||
| 111 | + | case w := <-writes: | |||
| 112 | + | t.Fatalf("empty push must not be injected, got %q", w) | |||
| 113 | + | default: | |||
| 114 | + | } | |||
| 115 | + | entries, err := os.ReadDir(dir) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | require.Empty(t, entries) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) { | |||
| 121 | + | cases := map[string]string{ | |||
| 122 | + | "image/png": "png", | |||
| 123 | + | "image/jpeg": "jpg", | |||
| 124 | + | "image/webp": "webp", | |||
| 125 | + | "image/gif": "gif", | |||
| 126 | + | "image/bmp": "bin", | |||
| 127 | + | "": "bin", | |||
| 128 | + | "application/octet-stream": "bin", | |||
| 129 | + | } | |||
| 130 | + | for mime, want := range cases { | |||
| 131 | + | require.Equal(t, want, clipboardExt(mime), "mime %q", mime) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestKillSessionRemovesClipboardTempFiles(t *testing.T) { | |||
| 136 | + | writes := make(chan []byte, 1) | |||
| 137 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 138 | + | defer releasePTY() | |||
| 139 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 140 | + | d.tempDir = t.TempDir() | |||
| 141 | + | // killSession requires the session to be registered in the daemon's | |||
| 142 | + | // registry (newManualSessionWithPTYs already does this via d.sessions). | |||
| 143 | + | ||||
| 144 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |||
| 145 | + | injected := <-writes | |||
| 146 | + | path := string(injected) | |||
| 147 | + | require.FileExists(t, path) | |||
| 148 | + | ||||
| 149 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 150 | + | require.NoFileExists(t, path) | |||
| 151 | + | } | |||
M internal/usecase/daemon/daemon.go +12 -0
| @@ -103,6 +103,9 @@ type Daemon struct { | |||
| 103 | 103 | persistEnabled bool | |
| 104 | 104 | procCwd func(int) (string, error) | |
| 105 | 105 | procComm func(int) (string, error) | |
| 106 | + | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 107 | + | // (see clipboard.go); empty means use os.TempDir(). | |
| 108 | + | tempDir string | |
| 106 | 109 | ||
| 107 | 110 | serveCtx context.Context | |
| 108 | 111 | serveCancel context.CancelFunc | |
| @@ -167,6 +170,15 @@ func WithCwdReader(fn func(int) (string, error)) Option { | |||
| 167 | 170 | } | |
| 168 | 171 | } | |
| 169 | 172 | ||
| 173 | + | // WithTempDir overrides the directory clipboard-image-transfer writes temp | |
| 174 | + | // files into (production default: os.TempDir()); tests use this with | |
| 175 | + | // t.TempDir() so writes are isolated and auto-cleaned. | |
| 176 | + | func WithTempDir(dir string) Option { | |
| 177 | + | return func(d *Daemon) { | |
| 178 | + | d.tempDir = dir | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 170 | 182 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | |
| 171 | 183 | // render debounce, and log receives diagnostics (defaults to slog.Default). | |
| 172 | 184 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | |
| @@ -103,6 +103,9 @@ type Daemon struct { | |||||
| 103 | persistEnabled bool | 103 | persistEnabled bool | ||
| 104 | procCwd func(int) (string, error) | 104 | procCwd func(int) (string, error) | ||
| 105 | procComm func(int) (string, error) | 105 | procComm func(int) (string, error) | ||
| 106 | + | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |||
| 107 | + | // (see clipboard.go); empty means use os.TempDir(). | |||
| 108 | + | tempDir string | |||
| 106 | 109 | ||||
| 107 | serveCtx context.Context | 110 | serveCtx context.Context | ||
| 108 | serveCancel context.CancelFunc | 111 | serveCancel context.CancelFunc | ||
| @@ -167,6 +170,15 @@ func WithCwdReader(fn func(int) (string, error)) Option { | |||||
| 167 | } | 170 | } | ||
| 168 | } | 171 | } | ||
| 169 | 172 | ||||
| 173 | + | // WithTempDir overrides the directory clipboard-image-transfer writes temp | |||
| 174 | + | // files into (production default: os.TempDir()); tests use this with | |||
| 175 | + | // t.TempDir() so writes are isolated and auto-cleaned. | |||
| 176 | + | func WithTempDir(dir string) Option { | |||
| 177 | + | return func(d *Daemon) { | |||
| 178 | + | d.tempDir = dir | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 170 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | 182 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | ||
| 171 | // render debounce, and log receives diagnostics (defaults to slog.Default). | 183 | // render debounce, and log receives diagnostics (defaults to slog.Default). | ||
| 172 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | 184 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | ||
M internal/usecase/daemon/session.go +13 -1
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | "context" | |
| 29 | 29 | "errors" | |
| 30 | 30 | "fmt" | |
| 31 | + | "os" | |
| 31 | 32 | "strconv" | |
| 32 | 33 | "strings" | |
| 33 | 34 | "sync" | |
| @@ -47,12 +48,16 @@ type session struct { | |||
| 47 | 48 | ctx context.Context | |
| 48 | 49 | cancel context.CancelFunc | |
| 49 | 50 | ||
| 50 | - | mu sync.Mutex // guards tabs, active, and client | |
| 51 | + | mu sync.Mutex // guards tabs, active, client, and clipFiles | |
| 51 | 52 | tabs []*tab | |
| 52 | 53 | active int | |
| 53 | 54 | client *attachedClient | |
| 54 | 55 | cwd string | |
| 55 | 56 | createdAt int64 | |
| 57 | + | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 58 | + | // clipboard.go) written for this session, removed best-effort in | |
| 59 | + | // killSession. | |
| 60 | + | clipFiles []string | |
| 56 | 61 | } | |
| 57 | 62 | ||
| 58 | 63 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | |
| @@ -467,11 +472,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 467 | 472 | sess.cancel() | |
| 468 | 473 | sess.mu.Lock() | |
| 469 | 474 | tabs := append([]*tab(nil), sess.tabs...) | |
| 475 | + | clipFiles := sess.clipFiles | |
| 476 | + | sess.clipFiles = nil | |
| 470 | 477 | sess.mu.Unlock() | |
| 471 | 478 | for _, tb := range tabs { | |
| 472 | 479 | d.clearDestroyedTabPreview(tb) | |
| 473 | 480 | tb.closeAllPanes() | |
| 474 | 481 | } | |
| 482 | + | for _, path := range clipFiles { | |
| 483 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 484 | + | d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) | |
| 485 | + | } | |
| 486 | + | } | |
| 475 | 487 | if empty { | |
| 476 | 488 | d.doneOnce.Do(func() { close(d.done) }) | |
| 477 | 489 | } else if ringing { | |
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | "context" | 28 | "context" | ||
| 29 | "errors" | 29 | "errors" | ||
| 30 | "fmt" | 30 | "fmt" | ||
| 31 | + | "os" | |||
| 31 | "strconv" | 32 | "strconv" | ||
| 32 | "strings" | 33 | "strings" | ||
| 33 | "sync" | 34 | "sync" | ||
| @@ -47,12 +48,16 @@ type session struct { | |||||
| 47 | ctx context.Context | 48 | ctx context.Context | ||
| 48 | cancel context.CancelFunc | 49 | cancel context.CancelFunc | ||
| 49 | 50 | ||||
| 50 | - | mu sync.Mutex // guards tabs, active, and client | 51 | + | mu sync.Mutex // guards tabs, active, client, and clipFiles |
| 51 | tabs []*tab | 52 | tabs []*tab | ||
| 52 | active int | 53 | active int | ||
| 53 | client *attachedClient | 54 | client *attachedClient | ||
| 54 | cwd string | 55 | cwd string | ||
| 55 | createdAt int64 | 56 | createdAt int64 | ||
| 57 | + | // clipFiles records clipboard-image-transfer temp file paths (see | |||
| 58 | + | // clipboard.go) written for this session, removed best-effort in | |||
| 59 | + | // killSession. | |||
| 60 | + | clipFiles []string | |||
| 56 | } | 61 | } | ||
| 57 | 62 | ||||
| 58 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | 63 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | ||
| @@ -467,11 +472,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 467 | sess.cancel() | 472 | sess.cancel() | ||
| 468 | sess.mu.Lock() | 473 | sess.mu.Lock() | ||
| 469 | tabs := append([]*tab(nil), sess.tabs...) | 474 | tabs := append([]*tab(nil), sess.tabs...) | ||
| 475 | + | clipFiles := sess.clipFiles | |||
| 476 | + | sess.clipFiles = nil | |||
| 470 | sess.mu.Unlock() | 477 | sess.mu.Unlock() | ||
| 471 | for _, tb := range tabs { | 478 | for _, tb := range tabs { | ||
| 472 | d.clearDestroyedTabPreview(tb) | 479 | d.clearDestroyedTabPreview(tb) | ||
| 473 | tb.closeAllPanes() | 480 | tb.closeAllPanes() | ||
| 474 | } | 481 | } | ||
| 482 | + | for _, path := range clipFiles { | |||
| 483 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 484 | + | d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) | |||
| 485 | + | } | |||
| 486 | + | } | |||
| 475 | if empty { | 487 | if empty { | ||
| 476 | d.doneOnce.Do(func() { close(d.done) }) | 488 | d.doneOnce.Do(func() { close(d.done) }) | ||
| 477 | } else if ringing { | 489 | } else if ringing { | ||