Merge pull request #14 from bnema/feature/clipboard-image-transfer
feat(client,daemon): transfer clipboard image to remote pane as temp-file path
23 files · +1390 -36
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 +113 -0
| @@ -0,0 +1,113 @@ | |||
| 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. Keep this short so a slow clipboard read cannot noticeably | |
| 33 | + | // freeze typing for longer than the image-transfer MVP's small payload cap. | |
| 34 | + | const defaultReadTimeout = time.Second | |
| 35 | + | ||
| 36 | + | // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It | |
| 37 | + | // holds no state and is safe for concurrent use; every call runs a fresh | |
| 38 | + | // subprocess. | |
| 39 | + | type WlPaste struct { | |
| 40 | + | // execCommand constructs the command to run; overridable in tests so | |
| 41 | + | // command construction can be verified without requiring wl-paste (or | |
| 42 | + | // even Wayland) to be installed. | |
| 43 | + | execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd | |
| 44 | + | // readTimeout bounds the data-read command; zero means defaultReadTimeout | |
| 45 | + | // (a zero-value WlPaste{execCommand: ...}, as tests construct directly, | |
| 46 | + | // still gets a sane default). Tests needing a short deadline set it | |
| 47 | + | // explicitly. | |
| 48 | + | readTimeout time.Duration | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // New returns a WlPaste clipboard reader that shells out to the real | |
| 52 | + | // wl-paste binary. | |
| 53 | + | func New() *WlPaste { | |
| 54 | + | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (w *WlPaste) readTimeoutOrDefault() time.Duration { | |
| 58 | + | if w.readTimeout <= 0 { | |
| 59 | + | return defaultReadTimeout | |
| 60 | + | } | |
| 61 | + | return w.readTimeout | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // ReadImage implements ports.ClipboardReader. It lists the clipboard's | |
| 65 | + | // offered MIME types, picks the best supported image type (if any), and | |
| 66 | + | // reads it. Any failure (wl-paste missing, no image on the clipboard, a | |
| 67 | + | // read error or timeout) is reported as ports.ErrNoClipboardImage so callers | |
| 68 | + | // uniformly fall back to forwarding the keystroke that triggered the read. | |
| 69 | + | func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) { | |
| 70 | + | listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout) | |
| 71 | + | defer cancel() | |
| 72 | + | ||
| 73 | + | listCmd := w.execCommand(listCtx, "wl-paste", "--list-types") | |
| 74 | + | out, err := listCmd.Output() | |
| 75 | + | if err != nil { | |
| 76 | + | return "", nil, ports.ErrNoClipboardImage | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | mime := pickImageMime(string(out)) | |
| 80 | + | if mime == "" { | |
| 81 | + | return "", nil, ports.ErrNoClipboardImage | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault()) | |
| 85 | + | defer readCancel() | |
| 86 | + | ||
| 87 | + | readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline") | |
| 88 | + | var buf bytes.Buffer | |
| 89 | + | readCmd.Stdout = &buf | |
| 90 | + | if err := readCmd.Run(); err != nil { | |
| 91 | + | return "", nil, ports.ErrNoClipboardImage | |
| 92 | + | } | |
| 93 | + | return mime, buf.Bytes(), nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // pickImageMime scans wl-paste --list-types output (one MIME type per line) | |
| 97 | + | // and returns the first match in mimePreference order, or "" if the | |
| 98 | + | // clipboard offers no supported image type. | |
| 99 | + | func pickImageMime(listTypesOutput string) string { | |
| 100 | + | offered := make(map[string]bool) | |
| 101 | + | for _, line := range strings.Split(listTypesOutput, "\n") { | |
| 102 | + | line = strings.TrimSpace(line) | |
| 103 | + | if line != "" { | |
| 104 | + | offered[line] = true | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | for _, mime := range mimePreference { | |
| 108 | + | if offered[mime] { | |
| 109 | + | return mime | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return "" | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 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. Keep this short so a slow clipboard read cannot noticeably | |||
| 33 | + | // freeze typing for longer than the image-transfer MVP's small payload cap. | |||
| 34 | + | const defaultReadTimeout = time.Second | |||
| 35 | + | ||||
| 36 | + | // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It | |||
| 37 | + | // holds no state and is safe for concurrent use; every call runs a fresh | |||
| 38 | + | // subprocess. | |||
| 39 | + | type WlPaste struct { | |||
| 40 | + | // execCommand constructs the command to run; overridable in tests so | |||
| 41 | + | // command construction can be verified without requiring wl-paste (or | |||
| 42 | + | // even Wayland) to be installed. | |||
| 43 | + | execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd | |||
| 44 | + | // readTimeout bounds the data-read command; zero means defaultReadTimeout | |||
| 45 | + | // (a zero-value WlPaste{execCommand: ...}, as tests construct directly, | |||
| 46 | + | // still gets a sane default). Tests needing a short deadline set it | |||
| 47 | + | // explicitly. | |||
| 48 | + | readTimeout time.Duration | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // New returns a WlPaste clipboard reader that shells out to the real | |||
| 52 | + | // wl-paste binary. | |||
| 53 | + | func New() *WlPaste { | |||
| 54 | + | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (w *WlPaste) readTimeoutOrDefault() time.Duration { | |||
| 58 | + | if w.readTimeout <= 0 { | |||
| 59 | + | return defaultReadTimeout | |||
| 60 | + | } | |||
| 61 | + | return w.readTimeout | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // ReadImage implements ports.ClipboardReader. It lists the clipboard's | |||
| 65 | + | // offered MIME types, picks the best supported image type (if any), and | |||
| 66 | + | // reads it. Any failure (wl-paste missing, no image on the clipboard, a | |||
| 67 | + | // read error or timeout) is reported as ports.ErrNoClipboardImage so callers | |||
| 68 | + | // uniformly fall back to forwarding the keystroke that triggered the read. | |||
| 69 | + | func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) { | |||
| 70 | + | listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout) | |||
| 71 | + | defer cancel() | |||
| 72 | + | ||||
| 73 | + | listCmd := w.execCommand(listCtx, "wl-paste", "--list-types") | |||
| 74 | + | out, err := listCmd.Output() | |||
| 75 | + | if err != nil { | |||
| 76 | + | return "", nil, ports.ErrNoClipboardImage | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | mime := pickImageMime(string(out)) | |||
| 80 | + | if mime == "" { | |||
| 81 | + | return "", nil, ports.ErrNoClipboardImage | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault()) | |||
| 85 | + | defer readCancel() | |||
| 86 | + | ||||
| 87 | + | readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline") | |||
| 88 | + | var buf bytes.Buffer | |||
| 89 | + | readCmd.Stdout = &buf | |||
| 90 | + | if err := readCmd.Run(); err != nil { | |||
| 91 | + | return "", nil, ports.ErrNoClipboardImage | |||
| 92 | + | } | |||
| 93 | + | return mime, buf.Bytes(), nil | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // pickImageMime scans wl-paste --list-types output (one MIME type per line) | |||
| 97 | + | // and returns the first match in mimePreference order, or "" if the | |||
| 98 | + | // clipboard offers no supported image type. | |||
| 99 | + | func pickImageMime(listTypesOutput string) string { | |||
| 100 | + | offered := make(map[string]bool) | |||
| 101 | + | for _, line := range strings.Split(listTypesOutput, "\n") { | |||
| 102 | + | line = strings.TrimSpace(line) | |||
| 103 | + | if line != "" { | |||
| 104 | + | offered[line] = true | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | for _, mime := range mimePreference { | |||
| 108 | + | if offered[mime] { | |||
| 109 | + | return mime | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return "" | |||
| 113 | + | } | |||
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/adapters/dgram/transport.go +15 -6
| @@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 288 | 288 | return | |
| 289 | 289 | } | |
| 290 | 290 | if reliable { | |
| 291 | - | if t.enqueueReliable(seq, f) { | |
| 291 | + | ack, queued := t.enqueueReliable(seq, f) | |
| 292 | + | if ack { | |
| 292 | 293 | t.sendAck(seq) | |
| 293 | 294 | } | |
| 295 | + | if queued { | |
| 296 | + | t.signalDelivery() | |
| 297 | + | } | |
| 294 | 298 | return | |
| 295 | 299 | } | |
| 296 | 300 | t.deliver(f) | |
| 297 | 301 | } | |
| 298 | 302 | } | |
| 299 | 303 | ||
| 300 | - | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool { | |
| 304 | + | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ack bool, queued bool) { | |
| 301 | 305 | t.deliverMu.Lock() | |
| 302 | 306 | defer t.deliverMu.Unlock() | |
| 303 | 307 | if seq < t.nextRecvSeq { | |
| 304 | - | return true | |
| 308 | + | return true, false | |
| 305 | 309 | } | |
| 306 | 310 | if _, exists := t.recvBuf[seq]; exists { | |
| 307 | - | return true | |
| 311 | + | return true, false | |
| 308 | 312 | } | |
| 309 | 313 | if len(t.recvBuf) >= maxRecvBuffer { | |
| 310 | 314 | if seq != t.nextRecvSeq { | |
| 311 | - | return false | |
| 315 | + | return false, false | |
| 312 | 316 | } | |
| 313 | 317 | for bufferedSeq := range t.recvBuf { | |
| 314 | 318 | delete(t.recvBuf, bufferedSeq) | |
| @@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool { | |||
| 316 | 320 | } | |
| 317 | 321 | } | |
| 318 | 322 | t.recvBuf[seq] = f | |
| 323 | + | return true, true | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (t *Transport) signalDelivery() { | |
| 327 | + | t.deliverMu.Lock() | |
| 319 | 328 | t.deliverCond.Signal() | |
| 320 | - | return true | |
| 329 | + | t.deliverMu.Unlock() | |
| 321 | 330 | } | |
| 322 | 331 | ||
| 323 | 332 | func (t *Transport) deliveryLoop() { | |
| @@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) { | |||||
| 288 | return | 288 | return | ||
| 289 | } | 289 | } | ||
| 290 | if reliable { | 290 | if reliable { | ||
| 291 | - | if t.enqueueReliable(seq, f) { | 291 | + | ack, queued := t.enqueueReliable(seq, f) |
| 292 | + | if ack { | |||
| 292 | t.sendAck(seq) | 293 | t.sendAck(seq) | ||
| 293 | } | 294 | } | ||
| 295 | + | if queued { | |||
| 296 | + | t.signalDelivery() | |||
| 297 | + | } | |||
| 294 | return | 298 | return | ||
| 295 | } | 299 | } | ||
| 296 | t.deliver(f) | 300 | t.deliver(f) | ||
| 297 | } | 301 | } | ||
| 298 | } | 302 | } | ||
| 299 | 303 | ||||
| 300 | - | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool { | 304 | + | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ack bool, queued bool) { |
| 301 | t.deliverMu.Lock() | 305 | t.deliverMu.Lock() | ||
| 302 | defer t.deliverMu.Unlock() | 306 | defer t.deliverMu.Unlock() | ||
| 303 | if seq < t.nextRecvSeq { | 307 | if seq < t.nextRecvSeq { | ||
| 304 | - | return true | 308 | + | return true, false |
| 305 | } | 309 | } | ||
| 306 | if _, exists := t.recvBuf[seq]; exists { | 310 | if _, exists := t.recvBuf[seq]; exists { | ||
| 307 | - | return true | 311 | + | return true, false |
| 308 | } | 312 | } | ||
| 309 | if len(t.recvBuf) >= maxRecvBuffer { | 313 | if len(t.recvBuf) >= maxRecvBuffer { | ||
| 310 | if seq != t.nextRecvSeq { | 314 | if seq != t.nextRecvSeq { | ||
| 311 | - | return false | 315 | + | return false, false |
| 312 | } | 316 | } | ||
| 313 | for bufferedSeq := range t.recvBuf { | 317 | for bufferedSeq := range t.recvBuf { | ||
| 314 | delete(t.recvBuf, bufferedSeq) | 318 | delete(t.recvBuf, bufferedSeq) | ||
| @@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool { | |||||
| 316 | } | 320 | } | ||
| 317 | } | 321 | } | ||
| 318 | t.recvBuf[seq] = f | 322 | t.recvBuf[seq] = f | ||
| 323 | + | return true, true | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (t *Transport) signalDelivery() { | |||
| 327 | + | t.deliverMu.Lock() | |||
| 319 | t.deliverCond.Signal() | 328 | t.deliverCond.Signal() | ||
| 320 | - | return true | 329 | + | t.deliverMu.Unlock() |
| 321 | } | 330 | } | ||
| 322 | 331 | ||||
| 323 | func (t *Transport) deliveryLoop() { | 332 | func (t *Transport) deliveryLoop() { | ||
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" | |
| @@ -286,6 +287,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 286 | 287 | remoteDialer: defaultRemoteDialer, | |
| 287 | 288 | runClient: client.Run, | |
| 288 | 289 | createDetached: createDetachedLocalSession, | |
| 290 | + | clipboard: clipboard.New(), | |
| 289 | 291 | }) | |
| 290 | 292 | } | |
| 291 | 293 | ||
| @@ -299,8 +301,13 @@ type runAttachDeps struct { | |||
| 299 | 301 | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | |
| 300 | 302 | localDialer func() ports.Dialer | |
| 301 | 303 | remoteDialer func(target, session string, log *slog.Logger) ports.Dialer | |
| 302 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, *slog.Logger) error | |
| 304 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | |
| 303 | 305 | createDetached func(context.Context, string) error | |
| 306 | + | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |
| 307 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 308 | + | // Only used for the remote-dialer branch below; local attaches never | |
| 309 | + | // intercept Ctrl+V regardless of this field. | |
| 310 | + | clipboard ports.ClipboardReader | |
| 304 | 311 | } | |
| 305 | 312 | ||
| 306 | 313 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | |
| @@ -323,7 +330,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 323 | 330 | if log != nil { | |
| 324 | 331 | log.Info("attaching to remote session", "target", remoteTarget, "name", name) | |
| 325 | 332 | } | |
| 326 | - | return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, log) | |
| 333 | + | return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log) | |
| 327 | 334 | } | |
| 328 | 335 | ||
| 329 | 336 | attachLocal := deps.attachLocal | |
| @@ -336,7 +343,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 336 | 343 | if log != nil { | |
| 337 | 344 | log.Info("attaching to local session", "intent", intent, "name", name) | |
| 338 | 345 | } | |
| 339 | - | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, log) | |
| 346 | + | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log) | |
| 340 | 347 | } | |
| 341 | 348 | } | |
| 342 | 349 | 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" | ||
| @@ -286,6 +287,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 286 | remoteDialer: defaultRemoteDialer, | 287 | remoteDialer: defaultRemoteDialer, | ||
| 287 | runClient: client.Run, | 288 | runClient: client.Run, | ||
| 288 | createDetached: createDetachedLocalSession, | 289 | createDetached: createDetachedLocalSession, | ||
| 290 | + | clipboard: clipboard.New(), | |||
| 289 | }) | 291 | }) | ||
| 290 | } | 292 | } | ||
| 291 | 293 | ||||
| @@ -299,8 +301,13 @@ type runAttachDeps struct { | |||||
| 299 | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | 301 | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | ||
| 300 | localDialer func() ports.Dialer | 302 | localDialer func() ports.Dialer | ||
| 301 | remoteDialer func(target, session string, log *slog.Logger) ports.Dialer | 303 | remoteDialer func(target, session string, log *slog.Logger) ports.Dialer | ||
| 302 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, *slog.Logger) error | 304 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error |
| 303 | createDetached func(context.Context, string) error | 305 | createDetached func(context.Context, string) error | ||
| 306 | + | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |||
| 307 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |||
| 308 | + | // Only used for the remote-dialer branch below; local attaches never | |||
| 309 | + | // intercept Ctrl+V regardless of this field. | |||
| 310 | + | clipboard ports.ClipboardReader | |||
| 304 | } | 311 | } | ||
| 305 | 312 | ||||
| 306 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | 313 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | ||
| @@ -323,7 +330,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 323 | if log != nil { | 330 | if log != nil { | ||
| 324 | log.Info("attaching to remote session", "target", remoteTarget, "name", name) | 331 | log.Info("attaching to remote session", "target", remoteTarget, "name", name) | ||
| 325 | } | 332 | } | ||
| 326 | - | return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, log) | 333 | + | return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log) |
| 327 | } | 334 | } | ||
| 328 | 335 | ||||
| 329 | attachLocal := deps.attachLocal | 336 | attachLocal := deps.attachLocal | ||
| @@ -336,7 +343,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 336 | if log != nil { | 343 | if log != nil { | ||
| 337 | log.Info("attaching to local session", "intent", intent, "name", name) | 344 | log.Info("attaching to local session", "intent", intent, "name", name) | ||
| 338 | } | 345 | } | ||
| 339 | - | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, log) | 346 | + | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log) |
| 340 | } | 347 | } | ||
| 341 | } | 348 | } | ||
| 342 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | 349 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | ||
M internal/app/run_test.go +30 -2
| @@ -394,15 +394,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 394 | 394 | return nil, errors.New("not used") | |
| 395 | 395 | } | |
| 396 | 396 | ||
| 397 | + | // fakeClipboardReader is a distinguishable ports.ClipboardReader used only to | |
| 398 | + | // verify identity (that runAttachWithDeps threads the *same* reader through), | |
| 399 | + | // never actually invoked in these wiring tests. | |
| 400 | + | type fakeClipboardReader struct{ ports.ClipboardReader } | |
| 401 | + | ||
| 397 | 402 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |
| 398 | 403 | var gotTarget, gotSession, gotDialer string | |
| 404 | + | var gotRemote bool | |
| 405 | + | var gotClipboard ports.ClipboardReader | |
| 406 | + | clip := &fakeClipboardReader{} | |
| 399 | 407 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 400 | 408 | remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer { | |
| 401 | 409 | gotTarget, gotSession = target, session | |
| 402 | 410 | return namedDialer{name: "remote"} | |
| 403 | 411 | }, | |
| 404 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error { | |
| 412 | + | clipboard: clip, | |
| 413 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |
| 405 | 414 | gotDialer = d.(namedDialer).name | |
| 415 | + | gotRemote = remote | |
| 416 | + | gotClipboard = clipboard | |
| 406 | 417 | if intent != ports.IntentAttach || name != "work" { | |
| 407 | 418 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |
| 408 | 419 | } | |
| @@ -415,14 +426,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||
| 415 | 426 | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | |
| 416 | 427 | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | |
| 417 | 428 | } | |
| 429 | + | if !gotRemote { | |
| 430 | + | t.Fatal("remote attach must pass remote=true to runClient") | |
| 431 | + | } | |
| 432 | + | if gotClipboard != ports.ClipboardReader(clip) { | |
| 433 | + | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |
| 434 | + | } | |
| 418 | 435 | } | |
| 419 | 436 | ||
| 420 | 437 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |
| 421 | 438 | var gotDialer string | |
| 439 | + | gotRemote := true | |
| 440 | + | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |
| 422 | 441 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 423 | 442 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 424 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error { | |
| 443 | + | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | |
| 444 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |
| 425 | 445 | gotDialer = d.(namedDialer).name | |
| 446 | + | gotRemote = remote | |
| 447 | + | gotClipboard = clipboard | |
| 426 | 448 | if intent != ports.IntentEphemeral || name != "" { | |
| 427 | 449 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | |
| 428 | 450 | } | |
| @@ -435,4 +457,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 435 | 457 | if gotDialer != "local" { | |
| 436 | 458 | t.Fatalf("local dialer = %q, want local", gotDialer) | |
| 437 | 459 | } | |
| 460 | + | if gotRemote { | |
| 461 | + | t.Fatal("local attach must pass remote=false to runClient") | |
| 462 | + | } | |
| 463 | + | if gotClipboard != nil { | |
| 464 | + | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |
| 465 | + | } | |
| 438 | 466 | } | |
| @@ -394,15 +394,26 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||||
| 394 | return nil, errors.New("not used") | 394 | return nil, errors.New("not used") | ||
| 395 | } | 395 | } | ||
| 396 | 396 | ||||
| 397 | + | // fakeClipboardReader is a distinguishable ports.ClipboardReader used only to | |||
| 398 | + | // verify identity (that runAttachWithDeps threads the *same* reader through), | |||
| 399 | + | // never actually invoked in these wiring tests. | |||
| 400 | + | type fakeClipboardReader struct{ ports.ClipboardReader } | |||
| 401 | + | ||||
| 397 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | 402 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | ||
| 398 | var gotTarget, gotSession, gotDialer string | 403 | var gotTarget, gotSession, gotDialer string | ||
| 404 | + | var gotRemote bool | |||
| 405 | + | var gotClipboard ports.ClipboardReader | |||
| 406 | + | clip := &fakeClipboardReader{} | |||
| 399 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | 407 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | ||
| 400 | remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer { | 408 | remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer { | ||
| 401 | gotTarget, gotSession = target, session | 409 | gotTarget, gotSession = target, session | ||
| 402 | return namedDialer{name: "remote"} | 410 | return namedDialer{name: "remote"} | ||
| 403 | }, | 411 | }, | ||
| 404 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error { | 412 | + | clipboard: clip, |
| 413 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |||
| 405 | gotDialer = d.(namedDialer).name | 414 | gotDialer = d.(namedDialer).name | ||
| 415 | + | gotRemote = remote | |||
| 416 | + | gotClipboard = clipboard | |||
| 406 | if intent != ports.IntentAttach || name != "work" { | 417 | if intent != ports.IntentAttach || name != "work" { | ||
| 407 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | 418 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | ||
| 408 | } | 419 | } | ||
| @@ -415,14 +426,25 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||||
| 415 | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | 426 | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | ||
| 416 | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | 427 | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | ||
| 417 | } | 428 | } | ||
| 429 | + | if !gotRemote { | |||
| 430 | + | t.Fatal("remote attach must pass remote=true to runClient") | |||
| 431 | + | } | |||
| 432 | + | if gotClipboard != ports.ClipboardReader(clip) { | |||
| 433 | + | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |||
| 434 | + | } | |||
| 418 | } | 435 | } | ||
| 419 | 436 | ||||
| 420 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | 437 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | ||
| 421 | var gotDialer string | 438 | var gotDialer string | ||
| 439 | + | gotRemote := true | |||
| 440 | + | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |||
| 422 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | 441 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | ||
| 423 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | 442 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | ||
| 424 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error { | 443 | + | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach |
| 444 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |||
| 425 | gotDialer = d.(namedDialer).name | 445 | gotDialer = d.(namedDialer).name | ||
| 446 | + | gotRemote = remote | |||
| 447 | + | gotClipboard = clipboard | |||
| 426 | if intent != ports.IntentEphemeral || name != "" { | 448 | if intent != ports.IntentEphemeral || name != "" { | ||
| 427 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | 449 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | ||
| 428 | } | 450 | } | ||
| @@ -435,4 +457,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 435 | if gotDialer != "local" { | 457 | if gotDialer != "local" { | ||
| 436 | t.Fatalf("local dialer = %q, want local", gotDialer) | 458 | t.Fatalf("local dialer = %q, want local", gotDialer) | ||
| 437 | } | 459 | } | ||
| 460 | + | if gotRemote { | |||
| 461 | + | t.Fatal("local attach must pass remote=false to runClient") | |||
| 462 | + | } | |||
| 463 | + | if gotClipboard != nil { | |||
| 464 | + | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |||
| 465 | + | } | |||
| 438 | } | 466 | } | ||
M internal/ports/frame.go +11 -10
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 5 | |
| 4 | + | const ProtocolVersion uint16 = 7 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -10,15 +10,16 @@ type MsgType uint8 | |||
| 10 | 10 | // through 15, server-originated messages from 16, leaving room for growth in | |
| 11 | 11 | // each band. | |
| 12 | 12 | const ( | |
| 13 | - | MsgHello MsgType = 1 | |
| 14 | - | MsgInput MsgType = 2 | |
| 15 | - | MsgResize MsgType = 3 | |
| 16 | - | MsgDetach MsgType = 4 | |
| 17 | - | MsgPing MsgType = 5 | |
| 18 | - | MsgList MsgType = 6 | |
| 19 | - | MsgKill MsgType = 7 | |
| 20 | - | MsgTheme MsgType = 8 | |
| 21 | - | MsgAck MsgType = 9 | |
| 13 | + | MsgHello MsgType = 1 | |
| 14 | + | MsgInput MsgType = 2 | |
| 15 | + | MsgResize MsgType = 3 | |
| 16 | + | MsgDetach MsgType = 4 | |
| 17 | + | MsgPing MsgType = 5 | |
| 18 | + | MsgList MsgType = 6 | |
| 19 | + | MsgKill MsgType = 7 | |
| 20 | + | MsgTheme MsgType = 8 | |
| 21 | + | MsgAck MsgType = 9 | |
| 22 | + | MsgImagePush MsgType = 10 | |
| 22 | 23 | ||
| 23 | 24 | MsgWelcome MsgType = 16 | |
| 24 | 25 | MsgError MsgType = 17 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 5 | 4 | + | const ProtocolVersion uint16 = 7 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -10,15 +10,16 @@ type MsgType uint8 | |||||
| 10 | // through 15, server-originated messages from 16, leaving room for growth in | 10 | // through 15, server-originated messages from 16, leaving room for growth in | ||
| 11 | // each band. | 11 | // each band. | ||
| 12 | const ( | 12 | const ( | ||
| 13 | - | MsgHello MsgType = 1 | 13 | + | MsgHello MsgType = 1 |
| 14 | - | MsgInput MsgType = 2 | 14 | + | MsgInput MsgType = 2 |
| 15 | - | MsgResize MsgType = 3 | 15 | + | MsgResize MsgType = 3 |
| 16 | - | MsgDetach MsgType = 4 | 16 | + | MsgDetach MsgType = 4 |
| 17 | - | MsgPing MsgType = 5 | 17 | + | MsgPing MsgType = 5 |
| 18 | - | MsgList MsgType = 6 | 18 | + | MsgList MsgType = 6 |
| 19 | - | MsgKill MsgType = 7 | 19 | + | MsgKill MsgType = 7 |
| 20 | - | MsgTheme MsgType = 8 | 20 | + | MsgTheme MsgType = 8 |
| 21 | - | MsgAck MsgType = 9 | 21 | + | MsgAck MsgType = 9 |
| 22 | + | MsgImagePush MsgType = 10 | |||
| 22 | 23 | ||||
| 23 | MsgWelcome MsgType = 16 | 24 | MsgWelcome MsgType = 16 | ||
| 24 | MsgError MsgType = 17 | 25 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +3 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 5 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 7 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -22,6 +22,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 22 | 22 | {"MsgKill", MsgKill}, | |
| 23 | 23 | {"MsgTheme", MsgTheme}, | |
| 24 | 24 | {"MsgAck", MsgAck}, | |
| 25 | + | {"MsgImagePush", MsgImagePush}, | |
| 25 | 26 | {"MsgWelcome", MsgWelcome}, | |
| 26 | 27 | {"MsgError", MsgError}, | |
| 27 | 28 | {"MsgOutput", MsgOutput}, | |
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 5 { | 6 | + | if ProtocolVersion != 7 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -22,6 +22,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 22 | {"MsgKill", MsgKill}, | 22 | {"MsgKill", MsgKill}, | ||
| 23 | {"MsgTheme", MsgTheme}, | 23 | {"MsgTheme", MsgTheme}, | ||
| 24 | {"MsgAck", MsgAck}, | 24 | {"MsgAck", MsgAck}, | ||
| 25 | + | {"MsgImagePush", MsgImagePush}, | |||
| 25 | {"MsgWelcome", MsgWelcome}, | 26 | {"MsgWelcome", MsgWelcome}, | ||
| 26 | {"MsgError", MsgError}, | 27 | {"MsgError", MsgError}, | ||
| 27 | {"MsgOutput", MsgOutput}, | 28 | {"MsgOutput", MsgOutput}, | ||
M internal/ports/mocks/mocks.go +95 -0
| @@ -1833,3 +1833,98 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||
| 1833 | 1833 | _c.Call.Return(run) | |
| 1834 | 1834 | return _c | |
| 1835 | 1835 | } | |
| 1836 | + | ||
| 1837 | + | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1838 | + | // The first argument is typically a *testing.T value. | |
| 1839 | + | func NewMockClipboardReader(t interface { | |
| 1840 | + | mock.TestingT | |
| 1841 | + | Cleanup(func()) | |
| 1842 | + | }) *MockClipboardReader { | |
| 1843 | + | mock := &MockClipboardReader{} | |
| 1844 | + | mock.Mock.Test(t) | |
| 1845 | + | ||
| 1846 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1847 | + | ||
| 1848 | + | return mock | |
| 1849 | + | } | |
| 1850 | + | ||
| 1851 | + | // MockClipboardReader is an autogenerated mock type for the ClipboardReader type | |
| 1852 | + | type MockClipboardReader struct { | |
| 1853 | + | mock.Mock | |
| 1854 | + | } | |
| 1855 | + | ||
| 1856 | + | type MockClipboardReader_Expecter struct { | |
| 1857 | + | mock *mock.Mock | |
| 1858 | + | } | |
| 1859 | + | ||
| 1860 | + | func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter { | |
| 1861 | + | return &MockClipboardReader_Expecter{mock: &_m.Mock} | |
| 1862 | + | } | |
| 1863 | + | ||
| 1864 | + | // ReadImage provides a mock function for the type MockClipboardReader | |
| 1865 | + | func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) { | |
| 1866 | + | ret := _mock.Called(ctx) | |
| 1867 | + | ||
| 1868 | + | if len(ret) == 0 { | |
| 1869 | + | panic("no return value specified for ReadImage") | |
| 1870 | + | } | |
| 1871 | + | ||
| 1872 | + | var r0 string | |
| 1873 | + | var r1 []byte | |
| 1874 | + | var r2 error | |
| 1875 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok { | |
| 1876 | + | return returnFunc(ctx) | |
| 1877 | + | } | |
| 1878 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok { | |
| 1879 | + | r0 = returnFunc(ctx) | |
| 1880 | + | } else { | |
| 1881 | + | r0 = ret.Get(0).(string) | |
| 1882 | + | } | |
| 1883 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok { | |
| 1884 | + | r1 = returnFunc(ctx) | |
| 1885 | + | } else { | |
| 1886 | + | if ret.Get(1) != nil { | |
| 1887 | + | r1 = ret.Get(1).([]byte) | |
| 1888 | + | } | |
| 1889 | + | } | |
| 1890 | + | if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok { | |
| 1891 | + | r2 = returnFunc(ctx) | |
| 1892 | + | } else { | |
| 1893 | + | r2 = ret.Error(2) | |
| 1894 | + | } | |
| 1895 | + | return r0, r1, r2 | |
| 1896 | + | } | |
| 1897 | + | ||
| 1898 | + | // MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage' | |
| 1899 | + | type MockClipboardReader_ReadImage_Call struct { | |
| 1900 | + | *mock.Call | |
| 1901 | + | } | |
| 1902 | + | ||
| 1903 | + | // ReadImage is a helper method to define mock.On call | |
| 1904 | + | // - ctx context.Context | |
| 1905 | + | func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call { | |
| 1906 | + | return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)} | |
| 1907 | + | } | |
| 1908 | + | ||
| 1909 | + | func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call { | |
| 1910 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1911 | + | var arg0 context.Context | |
| 1912 | + | if args[0] != nil { | |
| 1913 | + | arg0 = args[0].(context.Context) | |
| 1914 | + | } | |
| 1915 | + | run( | |
| 1916 | + | arg0, | |
| 1917 | + | ) | |
| 1918 | + | }) | |
| 1919 | + | return _c | |
| 1920 | + | } | |
| 1921 | + | ||
| 1922 | + | func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call { | |
| 1923 | + | _c.Call.Return(mime, data, err) | |
| 1924 | + | return _c | |
| 1925 | + | } | |
| 1926 | + | ||
| 1927 | + | func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call { | |
| 1928 | + | _c.Call.Return(run) | |
| 1929 | + | return _c | |
| 1930 | + | } | |
| @@ -1833,3 +1833,98 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||||
| 1833 | _c.Call.Return(run) | 1833 | _c.Call.Return(run) | ||
| 1834 | return _c | 1834 | return _c | ||
| 1835 | } | 1835 | } | ||
| 1836 | + | ||||
| 1837 | + | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1838 | + | // The first argument is typically a *testing.T value. | |||
| 1839 | + | func NewMockClipboardReader(t interface { | |||
| 1840 | + | mock.TestingT | |||
| 1841 | + | Cleanup(func()) | |||
| 1842 | + | }) *MockClipboardReader { | |||
| 1843 | + | mock := &MockClipboardReader{} | |||
| 1844 | + | mock.Mock.Test(t) | |||
| 1845 | + | ||||
| 1846 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1847 | + | ||||
| 1848 | + | return mock | |||
| 1849 | + | } | |||
| 1850 | + | ||||
| 1851 | + | // MockClipboardReader is an autogenerated mock type for the ClipboardReader type | |||
| 1852 | + | type MockClipboardReader struct { | |||
| 1853 | + | mock.Mock | |||
| 1854 | + | } | |||
| 1855 | + | ||||
| 1856 | + | type MockClipboardReader_Expecter struct { | |||
| 1857 | + | mock *mock.Mock | |||
| 1858 | + | } | |||
| 1859 | + | ||||
| 1860 | + | func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter { | |||
| 1861 | + | return &MockClipboardReader_Expecter{mock: &_m.Mock} | |||
| 1862 | + | } | |||
| 1863 | + | ||||
| 1864 | + | // ReadImage provides a mock function for the type MockClipboardReader | |||
| 1865 | + | func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) { | |||
| 1866 | + | ret := _mock.Called(ctx) | |||
| 1867 | + | ||||
| 1868 | + | if len(ret) == 0 { | |||
| 1869 | + | panic("no return value specified for ReadImage") | |||
| 1870 | + | } | |||
| 1871 | + | ||||
| 1872 | + | var r0 string | |||
| 1873 | + | var r1 []byte | |||
| 1874 | + | var r2 error | |||
| 1875 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok { | |||
| 1876 | + | return returnFunc(ctx) | |||
| 1877 | + | } | |||
| 1878 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok { | |||
| 1879 | + | r0 = returnFunc(ctx) | |||
| 1880 | + | } else { | |||
| 1881 | + | r0 = ret.Get(0).(string) | |||
| 1882 | + | } | |||
| 1883 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok { | |||
| 1884 | + | r1 = returnFunc(ctx) | |||
| 1885 | + | } else { | |||
| 1886 | + | if ret.Get(1) != nil { | |||
| 1887 | + | r1 = ret.Get(1).([]byte) | |||
| 1888 | + | } | |||
| 1889 | + | } | |||
| 1890 | + | if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok { | |||
| 1891 | + | r2 = returnFunc(ctx) | |||
| 1892 | + | } else { | |||
| 1893 | + | r2 = ret.Error(2) | |||
| 1894 | + | } | |||
| 1895 | + | return r0, r1, r2 | |||
| 1896 | + | } | |||
| 1897 | + | ||||
| 1898 | + | // MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage' | |||
| 1899 | + | type MockClipboardReader_ReadImage_Call struct { | |||
| 1900 | + | *mock.Call | |||
| 1901 | + | } | |||
| 1902 | + | ||||
| 1903 | + | // ReadImage is a helper method to define mock.On call | |||
| 1904 | + | // - ctx context.Context | |||
| 1905 | + | func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call { | |||
| 1906 | + | return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)} | |||
| 1907 | + | } | |||
| 1908 | + | ||||
| 1909 | + | func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call { | |||
| 1910 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1911 | + | var arg0 context.Context | |||
| 1912 | + | if args[0] != nil { | |||
| 1913 | + | arg0 = args[0].(context.Context) | |||
| 1914 | + | } | |||
| 1915 | + | run( | |||
| 1916 | + | arg0, | |||
| 1917 | + | ) | |||
| 1918 | + | }) | |||
| 1919 | + | return _c | |||
| 1920 | + | } | |||
| 1921 | + | ||||
| 1922 | + | func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call { | |||
| 1923 | + | _c.Call.Return(mime, data, err) | |||
| 1924 | + | return _c | |||
| 1925 | + | } | |||
| 1926 | + | ||||
| 1927 | + | func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call { | |||
| 1928 | + | _c.Call.Return(run) | |||
| 1929 | + | return _c | |||
| 1930 | + | } | |||
M internal/ports/ports.go +18 -0
| @@ -2,12 +2,18 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | // ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the | |
| 13 | + | // clipboard holds no image (or an unsupported type). Callers fall back to | |
| 14 | + | // forwarding whatever keystroke triggered the read. | |
| 15 | + | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | |
| 16 | + | ||
| 11 | 17 | // PTY is a running pseudo-terminal-backed child process. | |
| 12 | 18 | type PTY interface { | |
| 13 | 19 | io.ReadWriteCloser // Read = child output, Write = child input | |
| @@ -75,3 +81,15 @@ type Timer interface { | |||
| 75 | 81 | Reset(d time.Duration) bool | |
| 76 | 82 | Stop() bool | |
| 77 | 83 | } | |
| 84 | + | ||
| 85 | + | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |
| 86 | + | // forwarding to a remote session's focused pane. Implementations live in | |
| 87 | + | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |
| 88 | + | // see this interface. See | |
| 89 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md. | |
| 90 | + | type ClipboardReader interface { | |
| 91 | + | // ReadImage returns the clipboard's image bytes and MIME type. It returns | |
| 92 | + | // ErrNoClipboardImage when the clipboard holds no image (or an | |
| 93 | + | // unsupported type). | |
| 94 | + | ReadImage(ctx context.Context) (mime string, data []byte, err error) | |
| 95 | + | } | |
| @@ -2,12 +2,18 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | // ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the | |||
| 13 | + | // clipboard holds no image (or an unsupported type). Callers fall back to | |||
| 14 | + | // forwarding whatever keystroke triggered the read. | |||
| 15 | + | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | |||
| 16 | + | ||||
| 11 | // PTY is a running pseudo-terminal-backed child process. | 17 | // PTY is a running pseudo-terminal-backed child process. | ||
| 12 | type PTY interface { | 18 | type PTY interface { | ||
| 13 | io.ReadWriteCloser // Read = child output, Write = child input | 19 | io.ReadWriteCloser // Read = child output, Write = child input | ||
| @@ -75,3 +81,15 @@ type Timer interface { | |||||
| 75 | Reset(d time.Duration) bool | 81 | Reset(d time.Duration) bool | ||
| 76 | Stop() bool | 82 | Stop() bool | ||
| 77 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |||
| 86 | + | // forwarding to a remote session's focused pane. Implementations live in | |||
| 87 | + | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |||
| 88 | + | // see this interface. See | |||
| 89 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md. | |||
| 90 | + | type ClipboardReader interface { | |||
| 91 | + | // ReadImage returns the clipboard's image bytes and MIME type. It returns | |||
| 92 | + | // ErrNoClipboardImage when the clipboard holds no image (or an | |||
| 93 | + | // unsupported type). | |||
| 94 | + | ReadImage(ctx context.Context) (mime string, data []byte, err error) | |||
| 95 | + | } | |||
M internal/ports/wire.go +34 -0
| @@ -87,6 +87,15 @@ type Theme struct { | |||
| 87 | 87 | TrueColor bool | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | // ImagePush carries a clipboard image from a remote client, to be written to | |
| 91 | + | // a temp file and injected into the focused pane's PTY as a path (see | |
| 92 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 93 | + | type ImagePush struct { | |
| 94 | + | InputSeq uint64 | |
| 95 | + | Mime string | |
| 96 | + | Data []byte | |
| 97 | + | } | |
| 98 | + | ||
| 90 | 99 | // Detach asks the daemon to detach the current client without killing the | |
| 91 | 100 | // session. | |
| 92 | 101 | type Detach struct{} | |
| @@ -357,6 +366,31 @@ func UnmarshalInput(b []byte) (Input, error) { | |||
| 357 | 366 | return Input{InputSeq: seq, Data: r.rest()}, nil | |
| 358 | 367 | } | |
| 359 | 368 | ||
| 369 | + | // MarshalImagePush encodes m into an ImagePush message payload. | |
| 370 | + | func MarshalImagePush(m ImagePush) []byte { | |
| 371 | + | w := payloadWriter{} | |
| 372 | + | w.putUint64(m.InputSeq) | |
| 373 | + | w.putString(m.Mime) | |
| 374 | + | w.putBytes(m.Data) | |
| 375 | + | return w.b | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // UnmarshalImagePush decodes an ImagePush message payload. After the fixed | |
| 379 | + | // input sequence and length-prefixed mime string, the rest of the payload is | |
| 380 | + | // data; there is no length prefix for it. | |
| 381 | + | func UnmarshalImagePush(b []byte) (ImagePush, error) { | |
| 382 | + | r := payloadReader{b: b} | |
| 383 | + | seq, err := r.getUint64() | |
| 384 | + | if err != nil { | |
| 385 | + | return ImagePush{}, err | |
| 386 | + | } | |
| 387 | + | mime, err := r.getString() | |
| 388 | + | if err != nil { | |
| 389 | + | return ImagePush{}, err | |
| 390 | + | } | |
| 391 | + | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | |
| 392 | + | } | |
| 393 | + | ||
| 360 | 394 | // MarshalResize encodes m into a Resize message payload. | |
| 361 | 395 | func MarshalResize(m Resize) []byte { | |
| 362 | 396 | w := payloadWriter{} | |
| @@ -87,6 +87,15 @@ type Theme struct { | |||||
| 87 | TrueColor bool | 87 | TrueColor bool | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | // ImagePush carries a clipboard image from a remote client, to be written to | |||
| 91 | + | // a temp file and injected into the focused pane's PTY as a path (see | |||
| 92 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |||
| 93 | + | type ImagePush struct { | |||
| 94 | + | InputSeq uint64 | |||
| 95 | + | Mime string | |||
| 96 | + | Data []byte | |||
| 97 | + | } | |||
| 98 | + | ||||
| 90 | // Detach asks the daemon to detach the current client without killing the | 99 | // Detach asks the daemon to detach the current client without killing the | ||
| 91 | // session. | 100 | // session. | ||
| 92 | type Detach struct{} | 101 | type Detach struct{} | ||
| @@ -357,6 +366,31 @@ func UnmarshalInput(b []byte) (Input, error) { | |||||
| 357 | return Input{InputSeq: seq, Data: r.rest()}, nil | 366 | return Input{InputSeq: seq, Data: r.rest()}, nil | ||
| 358 | } | 367 | } | ||
| 359 | 368 | ||||
| 369 | + | // MarshalImagePush encodes m into an ImagePush message payload. | |||
| 370 | + | func MarshalImagePush(m ImagePush) []byte { | |||
| 371 | + | w := payloadWriter{} | |||
| 372 | + | w.putUint64(m.InputSeq) | |||
| 373 | + | w.putString(m.Mime) | |||
| 374 | + | w.putBytes(m.Data) | |||
| 375 | + | return w.b | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // UnmarshalImagePush decodes an ImagePush message payload. After the fixed | |||
| 379 | + | // input sequence and length-prefixed mime string, the rest of the payload is | |||
| 380 | + | // data; there is no length prefix for it. | |||
| 381 | + | func UnmarshalImagePush(b []byte) (ImagePush, error) { | |||
| 382 | + | r := payloadReader{b: b} | |||
| 383 | + | seq, err := r.getUint64() | |||
| 384 | + | if err != nil { | |||
| 385 | + | return ImagePush{}, err | |||
| 386 | + | } | |||
| 387 | + | mime, err := r.getString() | |||
| 388 | + | if err != nil { | |||
| 389 | + | return ImagePush{}, err | |||
| 390 | + | } | |||
| 391 | + | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | |||
| 392 | + | } | |||
| 393 | + | ||||
| 360 | // MarshalResize encodes m into a Resize message payload. | 394 | // MarshalResize encodes m into a Resize message payload. | ||
| 361 | func MarshalResize(m Resize) []byte { | 395 | func MarshalResize(m Resize) []byte { | ||
| 362 | w := payloadWriter{} | 396 | w := payloadWriter{} | ||
M internal/ports/wire_test.go +34 -0
| @@ -140,6 +140,40 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 140 | 140 | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |
| 144 | + | tests := []struct { | |
| 145 | + | name string | |
| 146 | + | msg ImagePush | |
| 147 | + | want []byte | |
| 148 | + | }{ | |
| 149 | + | { | |
| 150 | + | name: "png data", | |
| 151 | + | msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, | |
| 152 | + | want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03}, | |
| 153 | + | }, | |
| 154 | + | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | for _, tt := range tests { | |
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |
| 159 | + | got := MarshalImagePush(tt.msg) | |
| 160 | + | if !bytes.Equal(got, tt.want) { | |
| 161 | + | t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want) | |
| 162 | + | } | |
| 163 | + | back, err := UnmarshalImagePush(got) | |
| 164 | + | if err != nil { | |
| 165 | + | t.Fatalf("UnmarshalImagePush() error = %v", err) | |
| 166 | + | } | |
| 167 | + | if !reflect.DeepEqual(back, tt.msg) { | |
| 168 | + | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |
| 169 | + | } | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | full := MarshalImagePush(tests[0].msg) | |
| 174 | + | assertAllPrefixesFail(t, full[:19], UnmarshalImagePush) | |
| 175 | + | } | |
| 176 | + | ||
| 143 | 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| 144 | 178 | tests := []struct { | |
| 145 | 179 | name string | |
| @@ -140,6 +140,40 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||||
| 140 | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | 140 | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||
| 144 | + | tests := []struct { | |||
| 145 | + | name string | |||
| 146 | + | msg ImagePush | |||
| 147 | + | want []byte | |||
| 148 | + | }{ | |||
| 149 | + | { | |||
| 150 | + | name: "png data", | |||
| 151 | + | msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, | |||
| 152 | + | want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03}, | |||
| 153 | + | }, | |||
| 154 | + | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | for _, tt := range tests { | |||
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | + | got := MarshalImagePush(tt.msg) | |||
| 160 | + | if !bytes.Equal(got, tt.want) { | |||
| 161 | + | t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want) | |||
| 162 | + | } | |||
| 163 | + | back, err := UnmarshalImagePush(got) | |||
| 164 | + | if err != nil { | |||
| 165 | + | t.Fatalf("UnmarshalImagePush() error = %v", err) | |||
| 166 | + | } | |||
| 167 | + | if !reflect.DeepEqual(back, tt.msg) { | |||
| 168 | + | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |||
| 169 | + | } | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | full := MarshalImagePush(tests[0].msg) | |||
| 174 | + | assertAllPrefixesFail(t, full[:19], UnmarshalImagePush) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 143 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | ||
| 144 | tests := []struct { | 178 | tests := []struct { | ||
| 145 | name string | 179 | name string | ||
M internal/usecase/client/client.go +38 -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, log *slog.Logger) (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, log *slog.Logger) (retErr error) { | |
| 123 | 130 | if log == nil { | |
| 124 | 131 | log = slog.Default() | |
| 125 | 132 | } | |
| @@ -192,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 192 | 199 | } | |
| 193 | 200 | ms.dialed = true | |
| 194 | 201 | ||
| 195 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log) | |
| 202 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log) | |
| 196 | 203 | if result.welcomed { | |
| 197 | 204 | backoff = defaultReconnectBackoff.initial | |
| 198 | 205 | } | |
| @@ -255,7 +262,7 @@ func shouldReconnect(err error) bool { | |||
| 255 | 262 | // | |
| 256 | 263 | // It is kept as a compatibility wrapper for callers that still own dialing. | |
| 257 | 264 | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | |
| 258 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, slog.Default()) | |
| 265 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default()) | |
| 259 | 266 | } | |
| 260 | 267 | ||
| 261 | 268 | type singleTransportDialer struct{ transport ports.Transport } | |
| @@ -270,7 +277,7 @@ type attachResult struct { | |||
| 270 | 277 | err error | |
| 271 | 278 | } | |
| 272 | 279 | ||
| 273 | - | 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(), log *slog.Logger) attachResult { | |
| 280 | + | 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, log *slog.Logger) attachResult { | |
| 274 | 281 | // 1. Handshake: send Hello with our size and TERM. | |
| 275 | 282 | size, err := term.Size() | |
| 276 | 283 | if err != nil { | |
| @@ -336,8 +343,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 336 | 343 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 337 | 344 | sendErrCh := make(chan error, 1) | |
| 338 | 345 | ||
| 346 | + | var clip ports.ClipboardReader | |
| 347 | + | if remote { | |
| 348 | + | clip = clipboard | |
| 349 | + | } | |
| 339 | 350 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | |
| 340 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, log) | |
| 351 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log) | |
| 341 | 352 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 342 | 353 | ||
| 343 | 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -461,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 461 | 472 | // exits. That is harmless here — Attach has already returned and restored | |
| 462 | 473 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 463 | 474 | // closable stdin duplicate could lift it later if ever needed. | |
| 464 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, log *slog.Logger) { | |
| 475 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 465 | 476 | defer log.Debug("stdin pump exited") | |
| 466 | 477 | buf := make([]byte, stdinBufSize) | |
| 467 | 478 | var scanner theme.Scanner | |
| @@ -488,6 +499,26 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 488 | 499 | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |
| 489 | 500 | }) | |
| 490 | 501 | defer coalescer.Close() | |
| 502 | + | ||
| 503 | + | // On a remote attach with a ClipboardReader configured, splice in the | |
| 504 | + | // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16) | |
| 505 | + | // becomes a clipboard image push instead of reaching the coalescer (and | |
| 506 | + | // from there the remote pane) as an ordinary keystroke. | |
| 507 | + | sink := coalescer.Scan | |
| 508 | + | if clipboard != nil { | |
| 509 | + | ci := &clipboardIntercept{ | |
| 510 | + | coalescer: coalescer, | |
| 511 | + | reader: clipboard, | |
| 512 | + | log: log, | |
| 513 | + | sendImage: func(mime string, data []byte) { | |
| 514 | + | inputSeq++ | |
| 515 | + | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | |
| 516 | + | }, | |
| 517 | + | next: coalescer.Scan, | |
| 518 | + | } | |
| 519 | + | sink = ci.Scan | |
| 520 | + | } | |
| 521 | + | ||
| 491 | 522 | for { | |
| 492 | 523 | n, rerr := in.Read(buf) | |
| 493 | 524 | if n > 0 { | |
| @@ -503,7 +534,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 503 | 534 | return | |
| 504 | 535 | } | |
| 505 | 536 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 506 | - | }, coalescer.Scan) | |
| 537 | + | }, sink) | |
| 507 | 538 | if !sendOK.Load() { | |
| 508 | 539 | return | |
| 509 | 540 | } | |
| @@ -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, log *slog.Logger) (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, log *slog.Logger) (retErr error) { | |||
| 123 | if log == nil { | 130 | if log == nil { | ||
| 124 | log = slog.Default() | 131 | log = slog.Default() | ||
| 125 | } | 132 | } | ||
| @@ -192,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 192 | } | 199 | } | ||
| 193 | ms.dialed = true | 200 | ms.dialed = true | ||
| 194 | 201 | ||||
| 195 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log) | 202 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log) |
| 196 | if result.welcomed { | 203 | if result.welcomed { | ||
| 197 | backoff = defaultReconnectBackoff.initial | 204 | backoff = defaultReconnectBackoff.initial | ||
| 198 | } | 205 | } | ||
| @@ -255,7 +262,7 @@ func shouldReconnect(err error) bool { | |||||
| 255 | // | 262 | // | ||
| 256 | // It is kept as a compatibility wrapper for callers that still own dialing. | 263 | // It is kept as a compatibility wrapper for callers that still own dialing. | ||
| 257 | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | 264 | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | ||
| 258 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, slog.Default()) | 265 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default()) |
| 259 | } | 266 | } | ||
| 260 | 267 | ||||
| 261 | type singleTransportDialer struct{ transport ports.Transport } | 268 | type singleTransportDialer struct{ transport ports.Transport } | ||
| @@ -270,7 +277,7 @@ type attachResult struct { | |||||
| 270 | err error | 277 | err error | ||
| 271 | } | 278 | } | ||
| 272 | 279 | ||||
| 273 | - | 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(), log *slog.Logger) attachResult { | 280 | + | 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, log *slog.Logger) attachResult { |
| 274 | // 1. Handshake: send Hello with our size and TERM. | 281 | // 1. Handshake: send Hello with our size and TERM. | ||
| 275 | size, err := term.Size() | 282 | size, err := term.Size() | ||
| 276 | if err != nil { | 283 | if err != nil { | ||
| @@ -336,8 +343,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 336 | sendCh := make(chan ports.Frame, sendQueueDepth) | 343 | sendCh := make(chan ports.Frame, sendQueueDepth) | ||
| 337 | sendErrCh := make(chan error, 1) | 344 | sendErrCh := make(chan error, 1) | ||
| 338 | 345 | ||||
| 346 | + | var clip ports.ClipboardReader | |||
| 347 | + | if remote { | |||
| 348 | + | clip = clipboard | |||
| 349 | + | } | |||
| 339 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | 350 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | ||
| 340 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, log) | 351 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log) |
| 341 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | 352 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | ||
| 342 | 353 | ||||
| 343 | // 5. Output/main loop: the only goroutine that touches the terminal. | 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -461,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 461 | // exits. That is harmless here — Attach has already returned and restored | 472 | // exits. That is harmless here — Attach has already returned and restored | ||
| 462 | // the terminal — and matches the standard pattern for stdin pumps; a | 473 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 463 | // closable stdin duplicate could lift it later if ever needed. | 474 | // closable stdin duplicate could lift it later if ever needed. | ||
| 464 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, log *slog.Logger) { | 475 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) { |
| 465 | defer log.Debug("stdin pump exited") | 476 | defer log.Debug("stdin pump exited") | ||
| 466 | buf := make([]byte, stdinBufSize) | 477 | buf := make([]byte, stdinBufSize) | ||
| 467 | var scanner theme.Scanner | 478 | var scanner theme.Scanner | ||
| @@ -488,6 +499,26 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 488 | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | 499 | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | ||
| 489 | }) | 500 | }) | ||
| 490 | defer coalescer.Close() | 501 | defer coalescer.Close() | ||
| 502 | + | ||||
| 503 | + | // On a remote attach with a ClipboardReader configured, splice in the | |||
| 504 | + | // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16) | |||
| 505 | + | // becomes a clipboard image push instead of reaching the coalescer (and | |||
| 506 | + | // from there the remote pane) as an ordinary keystroke. | |||
| 507 | + | sink := coalescer.Scan | |||
| 508 | + | if clipboard != nil { | |||
| 509 | + | ci := &clipboardIntercept{ | |||
| 510 | + | coalescer: coalescer, | |||
| 511 | + | reader: clipboard, | |||
| 512 | + | log: log, | |||
| 513 | + | sendImage: func(mime string, data []byte) { | |||
| 514 | + | inputSeq++ | |||
| 515 | + | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | |||
| 516 | + | }, | |||
| 517 | + | next: coalescer.Scan, | |||
| 518 | + | } | |||
| 519 | + | sink = ci.Scan | |||
| 520 | + | } | |||
| 521 | + | ||||
| 491 | for { | 522 | for { | ||
| 492 | n, rerr := in.Read(buf) | 523 | n, rerr := in.Read(buf) | ||
| 493 | if n > 0 { | 524 | if n > 0 { | ||
| @@ -503,7 +534,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 503 | return | 534 | return | ||
| 504 | } | 535 | } | ||
| 505 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 536 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | ||
| 506 | - | }, coalescer.Scan) | 537 | + | }, sink) |
| 507 | if !sendOK.Load() { | 538 | if !sendOK.Load() { | ||
| 508 | return | 539 | return | ||
| 509 | } | 540 | } | ||
M internal/usecase/client/client_test.go +5 -5
| @@ -650,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 650 | 650 | d := portsmocks.NewMockDialer(t) | |
| 651 | 651 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 652 | 652 | ||
| 653 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler)) | |
| 653 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler)) | |
| 654 | 654 | require.Error(t, err) | |
| 655 | 655 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | |
| 656 | 656 | } | |
| @@ -670,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 670 | 670 | d := portsmocks.NewMockDialer(t) | |
| 671 | 671 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 672 | 672 | ||
| 673 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler)) | |
| 673 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) | |
| 674 | 674 | require.Error(t, err) | |
| 675 | 675 | var pe *client.ProtocolError | |
| 676 | 676 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -682,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||
| 682 | 682 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | |
| 683 | 683 | tm := portsmocks.NewMockTerminal(t) | |
| 684 | 684 | ||
| 685 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler)) | |
| 685 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler)) | |
| 686 | 686 | require.ErrorIs(t, err, dialErr) | |
| 687 | 687 | } | |
| 688 | 688 | ||
| @@ -771,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 771 | 771 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |
| 772 | 772 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | |
| 773 | 773 | ||
| 774 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler)) | |
| 774 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) | |
| 775 | 775 | require.NoError(t, err) | |
| 776 | 776 | require.Equal(t, int32(3), d.calls.Load()) | |
| 777 | 777 | require.Equal(t, int32(1), term.rawCount.Load()) | |
| @@ -798,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 798 | 798 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |
| 799 | 799 | d := &sequenceDialer{trs: []ports.Transport{tr}} | |
| 800 | 800 | ||
| 801 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler)) | |
| 801 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) | |
| 802 | 802 | require.Error(t, err) | |
| 803 | 803 | var de *client.DetachedError | |
| 804 | 804 | require.True(t, errors.As(err, &de)) | |
| @@ -650,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||||
| 650 | d := portsmocks.NewMockDialer(t) | 650 | d := portsmocks.NewMockDialer(t) | ||
| 651 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | 651 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | ||
| 652 | 652 | ||||
| 653 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler)) | 653 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler)) |
| 654 | require.Error(t, err) | 654 | require.Error(t, err) | ||
| 655 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | 655 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | ||
| 656 | } | 656 | } | ||
| @@ -670,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||||
| 670 | d := portsmocks.NewMockDialer(t) | 670 | d := portsmocks.NewMockDialer(t) | ||
| 671 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | 671 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | ||
| 672 | 672 | ||||
| 673 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler)) | 673 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) |
| 674 | require.Error(t, err) | 674 | require.Error(t, err) | ||
| 675 | var pe *client.ProtocolError | 675 | var pe *client.ProtocolError | ||
| 676 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | 676 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | ||
| @@ -682,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||||
| 682 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | 682 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | ||
| 683 | tm := portsmocks.NewMockTerminal(t) | 683 | tm := portsmocks.NewMockTerminal(t) | ||
| 684 | 684 | ||||
| 685 | - | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler)) | 685 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler)) |
| 686 | require.ErrorIs(t, err, dialErr) | 686 | require.ErrorIs(t, err, dialErr) | ||
| 687 | } | 687 | } | ||
| 688 | 688 | ||||
| @@ -771,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||||
| 771 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | 771 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | ||
| 772 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | 772 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | ||
| 773 | 773 | ||||
| 774 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler)) | 774 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) |
| 775 | require.NoError(t, err) | 775 | require.NoError(t, err) | ||
| 776 | require.Equal(t, int32(3), d.calls.Load()) | 776 | require.Equal(t, int32(3), d.calls.Load()) | ||
| 777 | require.Equal(t, int32(1), term.rawCount.Load()) | 777 | require.Equal(t, int32(1), term.rawCount.Load()) | ||
| @@ -798,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||||
| 798 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | 798 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | ||
| 799 | d := &sequenceDialer{trs: []ports.Transport{tr}} | 799 | d := &sequenceDialer{trs: []ports.Transport{tr}} | ||
| 800 | 800 | ||||
| 801 | - | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler)) | 801 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) |
| 802 | require.Error(t, err) | 802 | require.Error(t, err) | ||
| 803 | var de *client.DetachedError | 803 | var de *client.DetachedError | ||
| 804 | require.True(t, errors.As(err, &de)) | 804 | 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. The 1 MiB cap fits | |
| 17 | + | // both sshstdio and the datagram transport's fragmented payload ceiling. | |
| 18 | + | const maxClipboardImagePush = 1 << 20 // 1 MiB | |
| 19 | + | ||
| 20 | + | // clipboardIntercept sits between the terminal's theme scanner and the paste | |
| 21 | + | // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V | |
| 22 | + | // (0x16): each occurrence triggers a clipboard image read, sent as one | |
| 23 | + | // MsgImagePush frame instead of the keystroke. Everything else — ordinary | |
| 24 | + | // bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted | |
| 25 | + | // text may legitimately contain it) — is forwarded to next unchanged, so it | |
| 26 | + | // still gets the coalescer's marker-splitting treatment. | |
| 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, ports.BracketedPasteOpenMarker); 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. The 1 MiB cap fits | |||
| 17 | + | // both sshstdio and the datagram transport's fragmented payload ceiling. | |||
| 18 | + | const maxClipboardImagePush = 1 << 20 // 1 MiB | |||
| 19 | + | ||||
| 20 | + | // clipboardIntercept sits between the terminal's theme scanner and the paste | |||
| 21 | + | // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V | |||
| 22 | + | // (0x16): each occurrence triggers a clipboard image read, sent as one | |||
| 23 | + | // MsgImagePush frame instead of the keystroke. Everything else — ordinary | |||
| 24 | + | // bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted | |||
| 25 | + | // text may legitimately contain it) — is forwarded to next unchanged, so it | |||
| 26 | + | // still gets the coalescer's marker-splitting treatment. | |||
| 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, ports.BracketedPasteOpenMarker); 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 +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // clipboardRunTestDeps wires a Run() attempt with an input reader that | |
| 21 | + | // delivers data then blocks (so the pump stays parked until detach), a | |
| 22 | + | // transport that captures every MsgInput/MsgImagePush send, and a Recv that | |
| 23 | + | // answers Welcome then, once triggered, Detached — mirroring the pattern used | |
| 24 | + | // by the OSC-color-response test elsewhere in this package. | |
| 25 | + | func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) { | |
| 26 | + | t.Helper() | |
| 27 | + | var out bytes.Buffer | |
| 28 | + | var restoreCount int32 | |
| 29 | + | resizeCh := make(chan domain.Size) | |
| 30 | + | input := newOneShotBlockingReader(stdin) | |
| 31 | + | defer input.unblock() | |
| 32 | + | ||
| 33 | + | tm := portsmocks.NewMockTerminal(t) | |
| 34 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 35 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | |
| 36 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 37 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 38 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 39 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 40 | + | ||
| 41 | + | gotInput = make(chan []byte, 8) | |
| 42 | + | gotImage = make(chan ports.ImagePush, 1) | |
| 43 | + | allowDetach := make(chan struct{}) | |
| 44 | + | ||
| 45 | + | tr := portsmocks.NewMockTransport(t) | |
| 46 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 47 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 48 | + | in, err := ports.UnmarshalInput(f.Payload) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | gotInput <- append([]byte(nil), in.Data...) | |
| 51 | + | if bytes.IndexByte(in.Data, triggerByte) >= 0 { | |
| 52 | + | closeOnce(allowDetach) | |
| 53 | + | } | |
| 54 | + | return nil | |
| 55 | + | }).Maybe() | |
| 56 | + | tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error { | |
| 57 | + | ip, err := ports.UnmarshalImagePush(f.Payload) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | gotImage <- ip | |
| 60 | + | return nil | |
| 61 | + | }).Maybe() | |
| 62 | + | ||
| 63 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 64 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 65 | + | recvCh := make(chan recvItem, 1) | |
| 66 | + | recvCh <- recvItem{f: welcome} | |
| 67 | + | closed := make(chan struct{}) | |
| 68 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 69 | + | select { | |
| 70 | + | case it := <-recvCh: | |
| 71 | + | return it.f, it.err | |
| 72 | + | case <-allowDetach: | |
| 73 | + | select { | |
| 74 | + | case <-closed: | |
| 75 | + | return ports.Frame{}, io.EOF | |
| 76 | + | default: | |
| 77 | + | close(closed) | |
| 78 | + | return detached, nil | |
| 79 | + | } | |
| 80 | + | case <-closed: | |
| 81 | + | return ports.Frame{}, io.EOF | |
| 82 | + | } | |
| 83 | + | }).Maybe() | |
| 84 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 85 | + | ||
| 86 | + | d := portsmocks.NewMockDialer(t) | |
| 87 | + | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 88 | + | ||
| 89 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler)) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | return gotInput, gotImage | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func closeOnce(ch chan struct{}) { | |
| 95 | + | select { | |
| 96 | + | case <-ch: | |
| 97 | + | default: | |
| 98 | + | close(ch) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func drainInput(gotInput chan []byte) []byte { | |
| 103 | + | var b []byte | |
| 104 | + | for { | |
| 105 | + | select { | |
| 106 | + | case chunk := <-gotInput: | |
| 107 | + | b = append(b, chunk...) | |
| 108 | + | default: | |
| 109 | + | return b | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) { | |
| 115 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 116 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once() | |
| 117 | + | ||
| 118 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |
| 119 | + | ||
| 120 | + | select { | |
| 121 | + | case ip := <-gotImage: | |
| 122 | + | require.Equal(t, uint64(2), ip.InputSeq) | |
| 123 | + | require.Equal(t, "image/png", ip.Mime) | |
| 124 | + | require.Equal(t, []byte("PNGDATA"), ip.Data) | |
| 125 | + | case <-time.After(2 * time.Second): | |
| 126 | + | t.Fatal("MsgImagePush was not sent") | |
| 127 | + | } | |
| 128 | + | require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |
| 132 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 133 | + | clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once() | |
| 134 | + | ||
| 135 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |
| 136 | + | ||
| 137 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |
| 141 | + | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap | |
| 142 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 143 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | |
| 144 | + | ||
| 145 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |
| 146 | + | ||
| 147 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) { | |
| 151 | + | // A MockClipboardReader with no .EXPECT() set fails the test if | |
| 152 | + | // ReadImage is ever called — which must never happen for a 0x16 that | |
| 153 | + | // arrives as part of pasted content, even when the whole paste (open | |
| 154 | + | // marker, content, close marker) lands in a single read alongside it. | |
| 155 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 156 | + | ||
| 157 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z") | |
| 158 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z') | |
| 159 | + | ||
| 160 | + | require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit") | |
| 161 | + | select { | |
| 162 | + | case ip := <-gotImage: | |
| 163 | + | t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip) | |
| 164 | + | default: | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) { | |
| 169 | + | // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage | |
| 170 | + | // is ever called, which is exactly what a local (non-remote) attach must | |
| 171 | + | // never do. | |
| 172 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 173 | + | ||
| 174 | + | gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b') | |
| 175 | + | ||
| 176 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched") | |
| 177 | + | } | |
| @@ -0,0 +1,177 @@ | |||||
| 1 | + | package client_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // clipboardRunTestDeps wires a Run() attempt with an input reader that | |||
| 21 | + | // delivers data then blocks (so the pump stays parked until detach), a | |||
| 22 | + | // transport that captures every MsgInput/MsgImagePush send, and a Recv that | |||
| 23 | + | // answers Welcome then, once triggered, Detached — mirroring the pattern used | |||
| 24 | + | // by the OSC-color-response test elsewhere in this package. | |||
| 25 | + | func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) { | |||
| 26 | + | t.Helper() | |||
| 27 | + | var out bytes.Buffer | |||
| 28 | + | var restoreCount int32 | |||
| 29 | + | resizeCh := make(chan domain.Size) | |||
| 30 | + | input := newOneShotBlockingReader(stdin) | |||
| 31 | + | defer input.unblock() | |||
| 32 | + | ||||
| 33 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 34 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 35 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | |||
| 36 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 37 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 38 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 39 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 40 | + | ||||
| 41 | + | gotInput = make(chan []byte, 8) | |||
| 42 | + | gotImage = make(chan ports.ImagePush, 1) | |||
| 43 | + | allowDetach := make(chan struct{}) | |||
| 44 | + | ||||
| 45 | + | tr := portsmocks.NewMockTransport(t) | |||
| 46 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 47 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |||
| 48 | + | in, err := ports.UnmarshalInput(f.Payload) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | gotInput <- append([]byte(nil), in.Data...) | |||
| 51 | + | if bytes.IndexByte(in.Data, triggerByte) >= 0 { | |||
| 52 | + | closeOnce(allowDetach) | |||
| 53 | + | } | |||
| 54 | + | return nil | |||
| 55 | + | }).Maybe() | |||
| 56 | + | tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error { | |||
| 57 | + | ip, err := ports.UnmarshalImagePush(f.Payload) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | gotImage <- ip | |||
| 60 | + | return nil | |||
| 61 | + | }).Maybe() | |||
| 62 | + | ||||
| 63 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 64 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 65 | + | recvCh := make(chan recvItem, 1) | |||
| 66 | + | recvCh <- recvItem{f: welcome} | |||
| 67 | + | closed := make(chan struct{}) | |||
| 68 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 69 | + | select { | |||
| 70 | + | case it := <-recvCh: | |||
| 71 | + | return it.f, it.err | |||
| 72 | + | case <-allowDetach: | |||
| 73 | + | select { | |||
| 74 | + | case <-closed: | |||
| 75 | + | return ports.Frame{}, io.EOF | |||
| 76 | + | default: | |||
| 77 | + | close(closed) | |||
| 78 | + | return detached, nil | |||
| 79 | + | } | |||
| 80 | + | case <-closed: | |||
| 81 | + | return ports.Frame{}, io.EOF | |||
| 82 | + | } | |||
| 83 | + | }).Maybe() | |||
| 84 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 85 | + | ||||
| 86 | + | d := portsmocks.NewMockDialer(t) | |||
| 87 | + | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |||
| 88 | + | ||||
| 89 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler)) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | return gotInput, gotImage | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func closeOnce(ch chan struct{}) { | |||
| 95 | + | select { | |||
| 96 | + | case <-ch: | |||
| 97 | + | default: | |||
| 98 | + | close(ch) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func drainInput(gotInput chan []byte) []byte { | |||
| 103 | + | var b []byte | |||
| 104 | + | for { | |||
| 105 | + | select { | |||
| 106 | + | case chunk := <-gotInput: | |||
| 107 | + | b = append(b, chunk...) | |||
| 108 | + | default: | |||
| 109 | + | return b | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) { | |||
| 115 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 116 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once() | |||
| 117 | + | ||||
| 118 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |||
| 119 | + | ||||
| 120 | + | select { | |||
| 121 | + | case ip := <-gotImage: | |||
| 122 | + | require.Equal(t, uint64(2), ip.InputSeq) | |||
| 123 | + | require.Equal(t, "image/png", ip.Mime) | |||
| 124 | + | require.Equal(t, []byte("PNGDATA"), ip.Data) | |||
| 125 | + | case <-time.After(2 * time.Second): | |||
| 126 | + | t.Fatal("MsgImagePush was not sent") | |||
| 127 | + | } | |||
| 128 | + | require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |||
| 132 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 133 | + | clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once() | |||
| 134 | + | ||||
| 135 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |||
| 136 | + | ||||
| 137 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |||
| 141 | + | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap | |||
| 142 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 143 | + | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | |||
| 144 | + | ||||
| 145 | + | gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b') | |||
| 146 | + | ||||
| 147 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead") | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) { | |||
| 151 | + | // A MockClipboardReader with no .EXPECT() set fails the test if | |||
| 152 | + | // ReadImage is ever called — which must never happen for a 0x16 that | |||
| 153 | + | // arrives as part of pasted content, even when the whole paste (open | |||
| 154 | + | // marker, content, close marker) lands in a single read alongside it. | |||
| 155 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 156 | + | ||||
| 157 | + | paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z") | |||
| 158 | + | gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z') | |||
| 159 | + | ||||
| 160 | + | require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit") | |||
| 161 | + | select { | |||
| 162 | + | case ip := <-gotImage: | |||
| 163 | + | t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip) | |||
| 164 | + | default: | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) { | |||
| 169 | + | // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage | |||
| 170 | + | // is ever called, which is exactly what a local (non-remote) attach must | |||
| 171 | + | // never do. | |||
| 172 | + | clip := portsmocks.NewMockClipboardReader(t) | |||
| 173 | + | ||||
| 174 | + | gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b') | |||
| 175 | + | ||||
| 176 | + | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched") | |||
| 177 | + | } | |||
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
| @@ -486,6 +486,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 486 | 486 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 487 | 487 | d.applyTheme(sess, ac, th) | |
| 488 | 488 | } | |
| 489 | + | case ports.MsgImagePush: | |
| 490 | + | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 491 | + | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | |
| 492 | + | } | |
| 489 | 493 | case ports.MsgDetach: | |
| 490 | 494 | d.clientGone(sess, ac, tr, true) | |
| 491 | 495 | return | |
| @@ -486,6 +486,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 486 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | 486 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | ||
| 487 | d.applyTheme(sess, ac, th) | 487 | d.applyTheme(sess, ac, th) | ||
| 488 | } | 488 | } | ||
| 489 | + | case ports.MsgImagePush: | |||
| 490 | + | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |||
| 491 | + | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | |||
| 492 | + | } | |||
| 489 | case ports.MsgDetach: | 493 | case ports.MsgDetach: | ||
| 490 | d.clientGone(sess, ac, tr, true) | 494 | d.clientGone(sess, ac, tr, true) | ||
| 491 | return | 495 | return | ||
M internal/usecase/daemon/clipboard.go +127 -0
| @@ -1,9 +1,136 @@ | |||
| 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. | |
| 1 | 4 | package daemon | |
| 2 | 5 | ||
| 3 | 6 | import ( | |
| 7 | + | "os" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 4 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 5 | 11 | ) | |
| 6 | 12 | ||
| 13 | + | // maxImagePushSize independently caps an accepted MsgImagePush payload, | |
| 14 | + | // defending against an old or foreign client even though the client is | |
| 15 | + | // expected to enforce the same cap before sending. Kept at 1 MiB so one | |
| 16 | + | // ImagePush fits the datagram transport's fragmented payload ceiling. | |
| 17 | + | const maxImagePushSize = 1 << 20 // 1 MiB | |
| 18 | + | ||
| 19 | + | // Bracketed-paste markers used to wrap the injected clip path when the | |
| 20 | + | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | |
| 21 | + | // pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a | |
| 22 | + | // separate copy since the daemon has no dependency on the client package. | |
| 23 | + | var ( | |
| 24 | + | clipPasteOpenMarker = []byte("\x1b[200~") | |
| 25 | + | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // handleSequencedImagePush routes an ImagePush through the same input-sequence | |
| 29 | + | // surface as ordinary keystrokes. Echo prediction is still conservative; the | |
| 30 | + | // sequence is carried so resume/dedup plumbing can order image pushes with | |
| 31 | + | // surrounding input when that state reader grows beyond ordinary input. | |
| 32 | + | func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) { | |
| 33 | + | d.handleImagePush(sess, ip) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |
| 37 | + | // file's path into the session's focused pane, as if typed/pasted there. | |
| 38 | + | // Oversized or empty payloads are rejected without panicking. A write | |
| 39 | + | // failure is logged and the push is dropped: the client has already | |
| 40 | + | // consumed the Ctrl+V keystroke that triggered it, so there is nothing left | |
| 41 | + | // to re-trigger from here. | |
| 42 | + | func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) { | |
| 43 | + | if len(ip.Data) == 0 { | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | if len(ip.Data) > maxImagePushSize { | |
| 47 | + | d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize) | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | path, err := d.writeClipboardImage(sess, ip) | |
| 51 | + | if err != nil { | |
| 52 | + | d.log.Error("writing clipboard image failed", "err", err) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | d.injectClipboardPath(sess, path) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // writeClipboardImage writes ip's bytes to a new 0600 temp file and records | |
| 59 | + | // the path on sess for best-effort cleanup at session end (killSession). | |
| 60 | + | func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) { | |
| 61 | + | dir := d.tempDir | |
| 62 | + | if dir == "" { | |
| 63 | + | dir = os.TempDir() | |
| 64 | + | } | |
| 65 | + | f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime)) | |
| 66 | + | if err != nil { | |
| 67 | + | return "", err | |
| 68 | + | } | |
| 69 | + | path := f.Name() | |
| 70 | + | if _, err := f.Write(ip.Data); err != nil { | |
| 71 | + | _ = f.Close() | |
| 72 | + | _ = os.Remove(path) | |
| 73 | + | return "", err | |
| 74 | + | } | |
| 75 | + | if err := f.Close(); err != nil { | |
| 76 | + | _ = os.Remove(path) | |
| 77 | + | return "", err | |
| 78 | + | } | |
| 79 | + | sess.mu.Lock() | |
| 80 | + | sess.clipFiles = append(sess.clipFiles, path) | |
| 81 | + | sess.mu.Unlock() | |
| 82 | + | return path, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // injectClipboardPath writes path's text into sess's focused pane, through | |
| 86 | + | // the same PTY write path as ordinary input, wrapped in bracketed-paste | |
| 87 | + | // markers iff the pane's Screen reports bracketed-paste mode enabled. | |
| 88 | + | func (d *Daemon) injectClipboardPath(sess *session, path string) { | |
| 89 | + | tb := sess.activeTab() | |
| 90 | + | if tb == nil { | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | tb.mu.Lock() | |
| 94 | + | p := tb.focusedPane() | |
| 95 | + | tb.mu.Unlock() | |
| 96 | + | if p == nil { | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | p.mu.Lock() | |
| 101 | + | bracketed := p.screen.BracketedPasteMode() | |
| 102 | + | p.mu.Unlock() | |
| 103 | + | ||
| 104 | + | data := []byte(path) | |
| 105 | + | if bracketed { | |
| 106 | + | wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker)) | |
| 107 | + | wrapped = append(wrapped, clipPasteOpenMarker...) | |
| 108 | + | wrapped = append(wrapped, data...) | |
| 109 | + | wrapped = append(wrapped, clipPasteCloseMarker...) | |
| 110 | + | data = wrapped | |
| 111 | + | } | |
| 112 | + | d.writeToPane(sess, p, data) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // clipboardExt maps an ImagePush MIME type to a filename extension. Unknown | |
| 116 | + | // types fall back to "bin" rather than rejecting the push: the daemon copies | |
| 117 | + | // bytes as-is regardless (see the design doc's "no image conversion" scope | |
| 118 | + | // decision). | |
| 119 | + | func clipboardExt(mime string) string { | |
| 120 | + | switch mime { | |
| 121 | + | case "image/png": | |
| 122 | + | return "png" | |
| 123 | + | case "image/jpeg": | |
| 124 | + | return "jpg" | |
| 125 | + | case "image/webp": | |
| 126 | + | return "webp" | |
| 127 | + | case "image/gif": | |
| 128 | + | return "gif" | |
| 129 | + | default: | |
| 130 | + | return "bin" | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 7 | 134 | type clipboardForward struct { | |
| 8 | 135 | ac *attachedClient | |
| 9 | 136 | seq []byte | |
| @@ -1,9 +1,136 @@ | |||||
| 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. | |||
| 1 | package daemon | 4 | package daemon | ||
| 2 | 5 | ||||
| 3 | import ( | 6 | import ( | ||
| 7 | + | "os" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 4 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 5 | ) | 11 | ) | ||
| 6 | 12 | ||||
| 13 | + | // maxImagePushSize independently caps an accepted MsgImagePush payload, | |||
| 14 | + | // defending against an old or foreign client even though the client is | |||
| 15 | + | // expected to enforce the same cap before sending. Kept at 1 MiB so one | |||
| 16 | + | // ImagePush fits the datagram transport's fragmented payload ceiling. | |||
| 17 | + | const maxImagePushSize = 1 << 20 // 1 MiB | |||
| 18 | + | ||||
| 19 | + | // Bracketed-paste markers used to wrap the injected clip path when the | |||
| 20 | + | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | |||
| 21 | + | // pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a | |||
| 22 | + | // separate copy since the daemon has no dependency on the client package. | |||
| 23 | + | var ( | |||
| 24 | + | clipPasteOpenMarker = []byte("\x1b[200~") | |||
| 25 | + | clipPasteCloseMarker = []byte("\x1b[201~") | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // handleSequencedImagePush routes an ImagePush through the same input-sequence | |||
| 29 | + | // surface as ordinary keystrokes. Echo prediction is still conservative; the | |||
| 30 | + | // sequence is carried so resume/dedup plumbing can order image pushes with | |||
| 31 | + | // surrounding input when that state reader grows beyond ordinary input. | |||
| 32 | + | func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) { | |||
| 33 | + | d.handleImagePush(sess, ip) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |||
| 37 | + | // file's path into the session's focused pane, as if typed/pasted there. | |||
| 38 | + | // Oversized or empty payloads are rejected without panicking. A write | |||
| 39 | + | // failure is logged and the push is dropped: the client has already | |||
| 40 | + | // consumed the Ctrl+V keystroke that triggered it, so there is nothing left | |||
| 41 | + | // to re-trigger from here. | |||
| 42 | + | func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) { | |||
| 43 | + | if len(ip.Data) == 0 { | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | if len(ip.Data) > maxImagePushSize { | |||
| 47 | + | d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize) | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | path, err := d.writeClipboardImage(sess, ip) | |||
| 51 | + | if err != nil { | |||
| 52 | + | d.log.Error("writing clipboard image failed", "err", err) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | d.injectClipboardPath(sess, path) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // writeClipboardImage writes ip's bytes to a new 0600 temp file and records | |||
| 59 | + | // the path on sess for best-effort cleanup at session end (killSession). | |||
| 60 | + | func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) { | |||
| 61 | + | dir := d.tempDir | |||
| 62 | + | if dir == "" { | |||
| 63 | + | dir = os.TempDir() | |||
| 64 | + | } | |||
| 65 | + | f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime)) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return "", err | |||
| 68 | + | } | |||
| 69 | + | path := f.Name() | |||
| 70 | + | if _, err := f.Write(ip.Data); err != nil { | |||
| 71 | + | _ = f.Close() | |||
| 72 | + | _ = os.Remove(path) | |||
| 73 | + | return "", err | |||
| 74 | + | } | |||
| 75 | + | if err := f.Close(); err != nil { | |||
| 76 | + | _ = os.Remove(path) | |||
| 77 | + | return "", err | |||
| 78 | + | } | |||
| 79 | + | sess.mu.Lock() | |||
| 80 | + | sess.clipFiles = append(sess.clipFiles, path) | |||
| 81 | + | sess.mu.Unlock() | |||
| 82 | + | return path, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // injectClipboardPath writes path's text into sess's focused pane, through | |||
| 86 | + | // the same PTY write path as ordinary input, wrapped in bracketed-paste | |||
| 87 | + | // markers iff the pane's Screen reports bracketed-paste mode enabled. | |||
| 88 | + | func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 89 | + | tb := sess.activeTab() | |||
| 90 | + | if tb == nil { | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | tb.mu.Lock() | |||
| 94 | + | p := tb.focusedPane() | |||
| 95 | + | tb.mu.Unlock() | |||
| 96 | + | if p == nil { | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | p.mu.Lock() | |||
| 101 | + | bracketed := p.screen.BracketedPasteMode() | |||
| 102 | + | p.mu.Unlock() | |||
| 103 | + | ||||
| 104 | + | data := []byte(path) | |||
| 105 | + | if bracketed { | |||
| 106 | + | wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker)) | |||
| 107 | + | wrapped = append(wrapped, clipPasteOpenMarker...) | |||
| 108 | + | wrapped = append(wrapped, data...) | |||
| 109 | + | wrapped = append(wrapped, clipPasteCloseMarker...) | |||
| 110 | + | data = wrapped | |||
| 111 | + | } | |||
| 112 | + | d.writeToPane(sess, p, data) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // clipboardExt maps an ImagePush MIME type to a filename extension. Unknown | |||
| 116 | + | // types fall back to "bin" rather than rejecting the push: the daemon copies | |||
| 117 | + | // bytes as-is regardless (see the design doc's "no image conversion" scope | |||
| 118 | + | // decision). | |||
| 119 | + | func clipboardExt(mime string) string { | |||
| 120 | + | switch mime { | |||
| 121 | + | case "image/png": | |||
| 122 | + | return "png" | |||
| 123 | + | case "image/jpeg": | |||
| 124 | + | return "jpg" | |||
| 125 | + | case "image/webp": | |||
| 126 | + | return "webp" | |||
| 127 | + | case "image/gif": | |||
| 128 | + | return "gif" | |||
| 129 | + | default: | |||
| 130 | + | return "bin" | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 7 | type clipboardForward struct { | 134 | type clipboardForward struct { | ||
| 8 | ac *attachedClient | 135 | ac *attachedClient | ||
| 9 | seq []byte | 136 | seq []byte | ||
M internal/usecase/daemon/clipboard_test.go +142 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 7 | 9 | "strings" | |
| 8 | 10 | "testing" | |
| 9 | 11 | "time" | |
| @@ -17,6 +19,146 @@ import ( | |||
| 17 | 19 | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 22 | + | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |
| 23 | + | writes := make(chan []byte, 1) | |
| 24 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 25 | + | defer releasePTY() | |
| 26 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 27 | + | d.tempDir = t.TempDir() | |
| 28 | + | ||
| 29 | + | data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03} | |
| 30 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data}) | |
| 31 | + | ||
| 32 | + | injected := <-writes | |
| 33 | + | path := string(injected) | |
| 34 | + | require.FileExists(t, path) | |
| 35 | + | require.Equal(t, ".png", filepath.Ext(path)) | |
| 36 | + | require.True(t, filepath.IsAbs(path)) | |
| 37 | + | ||
| 38 | + | got, err := os.ReadFile(path) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | require.Equal(t, data, got) | |
| 41 | + | ||
| 42 | + | info, err := os.Stat(path) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) { | |
| 48 | + | writes := make(chan []byte, 1) | |
| 49 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 50 | + | defer releasePTY() | |
| 51 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 52 | + | d.tempDir = t.TempDir() | |
| 53 | + | pane := sess.tabs[0].focusedPane() | |
| 54 | + | require.False(t, pane.screen.BracketedPasteMode()) | |
| 55 | + | ||
| 56 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |
| 57 | + | ||
| 58 | + | injected := <-writes | |
| 59 | + | require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode") | |
| 60 | + | require.FileExists(t, string(injected)) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) { | |
| 64 | + | writes := make(chan []byte, 1) | |
| 65 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 66 | + | defer releasePTY() | |
| 67 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 68 | + | d.tempDir = t.TempDir() | |
| 69 | + | pane := sess.tabs[0].focusedPane() | |
| 70 | + | pane.screen.Write([]byte("\x1b[?2004h")) | |
| 71 | + | require.True(t, pane.screen.BracketedPasteMode()) | |
| 72 | + | ||
| 73 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |
| 74 | + | ||
| 75 | + | injected := <-writes | |
| 76 | + | require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker)) | |
| 77 | + | require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)]) | |
| 78 | + | require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):]) | |
| 79 | + | path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)]) | |
| 80 | + | require.FileExists(t, path) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func hasBracketedPasteMarkers(b []byte) bool { | |
| 84 | + | return len(b) >= len(clipPasteOpenMarker) && | |
| 85 | + | string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) { | |
| 89 | + | writes := make(chan []byte, 1) | |
| 90 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 91 | + | defer releasePTY() | |
| 92 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 93 | + | dir := t.TempDir() | |
| 94 | + | d.tempDir = dir | |
| 95 | + | ||
| 96 | + | huge := make([]byte, maxImagePushSize+1) | |
| 97 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge}) | |
| 98 | + | ||
| 99 | + | select { | |
| 100 | + | case w := <-writes: | |
| 101 | + | t.Fatalf("oversized push must not be injected into the pane, got %q", w) | |
| 102 | + | default: | |
| 103 | + | } | |
| 104 | + | entries, err := os.ReadDir(dir) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | require.Empty(t, entries, "oversized push must not write a temp file") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) { | |
| 110 | + | writes := make(chan []byte, 1) | |
| 111 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 112 | + | defer releasePTY() | |
| 113 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 114 | + | dir := t.TempDir() | |
| 115 | + | d.tempDir = dir | |
| 116 | + | ||
| 117 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil}) | |
| 118 | + | ||
| 119 | + | select { | |
| 120 | + | case w := <-writes: | |
| 121 | + | t.Fatalf("empty push must not be injected, got %q", w) | |
| 122 | + | default: | |
| 123 | + | } | |
| 124 | + | entries, err := os.ReadDir(dir) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | require.Empty(t, entries) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) { | |
| 130 | + | cases := map[string]string{ | |
| 131 | + | "image/png": "png", | |
| 132 | + | "image/jpeg": "jpg", | |
| 133 | + | "image/webp": "webp", | |
| 134 | + | "image/gif": "gif", | |
| 135 | + | "image/bmp": "bin", | |
| 136 | + | "": "bin", | |
| 137 | + | "application/octet-stream": "bin", | |
| 138 | + | } | |
| 139 | + | for mime, want := range cases { | |
| 140 | + | require.Equal(t, want, clipboardExt(mime), "mime %q", mime) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestKillSessionRemovesClipboardTempFiles(t *testing.T) { | |
| 145 | + | writes := make(chan []byte, 1) | |
| 146 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 147 | + | defer releasePTY() | |
| 148 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 149 | + | d.tempDir = t.TempDir() | |
| 150 | + | // killSession requires the session to be registered in the daemon's | |
| 151 | + | // registry (newManualSessionWithPTYs already does this via d.sessions). | |
| 152 | + | ||
| 153 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |
| 154 | + | injected := <-writes | |
| 155 | + | path := string(injected) | |
| 156 | + | require.FileExists(t, path) | |
| 157 | + | ||
| 158 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 159 | + | require.NoFileExists(t, path) | |
| 160 | + | } | |
| 161 | + | ||
| 20 | 162 | // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then | |
| 21 | 163 | // reports io.EOF — driving ptyReader through exactly len(chunks) reads before | |
| 22 | 164 | // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY. | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 7 | "strings" | 9 | "strings" | ||
| 8 | "testing" | 10 | "testing" | ||
| 9 | "time" | 11 | "time" | ||
| @@ -17,6 +19,146 @@ import ( | |||||
| 17 | "github.com/bnema/vev/pkg/renderer" | 19 | "github.com/bnema/vev/pkg/renderer" | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 22 | + | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |||
| 23 | + | writes := make(chan []byte, 1) | |||
| 24 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 25 | + | defer releasePTY() | |||
| 26 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 27 | + | d.tempDir = t.TempDir() | |||
| 28 | + | ||||
| 29 | + | data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03} | |||
| 30 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data}) | |||
| 31 | + | ||||
| 32 | + | injected := <-writes | |||
| 33 | + | path := string(injected) | |||
| 34 | + | require.FileExists(t, path) | |||
| 35 | + | require.Equal(t, ".png", filepath.Ext(path)) | |||
| 36 | + | require.True(t, filepath.IsAbs(path)) | |||
| 37 | + | ||||
| 38 | + | got, err := os.ReadFile(path) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | require.Equal(t, data, got) | |||
| 41 | + | ||||
| 42 | + | info, err := os.Stat(path) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) { | |||
| 48 | + | writes := make(chan []byte, 1) | |||
| 49 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 50 | + | defer releasePTY() | |||
| 51 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 52 | + | d.tempDir = t.TempDir() | |||
| 53 | + | pane := sess.tabs[0].focusedPane() | |||
| 54 | + | require.False(t, pane.screen.BracketedPasteMode()) | |||
| 55 | + | ||||
| 56 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |||
| 57 | + | ||||
| 58 | + | injected := <-writes | |||
| 59 | + | require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode") | |||
| 60 | + | require.FileExists(t, string(injected)) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) { | |||
| 64 | + | writes := make(chan []byte, 1) | |||
| 65 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 66 | + | defer releasePTY() | |||
| 67 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 68 | + | d.tempDir = t.TempDir() | |||
| 69 | + | pane := sess.tabs[0].focusedPane() | |||
| 70 | + | pane.screen.Write([]byte("\x1b[?2004h")) | |||
| 71 | + | require.True(t, pane.screen.BracketedPasteMode()) | |||
| 72 | + | ||||
| 73 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |||
| 74 | + | ||||
| 75 | + | injected := <-writes | |||
| 76 | + | require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker)) | |||
| 77 | + | require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)]) | |||
| 78 | + | require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):]) | |||
| 79 | + | path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)]) | |||
| 80 | + | require.FileExists(t, path) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func hasBracketedPasteMarkers(b []byte) bool { | |||
| 84 | + | return len(b) >= len(clipPasteOpenMarker) && | |||
| 85 | + | string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) { | |||
| 89 | + | writes := make(chan []byte, 1) | |||
| 90 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 91 | + | defer releasePTY() | |||
| 92 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 93 | + | dir := t.TempDir() | |||
| 94 | + | d.tempDir = dir | |||
| 95 | + | ||||
| 96 | + | huge := make([]byte, maxImagePushSize+1) | |||
| 97 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge}) | |||
| 98 | + | ||||
| 99 | + | select { | |||
| 100 | + | case w := <-writes: | |||
| 101 | + | t.Fatalf("oversized push must not be injected into the pane, got %q", w) | |||
| 102 | + | default: | |||
| 103 | + | } | |||
| 104 | + | entries, err := os.ReadDir(dir) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | require.Empty(t, entries, "oversized push must not write a temp file") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) { | |||
| 110 | + | writes := make(chan []byte, 1) | |||
| 111 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 112 | + | defer releasePTY() | |||
| 113 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 114 | + | dir := t.TempDir() | |||
| 115 | + | d.tempDir = dir | |||
| 116 | + | ||||
| 117 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil}) | |||
| 118 | + | ||||
| 119 | + | select { | |||
| 120 | + | case w := <-writes: | |||
| 121 | + | t.Fatalf("empty push must not be injected, got %q", w) | |||
| 122 | + | default: | |||
| 123 | + | } | |||
| 124 | + | entries, err := os.ReadDir(dir) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | require.Empty(t, entries) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) { | |||
| 130 | + | cases := map[string]string{ | |||
| 131 | + | "image/png": "png", | |||
| 132 | + | "image/jpeg": "jpg", | |||
| 133 | + | "image/webp": "webp", | |||
| 134 | + | "image/gif": "gif", | |||
| 135 | + | "image/bmp": "bin", | |||
| 136 | + | "": "bin", | |||
| 137 | + | "application/octet-stream": "bin", | |||
| 138 | + | } | |||
| 139 | + | for mime, want := range cases { | |||
| 140 | + | require.Equal(t, want, clipboardExt(mime), "mime %q", mime) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestKillSessionRemovesClipboardTempFiles(t *testing.T) { | |||
| 145 | + | writes := make(chan []byte, 1) | |||
| 146 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 147 | + | defer releasePTY() | |||
| 148 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 149 | + | d.tempDir = t.TempDir() | |||
| 150 | + | // killSession requires the session to be registered in the daemon's | |||
| 151 | + | // registry (newManualSessionWithPTYs already does this via d.sessions). | |||
| 152 | + | ||||
| 153 | + | d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")}) | |||
| 154 | + | injected := <-writes | |||
| 155 | + | path := string(injected) | |||
| 156 | + | require.FileExists(t, path) | |||
| 157 | + | ||||
| 158 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 159 | + | require.NoFileExists(t, path) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 20 | // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then | 162 | // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then | ||
| 21 | // reports io.EOF — driving ptyReader through exactly len(chunks) reads before | 163 | // reports io.EOF — driving ptyReader through exactly len(chunks) reads before | ||
| 22 | // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY. | 164 | // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY. | ||
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,7 +48,7 @@ type session struct { | |||
| 47 | 48 | ctx context.Context | |
| 48 | 49 | cancel context.CancelFunc | |
| 49 | 50 | ||
| 50 | - | mu sync.Mutex // guards tabs, active, client, and clipboard queue state | |
| 51 | + | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state | |
| 51 | 52 | tabs []*tab | |
| 52 | 53 | active int | |
| 53 | 54 | client *attachedClient | |
| @@ -55,6 +56,10 @@ type session struct { | |||
| 55 | 56 | clipboardWorkerRunning bool | |
| 56 | 57 | cwd string | |
| 57 | 58 | createdAt int64 | |
| 59 | + | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 60 | + | // clipboard.go) written for this session, removed best-effort in | |
| 61 | + | // killSession. | |
| 62 | + | clipFiles []string | |
| 58 | 63 | } | |
| 59 | 64 | ||
| 60 | 65 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | |
| @@ -484,11 +489,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 484 | 489 | sess.cancel() | |
| 485 | 490 | sess.mu.Lock() | |
| 486 | 491 | tabs := append([]*tab(nil), sess.tabs...) | |
| 492 | + | clipFiles := sess.clipFiles | |
| 493 | + | sess.clipFiles = nil | |
| 487 | 494 | sess.mu.Unlock() | |
| 488 | 495 | for _, tb := range tabs { | |
| 489 | 496 | d.clearDestroyedTabPreview(tb) | |
| 490 | 497 | tb.closeAllPanes() | |
| 491 | 498 | } | |
| 499 | + | for _, path := range clipFiles { | |
| 500 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 501 | + | d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) | |
| 502 | + | } | |
| 503 | + | } | |
| 492 | 504 | if empty { | |
| 493 | 505 | d.doneOnce.Do(func() { close(d.done) }) | |
| 494 | 506 | } 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,7 +48,7 @@ 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, client, and clipboard queue state | 51 | + | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state |
| 51 | tabs []*tab | 52 | tabs []*tab | ||
| 52 | active int | 53 | active int | ||
| 53 | client *attachedClient | 54 | client *attachedClient | ||
| @@ -55,6 +56,10 @@ type session struct { | |||||
| 55 | clipboardWorkerRunning bool | 56 | clipboardWorkerRunning bool | ||
| 56 | cwd string | 57 | cwd string | ||
| 57 | createdAt int64 | 58 | createdAt int64 | ||
| 59 | + | // clipFiles records clipboard-image-transfer temp file paths (see | |||
| 60 | + | // clipboard.go) written for this session, removed best-effort in | |||
| 61 | + | // killSession. | |||
| 62 | + | clipFiles []string | |||
| 58 | } | 63 | } | ||
| 59 | 64 | ||||
| 60 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | 65 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | ||
| @@ -484,11 +489,18 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 484 | sess.cancel() | 489 | sess.cancel() | ||
| 485 | sess.mu.Lock() | 490 | sess.mu.Lock() | ||
| 486 | tabs := append([]*tab(nil), sess.tabs...) | 491 | tabs := append([]*tab(nil), sess.tabs...) | ||
| 492 | + | clipFiles := sess.clipFiles | |||
| 493 | + | sess.clipFiles = nil | |||
| 487 | sess.mu.Unlock() | 494 | sess.mu.Unlock() | ||
| 488 | for _, tb := range tabs { | 495 | for _, tb := range tabs { | ||
| 489 | d.clearDestroyedTabPreview(tb) | 496 | d.clearDestroyedTabPreview(tb) | ||
| 490 | tb.closeAllPanes() | 497 | tb.closeAllPanes() | ||
| 491 | } | 498 | } | ||
| 499 | + | for _, path := range clipFiles { | |||
| 500 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 501 | + | d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) | |||
| 502 | + | } | |||
| 503 | + | } | |||
| 492 | if empty { | 504 | if empty { | ||
| 493 | d.doneOnce.Do(func() { close(d.done) }) | 505 | d.doneOnce.Do(func() { close(d.done) }) | ||
| 494 | } else if ringing { | 506 | } else if ringing { | ||