fix(clipboard): harden image transfer review findings
- commit
- 76a19204659fa942e508bc16675c7070f1877da7
- author
- brice · · 2026-07-04 17:03 UTC
- parent
- e9c558f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +53 -29
M internal/adapters/clipboard/wlpaste.go +3 -2
| @@ -29,8 +29,9 @@ const listTypesTimeout = 1 * time.Second | |||
| 29 | 29 | // wl-paste that hangs mid-read (as opposed to during the --list-types probe, | |
| 30 | 30 | // which listTypesTimeout already covers) would stall the client's stdin pump | |
| 31 | 31 | // indefinitely, since ReadImage is called with context.Background() from the | |
| 32 | - | // interceptor. Generous for a read of up to the 10 MiB cap. | |
| 33 | - | const defaultReadTimeout = 10 * time.Second | |
| 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 | |
| 34 | 35 | ||
| 35 | 36 | // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It | |
| 36 | 37 | // holds no state and is safe for concurrent use; every call runs a fresh | |
| @@ -29,8 +29,9 @@ const listTypesTimeout = 1 * time.Second | |||||
| 29 | // wl-paste that hangs mid-read (as opposed to during the --list-types probe, | 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 | 30 | // which listTypesTimeout already covers) would stall the client's stdin pump | ||
| 31 | // indefinitely, since ReadImage is called with context.Background() from the | 31 | // indefinitely, since ReadImage is called with context.Background() from the | ||
| 32 | - | // interceptor. Generous for a read of up to the 10 MiB cap. | 32 | + | // interceptor. Keep this short so a slow clipboard read cannot noticeably |
| 33 | - | const defaultReadTimeout = 10 * time.Second | 33 | + | // freeze typing for longer than the image-transfer MVP's small payload cap. |
| 34 | + | const defaultReadTimeout = time.Second | |||
| 34 | 35 | ||||
| 35 | // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It | 36 | // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It | ||
| 36 | // holds no state and is safe for concurrent use; every call runs a fresh | 37 | // holds no state and is safe for concurrent use; every call runs a fresh | ||
M internal/ports/frame.go +1 -1
| @@ -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 = 6 | |
| 4 | + | const ProtocolVersion uint16 = 7 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -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 = 6 | 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 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 6 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 6", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 7 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -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 != 6 { | 6 | + | if ProtocolVersion != 7 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 6", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/wire.go +12 -6
| @@ -91,8 +91,9 @@ type Theme struct { | |||
| 91 | 91 | // a temp file and injected into the focused pane's PTY as a path (see | |
| 92 | 92 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 93 | 93 | type ImagePush struct { | |
| 94 | - | Mime string | |
| 95 | - | Data []byte | |
| 94 | + | InputSeq uint64 | |
| 95 | + | Mime string | |
| 96 | + | Data []byte | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 98 | 99 | // Detach asks the daemon to detach the current client without killing the | |
| @@ -368,21 +369,26 @@ func UnmarshalInput(b []byte) (Input, error) { | |||
| 368 | 369 | // MarshalImagePush encodes m into an ImagePush message payload. | |
| 369 | 370 | func MarshalImagePush(m ImagePush) []byte { | |
| 370 | 371 | w := payloadWriter{} | |
| 372 | + | w.putUint64(m.InputSeq) | |
| 371 | 373 | w.putString(m.Mime) | |
| 372 | 374 | w.putBytes(m.Data) | |
| 373 | 375 | return w.b | |
| 374 | 376 | } | |
| 375 | 377 | ||
| 376 | - | // UnmarshalImagePush decodes an ImagePush message payload. After the | |
| 377 | - | // length-prefixed mime string, the rest of the payload is data; there is no | |
| 378 | - | // length prefix for it. | |
| 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. | |
| 379 | 381 | func UnmarshalImagePush(b []byte) (ImagePush, error) { | |
| 380 | 382 | r := payloadReader{b: b} | |
| 383 | + | seq, err := r.getUint64() | |
| 384 | + | if err != nil { | |
| 385 | + | return ImagePush{}, err | |
| 386 | + | } | |
| 381 | 387 | mime, err := r.getString() | |
| 382 | 388 | if err != nil { | |
| 383 | 389 | return ImagePush{}, err | |
| 384 | 390 | } | |
| 385 | - | return ImagePush{Mime: mime, Data: r.rest()}, nil | |
| 391 | + | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | |
| 386 | 392 | } | |
| 387 | 393 | ||
| 388 | 394 | // MarshalResize encodes m into a Resize message payload. | |
| @@ -91,8 +91,9 @@ type Theme struct { | |||||
| 91 | // a temp file and injected into the focused pane's PTY as a path (see | 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). | 92 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | ||
| 93 | type ImagePush struct { | 93 | type ImagePush struct { | ||
| 94 | - | Mime string | 94 | + | InputSeq uint64 |
| 95 | - | Data []byte | 95 | + | Mime string |
| 96 | + | Data []byte | |||
| 96 | } | 97 | } | ||
| 97 | 98 | ||||
| 98 | // 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 | ||
| @@ -368,21 +369,26 @@ func UnmarshalInput(b []byte) (Input, error) { | |||||
| 368 | // MarshalImagePush encodes m into an ImagePush message payload. | 369 | // MarshalImagePush encodes m into an ImagePush message payload. | ||
| 369 | func MarshalImagePush(m ImagePush) []byte { | 370 | func MarshalImagePush(m ImagePush) []byte { | ||
| 370 | w := payloadWriter{} | 371 | w := payloadWriter{} | ||
| 372 | + | w.putUint64(m.InputSeq) | |||
| 371 | w.putString(m.Mime) | 373 | w.putString(m.Mime) | ||
| 372 | w.putBytes(m.Data) | 374 | w.putBytes(m.Data) | ||
| 373 | return w.b | 375 | return w.b | ||
| 374 | } | 376 | } | ||
| 375 | 377 | ||||
| 376 | - | // UnmarshalImagePush decodes an ImagePush message payload. After the | 378 | + | // UnmarshalImagePush decodes an ImagePush message payload. After the fixed |
| 377 | - | // length-prefixed mime string, the rest of the payload is data; there is no | 379 | + | // input sequence and length-prefixed mime string, the rest of the payload is |
| 378 | - | // length prefix for it. | 380 | + | // data; there is no length prefix for it. |
| 379 | func UnmarshalImagePush(b []byte) (ImagePush, error) { | 381 | func UnmarshalImagePush(b []byte) (ImagePush, error) { | ||
| 380 | r := payloadReader{b: b} | 382 | r := payloadReader{b: b} | ||
| 383 | + | seq, err := r.getUint64() | |||
| 384 | + | if err != nil { | |||
| 385 | + | return ImagePush{}, err | |||
| 386 | + | } | |||
| 381 | mime, err := r.getString() | 387 | mime, err := r.getString() | ||
| 382 | if err != nil { | 388 | if err != nil { | ||
| 383 | return ImagePush{}, err | 389 | return ImagePush{}, err | ||
| 384 | } | 390 | } | ||
| 385 | - | return ImagePush{Mime: mime, Data: r.rest()}, nil | 391 | + | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil |
| 386 | } | 392 | } | ||
| 387 | 393 | ||||
| 388 | // MarshalResize encodes m into a Resize message payload. | 394 | // MarshalResize encodes m into a Resize message payload. | ||
M internal/ports/wire_test.go +4 -4
| @@ -148,10 +148,10 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||
| 148 | 148 | }{ | |
| 149 | 149 | { | |
| 150 | 150 | name: "png data", | |
| 151 | - | msg: ImagePush{Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, | |
| 152 | - | want: []byte{0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03}, | |
| 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 | 153 | }, | |
| 154 | - | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00}}, | |
| 154 | + | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | for _, tt := range tests { | |
| @@ -171,7 +171,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | full := MarshalImagePush(tests[0].msg) | |
| 174 | - | assertAllPrefixesFail(t, full[:11], UnmarshalImagePush) | |
| 174 | + | assertAllPrefixesFail(t, full[:19], UnmarshalImagePush) | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -148,10 +148,10 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||||
| 148 | }{ | 148 | }{ | ||
| 149 | { | 149 | { | ||
| 150 | name: "png data", | 150 | name: "png data", | ||
| 151 | - | msg: ImagePush{Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, | 151 | + | msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, |
| 152 | - | want: []byte{0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 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 | }, | 153 | }, | ||
| 154 | - | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00}}, | 154 | + | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, |
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | for _, tt := range tests { | 157 | for _, tt := range tests { | ||
| @@ -171,7 +171,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | full := MarshalImagePush(tests[0].msg) | 173 | full := MarshalImagePush(tests[0].msg) | ||
| 174 | - | assertAllPrefixesFail(t, full[:11], UnmarshalImagePush) | 174 | + | assertAllPrefixesFail(t, full[:19], UnmarshalImagePush) |
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | ||
M internal/usecase/client/client.go +2 -1
| @@ -511,7 +511,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 511 | 511 | reader: clipboard, | |
| 512 | 512 | log: log, | |
| 513 | 513 | sendImage: func(mime string, data []byte) { | |
| 514 | - | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})}) | |
| 514 | + | inputSeq++ | |
| 515 | + | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | |
| 515 | 516 | }, | |
| 516 | 517 | next: coalescer.Scan, | |
| 517 | 518 | } | |
| @@ -511,7 +511,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 511 | reader: clipboard, | 511 | reader: clipboard, | ||
| 512 | log: log, | 512 | log: log, | ||
| 513 | sendImage: func(mime string, data []byte) { | 513 | sendImage: func(mime string, data []byte) { | ||
| 514 | - | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})}) | 514 | + | inputSeq++ |
| 515 | + | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | |||
| 515 | }, | 516 | }, | ||
| 516 | next: coalescer.Scan, | 517 | next: coalescer.Scan, | ||
| 517 | } | 518 | } | ||
M internal/usecase/client/clipboard_intercept.go +3 -3
| @@ -13,9 +13,9 @@ import ( | |||
| 13 | 13 | const ctrlV = 0x16 | |
| 14 | 14 | ||
| 15 | 15 | // maxClipboardImagePush caps the image the client will send in one | |
| 16 | - | // MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit | |
| 17 | - | // means one frame always suffices under this cap. | |
| 18 | - | const maxClipboardImagePush = 10 << 20 // 10 MiB | |
| 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 | 19 | ||
| 20 | 20 | // clipboardIntercept sits between the terminal's theme scanner and the paste | |
| 21 | 21 | // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V | |
| @@ -13,9 +13,9 @@ import ( | |||||
| 13 | const ctrlV = 0x16 | 13 | const ctrlV = 0x16 | ||
| 14 | 14 | ||||
| 15 | // maxClipboardImagePush caps the image the client will send in one | 15 | // maxClipboardImagePush caps the image the client will send in one | ||
| 16 | - | // MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit | 16 | + | // MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits |
| 17 | - | // means one frame always suffices under this cap. | 17 | + | // both sshstdio and the datagram transport's fragmented payload ceiling. |
| 18 | - | const maxClipboardImagePush = 10 << 20 // 10 MiB | 18 | + | const maxClipboardImagePush = 1 << 20 // 1 MiB |
| 19 | 19 | ||||
| 20 | // clipboardIntercept sits between the terminal's theme scanner and the paste | 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 | 21 | // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V | ||
M internal/usecase/client/clipboard_intercept_test.go +2 -1
| @@ -119,6 +119,7 @@ func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testi | |||
| 119 | 119 | ||
| 120 | 120 | select { | |
| 121 | 121 | case ip := <-gotImage: | |
| 122 | + | require.Equal(t, uint64(2), ip.InputSeq) | |
| 122 | 123 | require.Equal(t, "image/png", ip.Mime) | |
| 123 | 124 | require.Equal(t, []byte("PNGDATA"), ip.Data) | |
| 124 | 125 | case <-time.After(2 * time.Second): | |
| @@ -137,7 +138,7 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |||
| 137 | 138 | } | |
| 138 | 139 | ||
| 139 | 140 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |
| 140 | - | huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap | |
| 141 | + | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap | |
| 141 | 142 | clip := portsmocks.NewMockClipboardReader(t) | |
| 142 | 143 | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | |
| 143 | 144 | ||
| @@ -119,6 +119,7 @@ func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testi | |||||
| 119 | 119 | ||||
| 120 | select { | 120 | select { | ||
| 121 | case ip := <-gotImage: | 121 | case ip := <-gotImage: | ||
| 122 | + | require.Equal(t, uint64(2), ip.InputSeq) | |||
| 122 | require.Equal(t, "image/png", ip.Mime) | 123 | require.Equal(t, "image/png", ip.Mime) | ||
| 123 | require.Equal(t, []byte("PNGDATA"), ip.Data) | 124 | require.Equal(t, []byte("PNGDATA"), ip.Data) | ||
| 124 | case <-time.After(2 * time.Second): | 125 | case <-time.After(2 * time.Second): | ||
| @@ -137,7 +138,7 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |||||
| 137 | } | 138 | } | ||
| 138 | 139 | ||||
| 139 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | 140 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | ||
| 140 | - | huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap | 141 | + | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap |
| 141 | clip := portsmocks.NewMockClipboardReader(t) | 142 | clip := portsmocks.NewMockClipboardReader(t) | ||
| 142 | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | 143 | clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once() | ||
| 143 | 144 | ||||
M internal/usecase/daemon/client.go +1 -1
| @@ -488,7 +488,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 488 | 488 | } | |
| 489 | 489 | case ports.MsgImagePush: | |
| 490 | 490 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 491 | - | d.handleImagePush(sess, ip) | |
| 491 | + | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | |
| 492 | 492 | } | |
| 493 | 493 | case ports.MsgDetach: | |
| 494 | 494 | d.clientGone(sess, ac, tr, true) | |
| @@ -488,7 +488,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 488 | } | 488 | } | ||
| 489 | case ports.MsgImagePush: | 489 | case ports.MsgImagePush: | ||
| 490 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | 490 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | ||
| 491 | - | d.handleImagePush(sess, ip) | 491 | + | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) |
| 492 | } | 492 | } | ||
| 493 | case ports.MsgDetach: | 493 | case ports.MsgDetach: | ||
| 494 | d.clientGone(sess, ac, tr, true) | 494 | d.clientGone(sess, ac, tr, true) | ||
M internal/usecase/daemon/clipboard.go +23 -8
| @@ -4,9 +4,7 @@ | |||
| 4 | 4 | package daemon | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| 7 | - | "fmt" | |
| 8 | 7 | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 8 | ||
| 11 | 9 | "github.com/bnema/vev/internal/ports" | |
| 12 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| @@ -14,9 +12,9 @@ import ( | |||
| 14 | 12 | ||
| 15 | 13 | // maxImagePushSize independently caps an accepted MsgImagePush payload, | |
| 16 | 14 | // defending against an old or foreign client even though the client is | |
| 17 | - | // expected to enforce the same cap before sending | |
| 18 | - | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 19 | - | const maxImagePushSize = 10 << 20 // 10 MiB | |
| 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 | |
| 20 | 18 | ||
| 21 | 19 | // Bracketed-paste markers used to wrap the injected clip path when the | |
| 22 | 20 | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | |
| @@ -27,6 +25,14 @@ var ( | |||
| 27 | 25 | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 28 | 26 | ) | |
| 29 | 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 | + | ||
| 30 | 36 | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |
| 31 | 37 | // file's path into the session's focused pane, as if typed/pasted there. | |
| 32 | 38 | // Oversized or empty payloads are rejected without panicking. A write | |
| @@ -56,9 +62,18 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||
| 56 | 62 | if dir == "" { | |
| 57 | 63 | dir = os.TempDir() | |
| 58 | 64 | } | |
| 59 | - | name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime)) | |
| 60 | - | path := filepath.Join(dir, name) | |
| 61 | - | if err := os.WriteFile(path, ip.Data, 0o600); err != nil { | |
| 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) | |
| 62 | 77 | return "", err | |
| 63 | 78 | } | |
| 64 | 79 | sess.mu.Lock() | |
| @@ -4,9 +4,7 @@ | |||||
| 4 | package daemon | 4 | package daemon | ||
| 5 | 5 | ||||
| 6 | import ( | 6 | import ( | ||
| 7 | - | "fmt" | |||
| 8 | "os" | 7 | "os" | ||
| 9 | - | "path/filepath" | |||
| 10 | 8 | ||||
| 11 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 12 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| @@ -14,9 +12,9 @@ import ( | |||||
| 14 | 12 | ||||
| 15 | // maxImagePushSize independently caps an accepted MsgImagePush payload, | 13 | // maxImagePushSize independently caps an accepted MsgImagePush payload, | ||
| 16 | // defending against an old or foreign client even though the client is | 14 | // defending against an old or foreign client even though the client is | ||
| 17 | - | // expected to enforce the same cap before sending | 15 | + | // expected to enforce the same cap before sending. Kept at 1 MiB so one |
| 18 | - | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | 16 | + | // ImagePush fits the datagram transport's fragmented payload ceiling. |
| 19 | - | const maxImagePushSize = 10 << 20 // 10 MiB | 17 | + | const maxImagePushSize = 1 << 20 // 1 MiB |
| 20 | 18 | ||||
| 21 | // Bracketed-paste markers used to wrap the injected clip path when the | 19 | // Bracketed-paste markers used to wrap the injected clip path when the | ||
| 22 | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | 20 | // focused pane has DEC private mode 2004 enabled. Mirrors the client's | ||
| @@ -27,6 +25,14 @@ var ( | |||||
| 27 | clipPasteCloseMarker = []byte("\x1b[201~") | 25 | clipPasteCloseMarker = []byte("\x1b[201~") | ||
| 28 | ) | 26 | ) | ||
| 29 | 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 | + | ||||
| 30 | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | 36 | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | ||
| 31 | // file's path into the session's focused pane, as if typed/pasted there. | 37 | // file's path into the session's focused pane, as if typed/pasted there. | ||
| 32 | // Oversized or empty payloads are rejected without panicking. A write | 38 | // Oversized or empty payloads are rejected without panicking. A write | ||
| @@ -56,9 +62,18 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||||
| 56 | if dir == "" { | 62 | if dir == "" { | ||
| 57 | dir = os.TempDir() | 63 | dir = os.TempDir() | ||
| 58 | } | 64 | } | ||
| 59 | - | name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime)) | 65 | + | f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime)) |
| 60 | - | path := filepath.Join(dir, name) | 66 | + | if err != nil { |
| 61 | - | if err := os.WriteFile(path, ip.Data, 0o600); 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) | |||
| 62 | return "", err | 77 | return "", err | ||
| 63 | } | 78 | } | ||
| 64 | sess.mu.Lock() | 79 | sess.mu.Lock() | ||