Merge pull request #12 from bnema/fix/remote-clipboard-paste
fix(vt,daemon): pass through app OSC 52 clipboard to attached client
8 files · +435 -13
M internal/usecase/copy/mode.go +12 -0
| @@ -239,6 +239,18 @@ func OSC52(text string) [][]byte { | |||
| 239 | 239 | return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")} | |
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | + | // OSC52FromBase64 builds the normalized OSC 52 sequence to forward a | |
| 243 | + | // clipboard set request that a pane app already emitted as base64. It | |
| 244 | + | // returns nil if b64 is not valid base64 or its decoded length exceeds | |
| 245 | + | // OSC52MaxPayloadBytes — callers must drop the request silently in that case. | |
| 246 | + | func OSC52FromBase64(b64 string) []byte { | |
| 247 | + | decoded, err := base64.StdEncoding.DecodeString(b64) | |
| 248 | + | if err != nil || len(decoded) > OSC52MaxPayloadBytes { | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | return []byte("\x1b]52;c;" + b64 + "\x07") | |
| 252 | + | } | |
| 253 | + | ||
| 242 | 254 | func strconvItoa(n int) string { | |
| 243 | 255 | if n == 0 { | |
| 244 | 256 | return "0" | |
| @@ -239,6 +239,18 @@ func OSC52(text string) [][]byte { | |||||
| 239 | return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")} | 239 | return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")} | ||
| 240 | } | 240 | } | ||
| 241 | 241 | ||||
| 242 | + | // OSC52FromBase64 builds the normalized OSC 52 sequence to forward a | |||
| 243 | + | // clipboard set request that a pane app already emitted as base64. It | |||
| 244 | + | // returns nil if b64 is not valid base64 or its decoded length exceeds | |||
| 245 | + | // OSC52MaxPayloadBytes — callers must drop the request silently in that case. | |||
| 246 | + | func OSC52FromBase64(b64 string) []byte { | |||
| 247 | + | decoded, err := base64.StdEncoding.DecodeString(b64) | |||
| 248 | + | if err != nil || len(decoded) > OSC52MaxPayloadBytes { | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | return []byte("\x1b]52;c;" + b64 + "\x07") | |||
| 252 | + | } | |||
| 253 | + | ||||
| 242 | func strconvItoa(n int) string { | 254 | func strconvItoa(n int) string { | ||
| 243 | if n == 0 { | 255 | if n == 0 { | ||
| 244 | return "0" | 256 | return "0" | ||
M internal/usecase/copy/mode_test.go +25 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package copy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/base64" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| @@ -366,6 +367,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) { | |||
| 366 | 367 | } | |
| 367 | 368 | } | |
| 368 | 369 | ||
| 370 | + | func TestOSC52FromBase64(t *testing.T) { | |
| 371 | + | valid := base64.StdEncoding.EncodeToString([]byte("hello")) | |
| 372 | + | oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1))) | |
| 373 | + | ||
| 374 | + | tests := []struct { | |
| 375 | + | name string | |
| 376 | + | in string | |
| 377 | + | want []byte | |
| 378 | + | }{ | |
| 379 | + | {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")}, | |
| 380 | + | {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")}, | |
| 381 | + | {name: "invalid base64", in: "not-valid-base64!!", want: nil}, | |
| 382 | + | {name: "oversized decoded payload", in: oversized, want: nil}, | |
| 383 | + | } | |
| 384 | + | for _, tt := range tests { | |
| 385 | + | t.Run(tt.name, func(t *testing.T) { | |
| 386 | + | got := OSC52FromBase64(tt.in) | |
| 387 | + | if !bytes.Equal(got, tt.want) { | |
| 388 | + | t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want) | |
| 389 | + | } | |
| 390 | + | }) | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 369 | 394 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | |
| 370 | 395 | t.Helper() | |
| 371 | 396 | s := string(chunk) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package copy | 1 | package copy | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "encoding/base64" | 5 | "encoding/base64" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -366,6 +367,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) { | |||||
| 366 | } | 367 | } | ||
| 367 | } | 368 | } | ||
| 368 | 369 | ||||
| 370 | + | func TestOSC52FromBase64(t *testing.T) { | |||
| 371 | + | valid := base64.StdEncoding.EncodeToString([]byte("hello")) | |||
| 372 | + | oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1))) | |||
| 373 | + | ||||
| 374 | + | tests := []struct { | |||
| 375 | + | name string | |||
| 376 | + | in string | |||
| 377 | + | want []byte | |||
| 378 | + | }{ | |||
| 379 | + | {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")}, | |||
| 380 | + | {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")}, | |||
| 381 | + | {name: "invalid base64", in: "not-valid-base64!!", want: nil}, | |||
| 382 | + | {name: "oversized decoded payload", in: oversized, want: nil}, | |||
| 383 | + | } | |||
| 384 | + | for _, tt := range tests { | |||
| 385 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 386 | + | got := OSC52FromBase64(tt.in) | |||
| 387 | + | if !bytes.Equal(got, tt.want) { | |||
| 388 | + | t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want) | |||
| 389 | + | } | |||
| 390 | + | }) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | ||||
| 369 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | 394 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | ||
| 370 | t.Helper() | 395 | t.Helper() | ||
| 371 | s := string(chunk) | 396 | s := string(chunk) | ||
A internal/usecase/daemon/clipboard.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | type clipboardForward struct { | |
| 8 | + | ac *attachedClient | |
| 9 | + | seq []byte | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set | |
| 13 | + | // request (already captured off the pane's screen while ptyReader held pane.mu, | |
| 14 | + | // then handed here after that lock is released) to the session's attached | |
| 15 | + | // client, if any. Invalid base64 or an oversized decoded payload is dropped | |
| 16 | + | // silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The | |
| 17 | + | // caller must not hold pane.mu, tab.mu, or session.mu. | |
| 18 | + | // | |
| 19 | + | // Requests are queued on the session and drained by one worker so clipboard | |
| 20 | + | // writes keep arrival order without making the PTY reader wait on client I/O. | |
| 21 | + | func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) { | |
| 22 | + | seq := scopy.OSC52FromBase64(b64) | |
| 23 | + | if seq == nil { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | sess.mu.Lock() | |
| 28 | + | if sess.ctx != nil { | |
| 29 | + | select { | |
| 30 | + | case <-sess.ctx.Done(): | |
| 31 | + | sess.mu.Unlock() | |
| 32 | + | return | |
| 33 | + | default: | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ac := sess.client | |
| 37 | + | if ac == nil { | |
| 38 | + | sess.mu.Unlock() | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq}) | |
| 42 | + | if !sess.clipboardWorkerRunning { | |
| 43 | + | sess.clipboardWorkerRunning = true | |
| 44 | + | go d.clipboardWorker(sess) | |
| 45 | + | } | |
| 46 | + | sess.mu.Unlock() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (d *Daemon) clipboardWorker(sess *session) { | |
| 50 | + | for { | |
| 51 | + | item, ok := nextClipboardForward(sess) | |
| 52 | + | if !ok { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq) | |
| 56 | + | if err != nil { | |
| 57 | + | d.detachOnSendError(sess, item.ac, failed) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func nextClipboardForward(sess *session) (clipboardForward, bool) { | |
| 63 | + | sess.mu.Lock() | |
| 64 | + | defer sess.mu.Unlock() | |
| 65 | + | if sess.ctx != nil { | |
| 66 | + | select { | |
| 67 | + | case <-sess.ctx.Done(): | |
| 68 | + | sess.clipboardQueue = nil | |
| 69 | + | sess.clipboardWorkerRunning = false | |
| 70 | + | return clipboardForward{}, false | |
| 71 | + | default: | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | if len(sess.clipboardQueue) == 0 { | |
| 75 | + | sess.clipboardWorkerRunning = false | |
| 76 | + | return clipboardForward{}, false | |
| 77 | + | } | |
| 78 | + | item := sess.clipboardQueue[0] | |
| 79 | + | copy(sess.clipboardQueue, sess.clipboardQueue[1:]) | |
| 80 | + | var zero clipboardForward | |
| 81 | + | sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero | |
| 82 | + | sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1] | |
| 83 | + | return item, true | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | type clipboardForward struct { | |||
| 8 | + | ac *attachedClient | |||
| 9 | + | seq []byte | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set | |||
| 13 | + | // request (already captured off the pane's screen while ptyReader held pane.mu, | |||
| 14 | + | // then handed here after that lock is released) to the session's attached | |||
| 15 | + | // client, if any. Invalid base64 or an oversized decoded payload is dropped | |||
| 16 | + | // silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The | |||
| 17 | + | // caller must not hold pane.mu, tab.mu, or session.mu. | |||
| 18 | + | // | |||
| 19 | + | // Requests are queued on the session and drained by one worker so clipboard | |||
| 20 | + | // writes keep arrival order without making the PTY reader wait on client I/O. | |||
| 21 | + | func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) { | |||
| 22 | + | seq := scopy.OSC52FromBase64(b64) | |||
| 23 | + | if seq == nil { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | sess.mu.Lock() | |||
| 28 | + | if sess.ctx != nil { | |||
| 29 | + | select { | |||
| 30 | + | case <-sess.ctx.Done(): | |||
| 31 | + | sess.mu.Unlock() | |||
| 32 | + | return | |||
| 33 | + | default: | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ac := sess.client | |||
| 37 | + | if ac == nil { | |||
| 38 | + | sess.mu.Unlock() | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq}) | |||
| 42 | + | if !sess.clipboardWorkerRunning { | |||
| 43 | + | sess.clipboardWorkerRunning = true | |||
| 44 | + | go d.clipboardWorker(sess) | |||
| 45 | + | } | |||
| 46 | + | sess.mu.Unlock() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (d *Daemon) clipboardWorker(sess *session) { | |||
| 50 | + | for { | |||
| 51 | + | item, ok := nextClipboardForward(sess) | |||
| 52 | + | if !ok { | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq) | |||
| 56 | + | if err != nil { | |||
| 57 | + | d.detachOnSendError(sess, item.ac, failed) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func nextClipboardForward(sess *session) (clipboardForward, bool) { | |||
| 63 | + | sess.mu.Lock() | |||
| 64 | + | defer sess.mu.Unlock() | |||
| 65 | + | if sess.ctx != nil { | |||
| 66 | + | select { | |||
| 67 | + | case <-sess.ctx.Done(): | |||
| 68 | + | sess.clipboardQueue = nil | |||
| 69 | + | sess.clipboardWorkerRunning = false | |||
| 70 | + | return clipboardForward{}, false | |||
| 71 | + | default: | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | if len(sess.clipboardQueue) == 0 { | |||
| 75 | + | sess.clipboardWorkerRunning = false | |||
| 76 | + | return clipboardForward{}, false | |||
| 77 | + | } | |||
| 78 | + | item := sess.clipboardQueue[0] | |||
| 79 | + | copy(sess.clipboardQueue, sess.clipboardQueue[1:]) | |||
| 80 | + | var zero clipboardForward | |||
| 81 | + | sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero | |||
| 82 | + | sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1] | |||
| 83 | + | return item, true | |||
| 84 | + | } | |||
A internal/usecase/daemon/clipboard_test.go +194 -0
| @@ -0,0 +1,194 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "io" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 17 | + | "github.com/bnema/vev/pkg/renderer" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then | |
| 21 | + | // reports io.EOF — driving ptyReader through exactly len(chunks) reads before | |
| 22 | + | // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY. | |
| 23 | + | func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY { | |
| 24 | + | t.Helper() | |
| 25 | + | p := portsmocks.NewMockPTY(t) | |
| 26 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 27 | + | if len(chunks) == 0 { | |
| 28 | + | return 0, io.EOF | |
| 29 | + | } | |
| 30 | + | n := copy(buf, chunks[0]) | |
| 31 | + | if n == len(chunks[0]) { | |
| 32 | + | chunks = chunks[1:] | |
| 33 | + | } else { | |
| 34 | + | chunks[0] = chunks[0][n:] | |
| 35 | + | } | |
| 36 | + | return n, nil | |
| 37 | + | }) | |
| 38 | + | p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() | |
| 39 | + | p.EXPECT().Close().Return(nil).Maybe() | |
| 40 | + | return p | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |
| 44 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 45 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07")) | |
| 46 | + | ||
| 47 | + | tr, sends := newCapturingTransport(t) | |
| 48 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 49 | + | defer cancel() | |
| 50 | + | win := newTestTabWithContext(p, sctx, cancel) | |
| 51 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 52 | + | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 53 | + | ac.setSession(sess) | |
| 54 | + | d.sessions[sess.id] = sess | |
| 55 | + | ||
| 56 | + | d.sessWg.Add(1) | |
| 57 | + | d.ptyReader(sess, win, win.focusedPane()) | |
| 58 | + | ||
| 59 | + | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 60 | + | out, err := ports.UnmarshalOutput(f.Payload) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |
| 66 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 67 | + | big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1) | |
| 68 | + | b64 := base64.StdEncoding.EncodeToString([]byte(big)) | |
| 69 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07")) | |
| 70 | + | ||
| 71 | + | tr, sends := newCapturingTransport(t) | |
| 72 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 73 | + | defer cancel() | |
| 74 | + | win := newTestTabWithContext(p, sctx, cancel) | |
| 75 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 76 | + | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 77 | + | ac.setSession(sess) | |
| 78 | + | d.sessions[sess.id] = sess | |
| 79 | + | ||
| 80 | + | d.sessWg.Add(1) | |
| 81 | + | d.ptyReader(sess, win, win.focusedPane()) | |
| 82 | + | ||
| 83 | + | select { | |
| 84 | + | case f := <-sends: | |
| 85 | + | require.NotEqual(t, ports.MsgOutput, f.Type) | |
| 86 | + | default: | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |
| 91 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 92 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07")) | |
| 93 | + | ||
| 94 | + | tr, sends := newCapturingTransport(t) | |
| 95 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 96 | + | defer cancel() | |
| 97 | + | win := newTestTabWithContext(p, sctx, cancel) | |
| 98 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 99 | + | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 100 | + | ac.setSession(sess) | |
| 101 | + | d.sessions[sess.id] = sess | |
| 102 | + | ||
| 103 | + | d.sessWg.Add(1) | |
| 104 | + | d.ptyReader(sess, win, win.focusedPane()) | |
| 105 | + | ||
| 106 | + | select { | |
| 107 | + | case f := <-sends: | |
| 108 | + | require.NotEqual(t, ports.MsgOutput, f.Type) | |
| 109 | + | default: | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |
| 114 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 115 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07")) | |
| 116 | + | ||
| 117 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 118 | + | defer cancel() | |
| 119 | + | win := newTestTabWithContext(p, sctx, cancel) | |
| 120 | + | sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 121 | + | d.sessions[sess.id] = sess | |
| 122 | + | ||
| 123 | + | d.sessWg.Add(1) | |
| 124 | + | require.NotPanics(t, func() { | |
| 125 | + | d.ptyReader(sess, win, win.focusedPane()) | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |
| 130 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 131 | + | tr := newBlockingClipboardTransport() | |
| 132 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 133 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 134 | + | defer cancel() | |
| 135 | + | sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac} | |
| 136 | + | ac.setSession(sess) | |
| 137 | + | ||
| 138 | + | first := base64.StdEncoding.EncodeToString([]byte("first")) | |
| 139 | + | second := base64.StdEncoding.EncodeToString([]byte("second")) | |
| 140 | + | d.forwardClipboardAsync(sess, first) | |
| 141 | + | require.Equal(t, "first", <-tr.started) | |
| 142 | + | ||
| 143 | + | d.forwardClipboardAsync(sess, second) | |
| 144 | + | select { | |
| 145 | + | case got := <-tr.started: | |
| 146 | + | t.Fatalf("second clipboard send started before first completed: %q", got) | |
| 147 | + | case <-time.After(50 * time.Millisecond): | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | close(tr.releaseFirst) | |
| 151 | + | require.Equal(t, "first", <-tr.sent) | |
| 152 | + | require.Equal(t, "second", <-tr.started) | |
| 153 | + | require.Equal(t, "second", <-tr.sent) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | type blockingClipboardTransport struct { | |
| 157 | + | started chan string | |
| 158 | + | sent chan string | |
| 159 | + | releaseFirst chan struct{} | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func newBlockingClipboardTransport() *blockingClipboardTransport { | |
| 163 | + | return &blockingClipboardTransport{ | |
| 164 | + | started: make(chan string, 2), | |
| 165 | + | sent: make(chan string, 2), | |
| 166 | + | releaseFirst: make(chan struct{}), | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (tr *blockingClipboardTransport) Send(f ports.Frame) error { | |
| 171 | + | out, err := ports.UnmarshalOutput(f.Payload) | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | data := string(out.Data) | |
| 176 | + | var label string | |
| 177 | + | switch { | |
| 178 | + | case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))): | |
| 179 | + | label = "first" | |
| 180 | + | case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))): | |
| 181 | + | label = "second" | |
| 182 | + | default: | |
| 183 | + | label = data | |
| 184 | + | } | |
| 185 | + | tr.started <- label | |
| 186 | + | if label == "first" { | |
| 187 | + | <-tr.releaseFirst | |
| 188 | + | } | |
| 189 | + | tr.sent <- label | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 194 | + | func (tr *blockingClipboardTransport) Close() error { return nil } | |
| @@ -0,0 +1,194 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 16 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 17 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then | |||
| 21 | + | // reports io.EOF — driving ptyReader through exactly len(chunks) reads before | |||
| 22 | + | // it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY. | |||
| 23 | + | func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY { | |||
| 24 | + | t.Helper() | |||
| 25 | + | p := portsmocks.NewMockPTY(t) | |||
| 26 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 27 | + | if len(chunks) == 0 { | |||
| 28 | + | return 0, io.EOF | |||
| 29 | + | } | |||
| 30 | + | n := copy(buf, chunks[0]) | |||
| 31 | + | if n == len(chunks[0]) { | |||
| 32 | + | chunks = chunks[1:] | |||
| 33 | + | } else { | |||
| 34 | + | chunks[0] = chunks[0][n:] | |||
| 35 | + | } | |||
| 36 | + | return n, nil | |||
| 37 | + | }) | |||
| 38 | + | p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() | |||
| 39 | + | p.EXPECT().Close().Return(nil).Maybe() | |||
| 40 | + | return p | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 44 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 45 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07")) | |||
| 46 | + | ||||
| 47 | + | tr, sends := newCapturingTransport(t) | |||
| 48 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 49 | + | defer cancel() | |||
| 50 | + | win := newTestTabWithContext(p, sctx, cancel) | |||
| 51 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |||
| 52 | + | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 53 | + | ac.setSession(sess) | |||
| 54 | + | d.sessions[sess.id] = sess | |||
| 55 | + | ||||
| 56 | + | d.sessWg.Add(1) | |||
| 57 | + | d.ptyReader(sess, win, win.focusedPane()) | |||
| 58 | + | ||||
| 59 | + | f := awaitFrame(t, sends, ports.MsgOutput) | |||
| 60 | + | out, err := ports.UnmarshalOutput(f.Payload) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||
| 66 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 67 | + | big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1) | |||
| 68 | + | b64 := base64.StdEncoding.EncodeToString([]byte(big)) | |||
| 69 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07")) | |||
| 70 | + | ||||
| 71 | + | tr, sends := newCapturingTransport(t) | |||
| 72 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 73 | + | defer cancel() | |||
| 74 | + | win := newTestTabWithContext(p, sctx, cancel) | |||
| 75 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |||
| 76 | + | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 77 | + | ac.setSession(sess) | |||
| 78 | + | d.sessions[sess.id] = sess | |||
| 79 | + | ||||
| 80 | + | d.sessWg.Add(1) | |||
| 81 | + | d.ptyReader(sess, win, win.focusedPane()) | |||
| 82 | + | ||||
| 83 | + | select { | |||
| 84 | + | case f := <-sends: | |||
| 85 | + | require.NotEqual(t, ports.MsgOutput, f.Type) | |||
| 86 | + | default: | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||
| 91 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 92 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07")) | |||
| 93 | + | ||||
| 94 | + | tr, sends := newCapturingTransport(t) | |||
| 95 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 96 | + | defer cancel() | |||
| 97 | + | win := newTestTabWithContext(p, sctx, cancel) | |||
| 98 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |||
| 99 | + | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 100 | + | ac.setSession(sess) | |||
| 101 | + | d.sessions[sess.id] = sess | |||
| 102 | + | ||||
| 103 | + | d.sessWg.Add(1) | |||
| 104 | + | d.ptyReader(sess, win, win.focusedPane()) | |||
| 105 | + | ||||
| 106 | + | select { | |||
| 107 | + | case f := <-sends: | |||
| 108 | + | require.NotEqual(t, ports.MsgOutput, f.Type) | |||
| 109 | + | default: | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||
| 114 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 115 | + | p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07")) | |||
| 116 | + | ||||
| 117 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 118 | + | defer cancel() | |||
| 119 | + | win := newTestTabWithContext(p, sctx, cancel) | |||
| 120 | + | sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |||
| 121 | + | d.sessions[sess.id] = sess | |||
| 122 | + | ||||
| 123 | + | d.sessWg.Add(1) | |||
| 124 | + | require.NotPanics(t, func() { | |||
| 125 | + | d.ptyReader(sess, win, win.focusedPane()) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |||
| 130 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 131 | + | tr := newBlockingClipboardTransport() | |||
| 132 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |||
| 133 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 134 | + | defer cancel() | |||
| 135 | + | sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac} | |||
| 136 | + | ac.setSession(sess) | |||
| 137 | + | ||||
| 138 | + | first := base64.StdEncoding.EncodeToString([]byte("first")) | |||
| 139 | + | second := base64.StdEncoding.EncodeToString([]byte("second")) | |||
| 140 | + | d.forwardClipboardAsync(sess, first) | |||
| 141 | + | require.Equal(t, "first", <-tr.started) | |||
| 142 | + | ||||
| 143 | + | d.forwardClipboardAsync(sess, second) | |||
| 144 | + | select { | |||
| 145 | + | case got := <-tr.started: | |||
| 146 | + | t.Fatalf("second clipboard send started before first completed: %q", got) | |||
| 147 | + | case <-time.After(50 * time.Millisecond): | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | close(tr.releaseFirst) | |||
| 151 | + | require.Equal(t, "first", <-tr.sent) | |||
| 152 | + | require.Equal(t, "second", <-tr.started) | |||
| 153 | + | require.Equal(t, "second", <-tr.sent) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | type blockingClipboardTransport struct { | |||
| 157 | + | started chan string | |||
| 158 | + | sent chan string | |||
| 159 | + | releaseFirst chan struct{} | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func newBlockingClipboardTransport() *blockingClipboardTransport { | |||
| 163 | + | return &blockingClipboardTransport{ | |||
| 164 | + | started: make(chan string, 2), | |||
| 165 | + | sent: make(chan string, 2), | |||
| 166 | + | releaseFirst: make(chan struct{}), | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func (tr *blockingClipboardTransport) Send(f ports.Frame) error { | |||
| 171 | + | out, err := ports.UnmarshalOutput(f.Payload) | |||
| 172 | + | if err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | data := string(out.Data) | |||
| 176 | + | var label string | |||
| 177 | + | switch { | |||
| 178 | + | case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))): | |||
| 179 | + | label = "first" | |||
| 180 | + | case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))): | |||
| 181 | + | label = "second" | |||
| 182 | + | default: | |||
| 183 | + | label = data | |||
| 184 | + | } | |||
| 185 | + | tr.started <- label | |||
| 186 | + | if label == "first" { | |||
| 187 | + | <-tr.releaseFirst | |||
| 188 | + | } | |||
| 189 | + | tr.sent <- label | |||
| 190 | + | return nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 194 | + | func (tr *blockingClipboardTransport) Close() error { return nil } | |||
M internal/usecase/daemon/render.go +8 -0
| @@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 54 | 54 | } | |
| 55 | 55 | buf := make([]byte, ptyReadBufSize) | |
| 56 | 56 | var resp []byte | |
| 57 | + | var clipboards []string | |
| 57 | 58 | attentionCh := make(chan struct{}, 1) | |
| 58 | 59 | p.mu.Lock() | |
| 59 | 60 | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 60 | 61 | p.screen.OnBell = func() { signal(attentionCh) } | |
| 61 | 62 | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 63 | + | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | |
| 62 | 64 | p.mu.Unlock() | |
| 63 | 65 | for { | |
| 64 | 66 | n, err := p.pty.Read(buf) | |
| @@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 74 | 76 | d.noteAttention(sess, tb) | |
| 75 | 77 | default: | |
| 76 | 78 | } | |
| 79 | + | if len(clipboards) > 0 { | |
| 80 | + | for _, b64 := range clipboards { | |
| 81 | + | d.forwardClipboardAsync(sess, b64) | |
| 82 | + | } | |
| 83 | + | clipboards = clipboards[:0] | |
| 84 | + | } | |
| 77 | 85 | if len(resp) > 0 { | |
| 78 | 86 | if _, writeErr := p.pty.Write(resp); writeErr != nil { | |
| 79 | 87 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |
| @@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 54 | } | 54 | } | ||
| 55 | buf := make([]byte, ptyReadBufSize) | 55 | buf := make([]byte, ptyReadBufSize) | ||
| 56 | var resp []byte | 56 | var resp []byte | ||
| 57 | + | var clipboards []string | |||
| 57 | attentionCh := make(chan struct{}, 1) | 58 | attentionCh := make(chan struct{}, 1) | ||
| 58 | p.mu.Lock() | 59 | p.mu.Lock() | ||
| 59 | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | 60 | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | ||
| 60 | p.screen.OnBell = func() { signal(attentionCh) } | 61 | p.screen.OnBell = func() { signal(attentionCh) } | ||
| 61 | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | 62 | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | ||
| 63 | + | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | |||
| 62 | p.mu.Unlock() | 64 | p.mu.Unlock() | ||
| 63 | for { | 65 | for { | ||
| 64 | n, err := p.pty.Read(buf) | 66 | n, err := p.pty.Read(buf) | ||
| @@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 74 | d.noteAttention(sess, tb) | 76 | d.noteAttention(sess, tb) | ||
| 75 | default: | 77 | default: | ||
| 76 | } | 78 | } | ||
| 79 | + | if len(clipboards) > 0 { | |||
| 80 | + | for _, b64 := range clipboards { | |||
| 81 | + | d.forwardClipboardAsync(sess, b64) | |||
| 82 | + | } | |||
| 83 | + | clipboards = clipboards[:0] | |||
| 84 | + | } | |||
| 77 | if len(resp) > 0 { | 85 | if len(resp) > 0 { | ||
| 78 | if _, writeErr := p.pty.Write(resp); writeErr != nil { | 86 | if _, writeErr := p.pty.Write(resp); writeErr != nil { | ||
| 79 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | 87 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | ||
M internal/usecase/daemon/session.go +8 -6
| @@ -47,12 +47,14 @@ type session struct { | |||
| 47 | 47 | ctx context.Context | |
| 48 | 48 | cancel context.CancelFunc | |
| 49 | 49 | ||
| 50 | - | mu sync.Mutex // guards tabs, active, and client | |
| 51 | - | tabs []*tab | |
| 52 | - | active int | |
| 53 | - | client *attachedClient | |
| 54 | - | cwd string | |
| 55 | - | createdAt int64 | |
| 50 | + | mu sync.Mutex // guards tabs, active, client, and clipboard queue state | |
| 51 | + | tabs []*tab | |
| 52 | + | active int | |
| 53 | + | client *attachedClient | |
| 54 | + | clipboardQueue []clipboardForward | |
| 55 | + | clipboardWorkerRunning bool | |
| 56 | + | cwd string | |
| 57 | + | createdAt int64 | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | |
| @@ -47,12 +47,14 @@ type session struct { | |||||
| 47 | ctx context.Context | 47 | ctx context.Context | ||
| 48 | cancel context.CancelFunc | 48 | cancel context.CancelFunc | ||
| 49 | 49 | ||||
| 50 | - | mu sync.Mutex // guards tabs, active, and client | 50 | + | mu sync.Mutex // guards tabs, active, client, and clipboard queue state |
| 51 | - | tabs []*tab | 51 | + | tabs []*tab |
| 52 | - | active int | 52 | + | active int |
| 53 | - | client *attachedClient | 53 | + | client *attachedClient |
| 54 | - | cwd string | 54 | + | clipboardQueue []clipboardForward |
| 55 | - | createdAt int64 | 55 | + | clipboardWorkerRunning bool |
| 56 | + | cwd string | |||
| 57 | + | createdAt int64 | |||
| 56 | } | 58 | } | ||
| 57 | 59 | ||||
| 58 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | 60 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | ||
M pkg/vt/screen.go +40 -7
| @@ -25,7 +25,11 @@ type screenState struct { | |||
| 25 | 25 | savedCursor cursorState | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | const maxEscapeBufferLen = 4096 | |
| 28 | + | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | |
| 29 | + | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | |
| 30 | + | // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy, | |
| 31 | + | // so keep this value in sync if that payload cap changes. | |
| 32 | + | const maxEscapeBufferLen = 128 * 1024 | |
| 29 | 33 | ||
| 30 | 34 | type Screen struct { | |
| 31 | 35 | Frame renderer.Frame | |
| @@ -45,8 +49,14 @@ type Screen struct { | |||
| 45 | 49 | OnBell func() | |
| 46 | 50 | // OnNotify is called synchronously from Write for explicit terminal | |
| 47 | 51 | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | |
| 48 | - | // All other OSC payloads remain discarded. Nil disables it. | |
| 52 | + | // Other non-clipboard OSC payloads remain discarded. Nil disables it. | |
| 49 | 53 | OnNotify func(title, body string) | |
| 54 | + | // OnClipboard is called synchronously from Write for a complete OSC 52 | |
| 55 | + | // clipboard set request from the child. The OSC 52 selection field is | |
| 56 | + | // accepted but ignored; the callback receives only the raw base64 payload. | |
| 57 | + | // Clipboard queries (data == "?") and malformed payloads are ignored and | |
| 58 | + | // never invoke it. Nil disables it. | |
| 59 | + | OnClipboard func(b64 string) | |
| 50 | 60 | ||
| 51 | 61 | defaultFG renderer.RGB | |
| 52 | 62 | defaultBG renderer.RGB | |
| @@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||
| 625 | 635 | } | |
| 626 | 636 | ||
| 627 | 637 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | |
| 628 | - | // terminator). Only notification sequences are acted on; titles, clipboard | |
| 629 | - | // and every other OSC are still discarded. | |
| 638 | + | // terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard | |
| 639 | + | // set requests (OSC 52) are acted on; titles and every other OSC are still | |
| 640 | + | // discarded. | |
| 630 | 641 | func (s *Screen) handleOSC(payload []byte) { | |
| 642 | + | if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' { | |
| 643 | + | s.handleOSC52(string(payload[len("52;"):])) | |
| 644 | + | return | |
| 645 | + | } | |
| 631 | 646 | if s.OnNotify == nil { | |
| 632 | 647 | return | |
| 633 | 648 | } | |
| @@ -654,6 +669,24 @@ func (s *Screen) handleOSC(payload []byte) { | |||
| 654 | 669 | } | |
| 655 | 670 | } | |
| 656 | 671 | ||
| 672 | + | // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52 | |
| 673 | + | // clipboard payload (selection may be empty; split on the first ";"). A | |
| 674 | + | // clipboard query (data == "?") is always ignored — vev never answers | |
| 675 | + | // clipboard queries. A payload with no second ";" is malformed and ignored. | |
| 676 | + | func (s *Screen) handleOSC52(rest string) { | |
| 677 | + | idx := strings.IndexByte(rest, ';') | |
| 678 | + | if idx < 0 { | |
| 679 | + | return | |
| 680 | + | } | |
| 681 | + | data := rest[idx+1:] | |
| 682 | + | if data == "?" { | |
| 683 | + | return | |
| 684 | + | } | |
| 685 | + | if s.OnClipboard != nil { | |
| 686 | + | s.OnClipboard(data) | |
| 687 | + | } | |
| 688 | + | } | |
| 689 | + | ||
| 657 | 690 | func consumeSTString(data []byte) (consumed int, partial bool) { | |
| 658 | 691 | for i := 2; i < len(data); i++ { | |
| 659 | 692 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| @@ -1022,11 +1055,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren | |||
| 1022 | 1055 | next := renderer.NewFrame(newW, newH) | |
| 1023 | 1056 | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |
| 1024 | 1057 | if evict != nil { | |
| 1025 | - | for y := 0; y < shift; y++ { | |
| 1058 | + | for y := range shift { | |
| 1026 | 1059 | evict(old.Row(y)) | |
| 1027 | 1060 | } | |
| 1028 | 1061 | } | |
| 1029 | - | for dy := 0; dy < newH; dy++ { | |
| 1062 | + | for dy := range newH { | |
| 1030 | 1063 | sy := dy + shift | |
| 1031 | 1064 | if sy >= old.Height { | |
| 1032 | 1065 | break | |
| @@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 1126 | 1159 | s.clampCursor() | |
| 1127 | 1160 | switch mode { | |
| 1128 | 1161 | case 1: | |
| 1129 | - | for y := 0; y <= s.Row && y < s.Frame.Height; y++ { | |
| 1162 | + | for y := range min(s.Row+1, s.Frame.Height) { | |
| 1130 | 1163 | end := s.Frame.Width | |
| 1131 | 1164 | if y == s.Row { | |
| 1132 | 1165 | end = min(s.Col+1, s.Frame.Width) | |
| @@ -25,7 +25,11 @@ type screenState struct { | |||||
| 25 | savedCursor cursorState | 25 | savedCursor cursorState | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | const maxEscapeBufferLen = 4096 | 28 | + | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads |
| 29 | + | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | |||
| 30 | + | // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy, | |||
| 31 | + | // so keep this value in sync if that payload cap changes. | |||
| 32 | + | const maxEscapeBufferLen = 128 * 1024 | |||
| 29 | 33 | ||||
| 30 | type Screen struct { | 34 | type Screen struct { | ||
| 31 | Frame renderer.Frame | 35 | Frame renderer.Frame | ||
| @@ -45,8 +49,14 @@ type Screen struct { | |||||
| 45 | OnBell func() | 49 | OnBell func() | ||
| 46 | // OnNotify is called synchronously from Write for explicit terminal | 50 | // OnNotify is called synchronously from Write for explicit terminal | ||
| 47 | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | 51 | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | ||
| 48 | - | // All other OSC payloads remain discarded. Nil disables it. | 52 | + | // Other non-clipboard OSC payloads remain discarded. Nil disables it. |
| 49 | OnNotify func(title, body string) | 53 | OnNotify func(title, body string) | ||
| 54 | + | // OnClipboard is called synchronously from Write for a complete OSC 52 | |||
| 55 | + | // clipboard set request from the child. The OSC 52 selection field is | |||
| 56 | + | // accepted but ignored; the callback receives only the raw base64 payload. | |||
| 57 | + | // Clipboard queries (data == "?") and malformed payloads are ignored and | |||
| 58 | + | // never invoke it. Nil disables it. | |||
| 59 | + | OnClipboard func(b64 string) | |||
| 50 | 60 | ||||
| 51 | defaultFG renderer.RGB | 61 | defaultFG renderer.RGB | ||
| 52 | defaultBG renderer.RGB | 62 | defaultBG renderer.RGB | ||
| @@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||||
| 625 | } | 635 | } | ||
| 626 | 636 | ||||
| 627 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | 637 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | ||
| 628 | - | // terminator). Only notification sequences are acted on; titles, clipboard | 638 | + | // terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard |
| 629 | - | // and every other OSC are still discarded. | 639 | + | // set requests (OSC 52) are acted on; titles and every other OSC are still |
| 640 | + | // discarded. | |||
| 630 | func (s *Screen) handleOSC(payload []byte) { | 641 | func (s *Screen) handleOSC(payload []byte) { | ||
| 642 | + | if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' { | |||
| 643 | + | s.handleOSC52(string(payload[len("52;"):])) | |||
| 644 | + | return | |||
| 645 | + | } | |||
| 631 | if s.OnNotify == nil { | 646 | if s.OnNotify == nil { | ||
| 632 | return | 647 | return | ||
| 633 | } | 648 | } | ||
| @@ -654,6 +669,24 @@ func (s *Screen) handleOSC(payload []byte) { | |||||
| 654 | } | 669 | } | ||
| 655 | } | 670 | } | ||
| 656 | 671 | ||||
| 672 | + | // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52 | |||
| 673 | + | // clipboard payload (selection may be empty; split on the first ";"). A | |||
| 674 | + | // clipboard query (data == "?") is always ignored — vev never answers | |||
| 675 | + | // clipboard queries. A payload with no second ";" is malformed and ignored. | |||
| 676 | + | func (s *Screen) handleOSC52(rest string) { | |||
| 677 | + | idx := strings.IndexByte(rest, ';') | |||
| 678 | + | if idx < 0 { | |||
| 679 | + | return | |||
| 680 | + | } | |||
| 681 | + | data := rest[idx+1:] | |||
| 682 | + | if data == "?" { | |||
| 683 | + | return | |||
| 684 | + | } | |||
| 685 | + | if s.OnClipboard != nil { | |||
| 686 | + | s.OnClipboard(data) | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | ||||
| 657 | func consumeSTString(data []byte) (consumed int, partial bool) { | 690 | func consumeSTString(data []byte) (consumed int, partial bool) { | ||
| 658 | for i := 2; i < len(data); i++ { | 691 | for i := 2; i < len(data); i++ { | ||
| 659 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | 692 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | ||
| @@ -1022,11 +1055,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren | |||||
| 1022 | next := renderer.NewFrame(newW, newH) | 1055 | next := renderer.NewFrame(newW, newH) | ||
| 1023 | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | 1056 | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | ||
| 1024 | if evict != nil { | 1057 | if evict != nil { | ||
| 1025 | - | for y := 0; y < shift; y++ { | 1058 | + | for y := range shift { |
| 1026 | evict(old.Row(y)) | 1059 | evict(old.Row(y)) | ||
| 1027 | } | 1060 | } | ||
| 1028 | } | 1061 | } | ||
| 1029 | - | for dy := 0; dy < newH; dy++ { | 1062 | + | for dy := range newH { |
| 1030 | sy := dy + shift | 1063 | sy := dy + shift | ||
| 1031 | if sy >= old.Height { | 1064 | if sy >= old.Height { | ||
| 1032 | break | 1065 | break | ||
| @@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||||
| 1126 | s.clampCursor() | 1159 | s.clampCursor() | ||
| 1127 | switch mode { | 1160 | switch mode { | ||
| 1128 | case 1: | 1161 | case 1: | ||
| 1129 | - | for y := 0; y <= s.Row && y < s.Frame.Height; y++ { | 1162 | + | for y := range min(s.Row+1, s.Frame.Height) { |
| 1130 | end := s.Frame.Width | 1163 | end := s.Frame.Width | ||
| 1131 | if y == s.Row { | 1164 | if y == s.Row { | ||
| 1132 | end = min(s.Col+1, s.Frame.Width) | 1165 | end = min(s.Col+1, s.Frame.Width) | ||
M pkg/vt/screen_test.go +64 -0
| @@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) { | |||
| 157 | 157 | require.Equal(t, 1, calls) | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | + | func TestOnClipboardBELTerminated(t *testing.T) { | |
| 161 | + | s := NewScreen(10, 2) | |
| 162 | + | var got string | |
| 163 | + | calls := 0 | |
| 164 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |
| 165 | + | s.Write([]byte("\x1b]52;c;aGVsbG8=\x07")) | |
| 166 | + | require.Equal(t, 1, calls) | |
| 167 | + | require.Equal(t, "aGVsbG8=", got) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestOnClipboardSTTerminated(t *testing.T) { | |
| 171 | + | s := NewScreen(10, 2) | |
| 172 | + | var got string | |
| 173 | + | calls := 0 | |
| 174 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |
| 175 | + | s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\")) | |
| 176 | + | require.Equal(t, 1, calls) | |
| 177 | + | require.Equal(t, "aGVsbG8=", got) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestOnClipboardQueryIgnored(t *testing.T) { | |
| 181 | + | s := NewScreen(10, 2) | |
| 182 | + | calls := 0 | |
| 183 | + | s.OnClipboard = func(string) { calls++ } | |
| 184 | + | s.Write([]byte("\x1b]52;c;?\x07")) | |
| 185 | + | require.Equal(t, 0, calls) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestOnClipboardEmptySelection(t *testing.T) { | |
| 189 | + | s := NewScreen(10, 2) | |
| 190 | + | var got string | |
| 191 | + | calls := 0 | |
| 192 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |
| 193 | + | s.Write([]byte("\x1b]52;;aGVsbG8=\x07")) | |
| 194 | + | require.Equal(t, 1, calls) | |
| 195 | + | require.Equal(t, "aGVsbG8=", got) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) { | |
| 199 | + | s := NewScreen(10, 2) | |
| 200 | + | require.NotPanics(t, func() { | |
| 201 | + | s.Write([]byte("\x1b]52;c;aGVsbG8=\x07")) | |
| 202 | + | }) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestOnClipboardSplitAcrossWrites(t *testing.T) { | |
| 206 | + | s := NewScreen(10, 2) | |
| 207 | + | var got string | |
| 208 | + | calls := 0 | |
| 209 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |
| 210 | + | s.Write([]byte("\x1b]52;c;aGVs")) | |
| 211 | + | s.Write([]byte("bG8=\x07")) | |
| 212 | + | require.Equal(t, 1, calls) | |
| 213 | + | require.Equal(t, "aGVsbG8=", got) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) { | |
| 217 | + | s := NewScreen(10, 2) | |
| 218 | + | calls := 0 | |
| 219 | + | s.OnClipboard = func(string) { calls++ } | |
| 220 | + | s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field | |
| 221 | + | require.Equal(t, 0, calls) | |
| 222 | + | } | |
| 223 | + | ||
| 160 | 224 | // --------------------------------------------------------------------------- | |
| 161 | 225 | // Printable ASCII / UTF-8 | |
| 162 | 226 | // --------------------------------------------------------------------------- | |
| @@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) { | |||||
| 157 | require.Equal(t, 1, calls) | 157 | require.Equal(t, 1, calls) | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | + | func TestOnClipboardBELTerminated(t *testing.T) { | |||
| 161 | + | s := NewScreen(10, 2) | |||
| 162 | + | var got string | |||
| 163 | + | calls := 0 | |||
| 164 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 165 | + | s.Write([]byte("\x1b]52;c;aGVsbG8=\x07")) | |||
| 166 | + | require.Equal(t, 1, calls) | |||
| 167 | + | require.Equal(t, "aGVsbG8=", got) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestOnClipboardSTTerminated(t *testing.T) { | |||
| 171 | + | s := NewScreen(10, 2) | |||
| 172 | + | var got string | |||
| 173 | + | calls := 0 | |||
| 174 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 175 | + | s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\")) | |||
| 176 | + | require.Equal(t, 1, calls) | |||
| 177 | + | require.Equal(t, "aGVsbG8=", got) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestOnClipboardQueryIgnored(t *testing.T) { | |||
| 181 | + | s := NewScreen(10, 2) | |||
| 182 | + | calls := 0 | |||
| 183 | + | s.OnClipboard = func(string) { calls++ } | |||
| 184 | + | s.Write([]byte("\x1b]52;c;?\x07")) | |||
| 185 | + | require.Equal(t, 0, calls) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestOnClipboardEmptySelection(t *testing.T) { | |||
| 189 | + | s := NewScreen(10, 2) | |||
| 190 | + | var got string | |||
| 191 | + | calls := 0 | |||
| 192 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 193 | + | s.Write([]byte("\x1b]52;;aGVsbG8=\x07")) | |||
| 194 | + | require.Equal(t, 1, calls) | |||
| 195 | + | require.Equal(t, "aGVsbG8=", got) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) { | |||
| 199 | + | s := NewScreen(10, 2) | |||
| 200 | + | require.NotPanics(t, func() { | |||
| 201 | + | s.Write([]byte("\x1b]52;c;aGVsbG8=\x07")) | |||
| 202 | + | }) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestOnClipboardSplitAcrossWrites(t *testing.T) { | |||
| 206 | + | s := NewScreen(10, 2) | |||
| 207 | + | var got string | |||
| 208 | + | calls := 0 | |||
| 209 | + | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 210 | + | s.Write([]byte("\x1b]52;c;aGVs")) | |||
| 211 | + | s.Write([]byte("bG8=\x07")) | |||
| 212 | + | require.Equal(t, 1, calls) | |||
| 213 | + | require.Equal(t, "aGVsbG8=", got) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) { | |||
| 217 | + | s := NewScreen(10, 2) | |||
| 218 | + | calls := 0 | |||
| 219 | + | s.OnClipboard = func(string) { calls++ } | |||
| 220 | + | s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field | |||
| 221 | + | require.Equal(t, 0, calls) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 160 | // --------------------------------------------------------------------------- | 224 | // --------------------------------------------------------------------------- | ||
| 161 | // Printable ASCII / UTF-8 | 225 | // Printable ASCII / UTF-8 | ||
| 162 | // --------------------------------------------------------------------------- | 226 | // --------------------------------------------------------------------------- | ||