fix(client): retain clipboard across remote handoffs (#235)
- commit
- 76f8a9f1778dc7ebe319059698dbff331d8f6d7f
- author
- bnema · · 2026-08-28 18:21 UTC
- committer
- GitHub · · 2026-08-28 18:21 UTC
- parent
- fd94d91
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
4 files · +13 -17
M internal/adapters/clipboard/wlpaste.go +2 -5
| @@ -1,11 +1,8 @@ | |||
| 1 | 1 | //go:build linux | |
| 2 | 2 | ||
| 3 | 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | |
| 4 | - | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | |
| 5 | - | // attach forward a locally clipped image to the daemon (see | |
| 6 | - | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an | |
| 7 | - | // xclip or OSC-52 backend could implement the same interface later without | |
| 8 | - | // touching the wire protocol. | |
| 4 | + | // wl-clipboard CLI (wl-paste). It lets a remote attach forward a locally | |
| 5 | + | // clipped image to the daemon. | |
| 9 | 6 | package clipboard | |
| 10 | 7 | ||
| 11 | 8 | import ( | |
| @@ -1,11 +1,8 @@ | |||||
| 1 | //go:build linux | 1 | //go:build linux | ||
| 2 | 2 | ||||
| 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | ||
| 4 | - | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | 4 | + | // wl-clipboard CLI (wl-paste). It lets a remote attach forward a locally |
| 5 | - | // attach forward a locally clipped image to the daemon (see | 5 | + | // clipped image to the daemon. |
| 6 | - | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an | |||
| 7 | - | // xclip or OSC-52 backend could implement the same interface later without | |||
| 8 | - | // touching the wire protocol. | |||
| 9 | package clipboard | 6 | package clipboard | ||
| 10 | 7 | ||||
| 11 | import ( | 8 | import ( | ||
M internal/app/run.go +4 -4
| @@ -811,10 +811,9 @@ type runAttachDeps struct { | |||
| 811 | 811 | runClient runClientFunc | |
| 812 | 812 | createDetached func(context.Context, string) error | |
| 813 | 813 | runtimeObserver ports.SerializedRuntimeObserver | |
| 814 | - | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |
| 815 | - | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 816 | - | // Only used for the remote-dialer branch below; local attaches never | |
| 817 | - | // intercept Ctrl+V regardless of this field. | |
| 814 | + | // clipboard reads a clipboard image on a remote route's Ctrl+V. | |
| 815 | + | // The client retains it across local-to-remote handoffs and only enables | |
| 816 | + | // interception while the active route is remote. | |
| 818 | 817 | clipboard ports.ClipboardReader | |
| 819 | 818 | // Optional remote-learning seams. | |
| 820 | 819 | stateDir func() string | |
| @@ -991,6 +990,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 991 | 990 | Dialer: sessionwire.NewClientDialer(localDialer()), | |
| 992 | 991 | Terminal: term.New(), | |
| 993 | 992 | Clock: clock.New(), | |
| 993 | + | Clipboard: deps.clipboard, | |
| 994 | 994 | Logger: log, | |
| 995 | 995 | RuntimeObserver: deps.runtimeObserver, | |
| 996 | 996 | AttachHandoff: handoff, | |
| @@ -811,10 +811,9 @@ type runAttachDeps struct { | |||||
| 811 | runClient runClientFunc | 811 | runClient runClientFunc | ||
| 812 | createDetached func(context.Context, string) error | 812 | createDetached func(context.Context, string) error | ||
| 813 | runtimeObserver ports.SerializedRuntimeObserver | 813 | runtimeObserver ports.SerializedRuntimeObserver | ||
| 814 | - | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | 814 | + | // clipboard reads a clipboard image on a remote route's Ctrl+V. |
| 815 | - | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | 815 | + | // The client retains it across local-to-remote handoffs and only enables |
| 816 | - | // Only used for the remote-dialer branch below; local attaches never | 816 | + | // interception while the active route is remote. |
| 817 | - | // intercept Ctrl+V regardless of this field. | |||
| 818 | clipboard ports.ClipboardReader | 817 | clipboard ports.ClipboardReader | ||
| 819 | // Optional remote-learning seams. | 818 | // Optional remote-learning seams. | ||
| 820 | stateDir func() string | 819 | stateDir func() string | ||
| @@ -991,6 +990,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 991 | Dialer: sessionwire.NewClientDialer(localDialer()), | 990 | Dialer: sessionwire.NewClientDialer(localDialer()), | ||
| 992 | Terminal: term.New(), | 991 | Terminal: term.New(), | ||
| 993 | Clock: clock.New(), | 992 | Clock: clock.New(), | ||
| 993 | + | Clipboard: deps.clipboard, | |||
| 994 | Logger: log, | 994 | Logger: log, | ||
| 995 | RuntimeObserver: deps.runtimeObserver, | 995 | RuntimeObserver: deps.runtimeObserver, | ||
| 996 | AttachHandoff: handoff, | 996 | AttachHandoff: handoff, | ||
M internal/app/run_test.go +5 -4
| @@ -1065,12 +1065,13 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |||
| 1065 | 1065 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |
| 1066 | 1066 | var gotDialer string | |
| 1067 | 1067 | gotRemote := true | |
| 1068 | - | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |
| 1068 | + | var gotClipboard ports.ClipboardReader | |
| 1069 | + | clip := &fakeClipboardReader{} | |
| 1069 | 1070 | factory := newRemoteDialerFactoryMock(t) | |
| 1070 | 1071 | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 1071 | 1072 | localDialer: func() wire.Dialer { return namedDialer{name: "local"} }, | |
| 1072 | 1073 | remoteDialerFactory: factory.DialerForRemote, | |
| 1073 | - | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | |
| 1074 | + | clipboard: clip, | |
| 1074 | 1075 | runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 1075 | 1076 | requireNamedClientDialer(t, ctx, deps.Dialer, "local") | |
| 1076 | 1077 | gotDialer = "local" | |
| @@ -1094,8 +1095,8 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 1094 | 1095 | if gotRemote { | |
| 1095 | 1096 | t.Fatal("local attach must pass remote=false to runClient") | |
| 1096 | 1097 | } | |
| 1097 | - | if gotClipboard != nil { | |
| 1098 | - | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |
| 1098 | + | if gotClipboard != ports.ClipboardReader(clip) { | |
| 1099 | + | t.Fatalf("local attach must retain the ClipboardReader for later remote handoffs, got %#v", gotClipboard) | |
| 1099 | 1100 | } | |
| 1100 | 1101 | } | |
| 1101 | 1102 | ||
| @@ -1065,12 +1065,13 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |||||
| 1065 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | 1065 | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | ||
| 1066 | var gotDialer string | 1066 | var gotDialer string | ||
| 1067 | gotRemote := true | 1067 | gotRemote := true | ||
| 1068 | - | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | 1068 | + | var gotClipboard ports.ClipboardReader |
| 1069 | + | clip := &fakeClipboardReader{} | |||
| 1069 | factory := newRemoteDialerFactoryMock(t) | 1070 | factory := newRemoteDialerFactoryMock(t) | ||
| 1070 | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | 1071 | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | ||
| 1071 | localDialer: func() wire.Dialer { return namedDialer{name: "local"} }, | 1072 | localDialer: func() wire.Dialer { return namedDialer{name: "local"} }, | ||
| 1072 | remoteDialerFactory: factory.DialerForRemote, | 1073 | remoteDialerFactory: factory.DialerForRemote, | ||
| 1073 | - | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | 1074 | + | clipboard: clip, |
| 1074 | runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error { | 1075 | runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error { | ||
| 1075 | requireNamedClientDialer(t, ctx, deps.Dialer, "local") | 1076 | requireNamedClientDialer(t, ctx, deps.Dialer, "local") | ||
| 1076 | gotDialer = "local" | 1077 | gotDialer = "local" | ||
| @@ -1094,8 +1095,8 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 1094 | if gotRemote { | 1095 | if gotRemote { | ||
| 1095 | t.Fatal("local attach must pass remote=false to runClient") | 1096 | t.Fatal("local attach must pass remote=false to runClient") | ||
| 1096 | } | 1097 | } | ||
| 1097 | - | if gotClipboard != nil { | 1098 | + | if gotClipboard != ports.ClipboardReader(clip) { |
| 1098 | - | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | 1099 | + | t.Fatalf("local attach must retain the ClipboardReader for later remote handoffs, got %#v", gotClipboard) |
| 1099 | } | 1100 | } | ||
| 1100 | } | 1101 | } | ||
| 1101 | 1102 | ||||
M internal/ports/clipboard.go +2 -4
| @@ -10,11 +10,9 @@ import ( | |||
| 10 | 10 | // forwarding whatever keystroke triggered the read. | |
| 11 | 11 | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | |
| 12 | 12 | ||
| 13 | - | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |
| 13 | + | // ClipboardReader reads an image off the client-side system clipboard for | |
| 14 | 14 | // forwarding to a remote session's focused pane. Implementations live in | |
| 15 | - | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |
| 16 | - | // see this interface. See | |
| 17 | - | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md. | |
| 15 | + | // internal/adapters; use cases only see this interface. | |
| 18 | 16 | type ClipboardReader interface { | |
| 19 | 17 | // ReadImage returns the clipboard's image bytes and MIME type. It returns | |
| 20 | 18 | // ErrNoClipboardImage when the clipboard holds no image (or an | |
| @@ -10,11 +10,9 @@ import ( | |||||
| 10 | // forwarding whatever keystroke triggered the read. | 10 | // forwarding whatever keystroke triggered the read. | ||
| 11 | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | 11 | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | ||
| 12 | 12 | ||||
| 13 | - | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | 13 | + | // ClipboardReader reads an image off the client-side system clipboard for |
| 14 | // forwarding to a remote session's focused pane. Implementations live in | 14 | // forwarding to a remote session's focused pane. Implementations live in | ||
| 15 | - | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | 15 | + | // internal/adapters; use cases only see this interface. |
| 16 | - | // see this interface. See | |||
| 17 | - | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md. | |||
| 18 | type ClipboardReader interface { | 16 | type ClipboardReader interface { | ||
| 19 | // ReadImage returns the clipboard's image bytes and MIME type. It returns | 17 | // ReadImage returns the clipboard's image bytes and MIME type. It returns | ||
| 20 | // ErrNoClipboardImage when the clipboard holds no image (or an | 18 | // ErrNoClipboardImage when the clipboard holds no image (or an | ||