feat(protocol): select an attached tab in place
Add the SelectTab client message so the client picker can switch the live attachment to another tab of the same session without reconnecting. The daemon moves only the requesting attachment's view and refuses an unknown tab without repairing the view. Bumps protocol.Version 58 -> 59; this is the plan's single bump, so later layout changes in this plan fold into 59.
- commit
- 11ffa7d3869a6541396c757091e306e6007e336d
- author
- brice · · 2026-09-22 20:07 UTC
- parent
- ee2521c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +375 -115
M internal/adapters/sessionwire/client_test.go +1 -0
| @@ -34,6 +34,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 34 | 34 | {name: "reset", message: protocol.OutputResetRequest{}}, | |
| 35 | 35 | {name: "UI fence", message: protocol.UIFence{ActionID: 7}}, | |
| 36 | 36 | {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}}, | |
| 37 | + | {name: "select tab", message: protocol.SelectTab{TabID: "tab-2"}}, | |
| 37 | 38 | {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | |
| 38 | 39 | {name: "attention", message: protocol.RouteAttentionSubscription{}}, | |
| 39 | 40 | {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}}, | |
| @@ -34,6 +34,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||||
| 34 | {name: "reset", message: protocol.OutputResetRequest{}}, | 34 | {name: "reset", message: protocol.OutputResetRequest{}}, | ||
| 35 | {name: "UI fence", message: protocol.UIFence{ActionID: 7}}, | 35 | {name: "UI fence", message: protocol.UIFence{ActionID: 7}}, | ||
| 36 | {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}}, | 36 | {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}}, | ||
| 37 | + | {name: "select tab", message: protocol.SelectTab{TabID: "tab-2"}}, | |||
| 37 | {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | 38 | {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | ||
| 38 | {name: "attention", message: protocol.RouteAttentionSubscription{}}, | 39 | {name: "attention", message: protocol.RouteAttentionSubscription{}}, | ||
| 39 | {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}}, | 40 | {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}}, | ||
M internal/adapters/sessionwire/proto_convert_misc.go +18 -0
| @@ -325,6 +325,24 @@ func uiFenceFromWire(message *wire.UIFence) (protocol.UIFence, error) { | |||
| 325 | 325 | return protocol.UIFence{ActionID: message.GetActionId()}, nil | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | + | func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) { | |
| 329 | + | if err := message.Validate(); err != nil { | |
| 330 | + | return nil, err | |
| 331 | + | } | |
| 332 | + | return &wire.SelectTab{TabId: string(message.TabID)}, nil | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) { | |
| 336 | + | if message == nil { | |
| 337 | + | return protocol.SelectTab{}, errProtoConvertRange | |
| 338 | + | } | |
| 339 | + | selected := protocol.SelectTab{TabID: domain.TabStableID(message.GetTabId())} | |
| 340 | + | if err := selected.Validate(); err != nil { | |
| 341 | + | return protocol.SelectTab{}, err | |
| 342 | + | } | |
| 343 | + | return selected, nil | |
| 344 | + | } | |
| 345 | + | ||
| 328 | 346 | func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) { | |
| 329 | 347 | if err := message.Validate(); err != nil { | |
| 330 | 348 | return nil, err | |
| @@ -325,6 +325,24 @@ func uiFenceFromWire(message *wire.UIFence) (protocol.UIFence, error) { | |||||
| 325 | return protocol.UIFence{ActionID: message.GetActionId()}, nil | 325 | return protocol.UIFence{ActionID: message.GetActionId()}, nil | ||
| 326 | } | 326 | } | ||
| 327 | 327 | ||||
| 328 | + | func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) { | |||
| 329 | + | if err := message.Validate(); err != nil { | |||
| 330 | + | return nil, err | |||
| 331 | + | } | |||
| 332 | + | return &wire.SelectTab{TabId: string(message.TabID)}, nil | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) { | |||
| 336 | + | if message == nil { | |||
| 337 | + | return protocol.SelectTab{}, errProtoConvertRange | |||
| 338 | + | } | |||
| 339 | + | selected := protocol.SelectTab{TabID: domain.TabStableID(message.GetTabId())} | |||
| 340 | + | if err := selected.Validate(); err != nil { | |||
| 341 | + | return protocol.SelectTab{}, err | |||
| 342 | + | } | |||
| 343 | + | return selected, nil | |||
| 344 | + | } | |||
| 345 | + | ||||
| 328 | func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) { | 346 | func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) { | ||
| 329 | if err := message.Validate(); err != nil { | 347 | if err := message.Validate(); err != nil { | ||
| 330 | return nil, err | 348 | return nil, err | ||
M internal/adapters/sessionwire/proto_convert_test.go +42 -0
| @@ -50,6 +50,7 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||
| 50 | 50 | protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, | |
| 51 | 51 | protocol.OutputResetRequest{}, | |
| 52 | 52 | protocol.UIFence{ActionID: 7}, | |
| 53 | + | protocol.SelectTab{TabID: "tab-2"}, | |
| 53 | 54 | protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, | |
| 54 | 55 | protocol.RouteAttentionSubscription{}, | |
| 55 | 56 | protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}}, | |
| @@ -250,6 +251,47 @@ func TestProtoScanNegatives(t *testing.T) { | |||
| 250 | 251 | require.NoError(t, proto.Unmarshal(duplicate, envelope)) | |
| 251 | 252 | } | |
| 252 | 253 | ||
| 254 | + | // TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses | |
| 255 | + | // a malformed tab identity, a truncated prefix, and trailing garbage. | |
| 256 | + | func TestProtoSelectTabWire(t *testing.T) { | |
| 257 | + | envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"}) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | raw, err := proto.Marshal(envelope) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | // field 31 (length-delimited) -> SelectTab{field 1 = "t1"}. | |
| 262 | + | require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw) | |
| 263 | + | ||
| 264 | + | tests := []struct { | |
| 265 | + | name string | |
| 266 | + | raw []byte | |
| 267 | + | wantErr bool | |
| 268 | + | }{ | |
| 269 | + | {name: "exact", raw: raw}, | |
| 270 | + | {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true}, | |
| 271 | + | {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true}, | |
| 272 | + | } | |
| 273 | + | for _, tt := range tests { | |
| 274 | + | t.Run(tt.name, func(t *testing.T) { | |
| 275 | + | err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw) | |
| 276 | + | if tt.wantErr { | |
| 277 | + | require.Error(t, err) | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | require.NoError(t, err) | |
| 281 | + | decoded := &wire.ClientEnvelope{} | |
| 282 | + | require.NoError(t, proto.Unmarshal(tt.raw, decoded)) | |
| 283 | + | got, err := decodeProtoClient(decoded) | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | require.Equal(t, protocol.SelectTab{TabID: "t1"}, got) | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | _, err = encodeProtoClient(protocol.SelectTab{}) | |
| 290 | + | require.Error(t, err, "an empty tab identity is never encoded") | |
| 291 | + | _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}}) | |
| 292 | + | require.Error(t, err, "an empty tab identity is never decoded") | |
| 293 | + | } | |
| 294 | + | ||
| 253 | 295 | func protoMessageName(message any) string { | |
| 254 | 296 | return protoShortName(message) | |
| 255 | 297 | } | |
| @@ -50,6 +50,7 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||||
| 50 | protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, | 50 | protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, | ||
| 51 | protocol.OutputResetRequest{}, | 51 | protocol.OutputResetRequest{}, | ||
| 52 | protocol.UIFence{ActionID: 7}, | 52 | protocol.UIFence{ActionID: 7}, | ||
| 53 | + | protocol.SelectTab{TabID: "tab-2"}, | |||
| 53 | protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, | 54 | protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, | ||
| 54 | protocol.RouteAttentionSubscription{}, | 55 | protocol.RouteAttentionSubscription{}, | ||
| 55 | protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}}, | 56 | protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}}, | ||
| @@ -250,6 +251,47 @@ func TestProtoScanNegatives(t *testing.T) { | |||||
| 250 | require.NoError(t, proto.Unmarshal(duplicate, envelope)) | 251 | require.NoError(t, proto.Unmarshal(duplicate, envelope)) | ||
| 251 | } | 252 | } | ||
| 252 | 253 | ||||
| 254 | + | // TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses | |||
| 255 | + | // a malformed tab identity, a truncated prefix, and trailing garbage. | |||
| 256 | + | func TestProtoSelectTabWire(t *testing.T) { | |||
| 257 | + | envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"}) | |||
| 258 | + | require.NoError(t, err) | |||
| 259 | + | raw, err := proto.Marshal(envelope) | |||
| 260 | + | require.NoError(t, err) | |||
| 261 | + | // field 31 (length-delimited) -> SelectTab{field 1 = "t1"}. | |||
| 262 | + | require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw) | |||
| 263 | + | ||||
| 264 | + | tests := []struct { | |||
| 265 | + | name string | |||
| 266 | + | raw []byte | |||
| 267 | + | wantErr bool | |||
| 268 | + | }{ | |||
| 269 | + | {name: "exact", raw: raw}, | |||
| 270 | + | {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true}, | |||
| 271 | + | {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true}, | |||
| 272 | + | } | |||
| 273 | + | for _, tt := range tests { | |||
| 274 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 275 | + | err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw) | |||
| 276 | + | if tt.wantErr { | |||
| 277 | + | require.Error(t, err) | |||
| 278 | + | return | |||
| 279 | + | } | |||
| 280 | + | require.NoError(t, err) | |||
| 281 | + | decoded := &wire.ClientEnvelope{} | |||
| 282 | + | require.NoError(t, proto.Unmarshal(tt.raw, decoded)) | |||
| 283 | + | got, err := decodeProtoClient(decoded) | |||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | require.Equal(t, protocol.SelectTab{TabID: "t1"}, got) | |||
| 286 | + | }) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | _, err = encodeProtoClient(protocol.SelectTab{}) | |||
| 290 | + | require.Error(t, err, "an empty tab identity is never encoded") | |||
| 291 | + | _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}}) | |||
| 292 | + | require.Error(t, err, "an empty tab identity is never decoded") | |||
| 293 | + | } | |||
| 294 | + | ||||
| 253 | func protoMessageName(message any) string { | 295 | func protoMessageName(message any) string { | ||
| 254 | return protoShortName(message) | 296 | return protoShortName(message) | ||
| 255 | } | 297 | } | ||
M internal/adapters/sessionwire/proto_envelope.go +13 -0
| @@ -148,6 +148,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||
| 148 | 148 | return nil, ErrInvalidMessage | |
| 149 | 149 | } | |
| 150 | 150 | return encodeProtoClient(*m) | |
| 151 | + | case protocol.SelectTab: | |
| 152 | + | converted, err := selectTabToWire(m) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: converted}}, nil | |
| 157 | + | case *protocol.SelectTab: | |
| 158 | + | if m == nil { | |
| 159 | + | return nil, ErrInvalidMessage | |
| 160 | + | } | |
| 161 | + | return encodeProtoClient(*m) | |
| 151 | 162 | case protocol.UIFence: | |
| 152 | 163 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | |
| 153 | 164 | case *protocol.UIFence: | |
| @@ -652,6 +663,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||
| 652 | 663 | return protocol.OutputResetRequest{}, nil | |
| 653 | 664 | case *wire.ClientEnvelope_UiFence: | |
| 654 | 665 | return uiFenceFromWire(payload.UiFence) | |
| 666 | + | case *wire.ClientEnvelope_SelectTab: | |
| 667 | + | return selectTabFromWire(payload.SelectTab) | |
| 655 | 668 | case *wire.ClientEnvelope_RemotePreviewRequest: | |
| 656 | 669 | return remotePreviewRequestFromWire(payload.RemotePreviewRequest) | |
| 657 | 670 | case *wire.ClientEnvelope_RouteAttentionSubscription: | |
| @@ -148,6 +148,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||||
| 148 | return nil, ErrInvalidMessage | 148 | return nil, ErrInvalidMessage | ||
| 149 | } | 149 | } | ||
| 150 | return encodeProtoClient(*m) | 150 | return encodeProtoClient(*m) | ||
| 151 | + | case protocol.SelectTab: | |||
| 152 | + | converted, err := selectTabToWire(m) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: converted}}, nil | |||
| 157 | + | case *protocol.SelectTab: | |||
| 158 | + | if m == nil { | |||
| 159 | + | return nil, ErrInvalidMessage | |||
| 160 | + | } | |||
| 161 | + | return encodeProtoClient(*m) | |||
| 151 | case protocol.UIFence: | 162 | case protocol.UIFence: | ||
| 152 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | 163 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | ||
| 153 | case *protocol.UIFence: | 164 | case *protocol.UIFence: | ||
| @@ -652,6 +663,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||||
| 652 | return protocol.OutputResetRequest{}, nil | 663 | return protocol.OutputResetRequest{}, nil | ||
| 653 | case *wire.ClientEnvelope_UiFence: | 664 | case *wire.ClientEnvelope_UiFence: | ||
| 654 | return uiFenceFromWire(payload.UiFence) | 665 | return uiFenceFromWire(payload.UiFence) | ||
| 666 | + | case *wire.ClientEnvelope_SelectTab: | |||
| 667 | + | return selectTabFromWire(payload.SelectTab) | |||
| 655 | case *wire.ClientEnvelope_RemotePreviewRequest: | 668 | case *wire.ClientEnvelope_RemotePreviewRequest: | ||
| 656 | return remotePreviewRequestFromWire(payload.RemotePreviewRequest) | 669 | return remotePreviewRequestFromWire(payload.RemotePreviewRequest) | ||
| 657 | case *wire.ClientEnvelope_RouteAttentionSubscription: | 670 | case *wire.ClientEnvelope_RouteAttentionSubscription: | ||
M internal/adapters/sessionwire/server_test.go +1 -0
| @@ -34,6 +34,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 34 | 34 | {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | |
| 35 | 35 | {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}}, | |
| 36 | 36 | {name: "reset", want: protocol.OutputResetRequest{}}, | |
| 37 | + | {name: "select tab", want: protocol.SelectTab{TabID: "tab-2"}}, | |
| 37 | 38 | {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | |
| 38 | 39 | {name: "attention", want: protocol.RouteAttentionSubscription{}}, | |
| 39 | 40 | {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}}, | |
| @@ -34,6 +34,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||||
| 34 | {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | 34 | {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | ||
| 35 | {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}}, | 35 | {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}}, | ||
| 36 | {name: "reset", want: protocol.OutputResetRequest{}}, | 36 | {name: "reset", want: protocol.OutputResetRequest{}}, | ||
| 37 | + | {name: "select tab", want: protocol.SelectTab{TabID: "tab-2"}}, | |||
| 37 | {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | 38 | {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | ||
| 38 | {name: "attention", want: protocol.RouteAttentionSubscription{}}, | 39 | {name: "attention", want: protocol.RouteAttentionSubscription{}}, | ||
| 39 | {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}}, | 40 | {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}}, | ||
M internal/protocol/messages.go +1 -0
| @@ -19,6 +19,7 @@ func (ImagePush) clientMessage() {} | |||
| 19 | 19 | func (ClientNotice) clientMessage() {} | |
| 20 | 20 | func (CommandRequest) clientMessage() {} | |
| 21 | 21 | func (OutputResetRequest) clientMessage() {} | |
| 22 | + | func (SelectTab) clientMessage() {} | |
| 22 | 23 | func (RemotePreviewRequest) clientMessage() {} | |
| 23 | 24 | func (RouteAttentionSubscription) clientMessage() {} | |
| 24 | 25 | func (SamePeerSwitchRequest) clientMessage() {} | |
| @@ -19,6 +19,7 @@ func (ImagePush) clientMessage() {} | |||||
| 19 | func (ClientNotice) clientMessage() {} | 19 | func (ClientNotice) clientMessage() {} | ||
| 20 | func (CommandRequest) clientMessage() {} | 20 | func (CommandRequest) clientMessage() {} | ||
| 21 | func (OutputResetRequest) clientMessage() {} | 21 | func (OutputResetRequest) clientMessage() {} | ||
| 22 | + | func (SelectTab) clientMessage() {} | |||
| 22 | func (RemotePreviewRequest) clientMessage() {} | 23 | func (RemotePreviewRequest) clientMessage() {} | ||
| 23 | func (RouteAttentionSubscription) clientMessage() {} | 24 | func (RouteAttentionSubscription) clientMessage() {} | ||
| 24 | func (SamePeerSwitchRequest) clientMessage() {} | 25 | func (SamePeerSwitchRequest) clientMessage() {} | ||
M internal/protocol/session.go +14 -1
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 58 | |
| 19 | + | const Version uint16 = 59 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
| @@ -344,6 +344,19 @@ type SessionInfo struct { | |||
| 344 | 344 | type Sessions struct{ Sessions []SessionInfo } | |
| 345 | 345 | type OutputResetRequest struct{} | |
| 346 | 346 | ||
| 347 | + | // SelectTab asks the daemon to show one exact tab of the attached session on | |
| 348 | + | // this attachment's own view. It is the client picker's in-place tab switch: | |
| 349 | + | // the attachment and its stream stay live, and a tab that no longer exists is | |
| 350 | + | // refused without changing the view. | |
| 351 | + | type SelectTab struct { | |
| 352 | + | TabID domain.TabStableID | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Validate requires a well-formed stable tab identity. | |
| 356 | + | func (m SelectTab) Validate() error { | |
| 357 | + | return domain.ValidateTabStableID(m.TabID) | |
| 358 | + | } | |
| 359 | + | ||
| 347 | 360 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | |
| 348 | 361 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | |
| 349 | 362 | } | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 18 | // version as the session and broker conversations. | 18 | // version as the session and broker conversations. | ||
| 19 | - | const Version uint16 = 58 | 19 | + | const Version uint16 = 59 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 22 | // first committed publication. It excludes the preceding client-local | ||
| @@ -344,6 +344,19 @@ type SessionInfo struct { | |||||
| 344 | type Sessions struct{ Sessions []SessionInfo } | 344 | type Sessions struct{ Sessions []SessionInfo } | ||
| 345 | type OutputResetRequest struct{} | 345 | type OutputResetRequest struct{} | ||
| 346 | 346 | ||||
| 347 | + | // SelectTab asks the daemon to show one exact tab of the attached session on | |||
| 348 | + | // this attachment's own view. It is the client picker's in-place tab switch: | |||
| 349 | + | // the attachment and its stream stay live, and a tab that no longer exists is | |||
| 350 | + | // refused without changing the view. | |||
| 351 | + | type SelectTab struct { | |||
| 352 | + | TabID domain.TabStableID | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // Validate requires a well-formed stable tab identity. | |||
| 356 | + | func (m SelectTab) Validate() error { | |||
| 357 | + | return domain.ValidateTabStableID(m.TabID) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 347 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | 360 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | ||
| 348 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | 361 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | ||
| 349 | } | 362 | } | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(58), Version) | |
| 13 | + | require.Equal(t, uint16(59), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | ||
| 13 | - | require.Equal(t, uint16(58), Version) | 13 | + | require.Equal(t, uint16(59), Version) |
| 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
M internal/protocol/wire/envelope.pb.go +87 -66 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +3 -3
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |
| 18 | - | if protocol.Version != 58 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 58", protocol.Version) | |
| 18 | + | if protocol.Version != 59 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 59", protocol.Version) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||
| 40 | 40 | "ui_fence": true, "navigation_inventory_request": true, | |
| 41 | 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | |
| 42 | 42 | "picker_close": true, "picker_selection": true, | |
| 43 | - | "picker_preview_request": true, | |
| 43 | + | "picker_preview_request": true, "select_tab": true, | |
| 44 | 44 | } | |
| 45 | 45 | serverSemantic := map[string]bool{ | |
| 46 | 46 | "attachment_suspended": true, "attachment_activated": true, | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | // shares a payload type except route navigation failure, and the preamble | 15 | // shares a payload type except route navigation failure, and the preamble | ||
| 16 | // constants agree with the schema authority. | 16 | // constants agree with the schema authority. | ||
| 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | ||
| 18 | - | if protocol.Version != 58 { | 18 | + | if protocol.Version != 59 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 58", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 59", protocol.Version) |
| 20 | } | 20 | } | ||
| 21 | if ProtocolEpoch != 1 { | 21 | if ProtocolEpoch != 1 { | ||
| 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | ||
| @@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||||
| 40 | "ui_fence": true, "navigation_inventory_request": true, | 40 | "ui_fence": true, "navigation_inventory_request": true, | ||
| 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | ||
| 42 | "picker_close": true, "picker_selection": true, | 42 | "picker_close": true, "picker_selection": true, | ||
| 43 | - | "picker_preview_request": true, | 43 | + | "picker_preview_request": true, "select_tab": true, |
| 44 | } | 44 | } | ||
| 45 | serverSemantic := map[string]bool{ | 45 | serverSemantic := map[string]bool{ | ||
| 46 | "attachment_suspended": true, "attachment_activated": true, | 46 | "attachment_suspended": true, "attachment_activated": true, | ||
M internal/protocol/wire/schema/envelope.proto +1 -0
| @@ -96,6 +96,7 @@ message ClientEnvelope { | |||
| 96 | 96 | PickerPreviewRequest picker_preview_request = 27; | |
| 97 | 97 | SuspendAttachment suspend_attachment = 29; | |
| 98 | 98 | ActivateAttachment activate_attachment = 30; | |
| 99 | + | SelectTab select_tab = 31; | |
| 99 | 100 | } | |
| 100 | 101 | } | |
| 101 | 102 | ||
| @@ -96,6 +96,7 @@ message ClientEnvelope { | |||||
| 96 | PickerPreviewRequest picker_preview_request = 27; | 96 | PickerPreviewRequest picker_preview_request = 27; | ||
| 97 | SuspendAttachment suspend_attachment = 29; | 97 | SuspendAttachment suspend_attachment = 29; | ||
| 98 | ActivateAttachment activate_attachment = 30; | 98 | ActivateAttachment activate_attachment = 30; | ||
| 99 | + | SelectTab select_tab = 31; | |||
| 99 | } | 100 | } | ||
| 100 | } | 101 | } | ||
| 101 | 102 | ||||
M internal/protocol/wire/schema/terminal.proto +5 -0
| @@ -86,6 +86,11 @@ message ClientNotice { | |||
| 86 | 86 | // OutputResetRequest restarts the output chain. Empty marker. | |
| 87 | 87 | message OutputResetRequest {} | |
| 88 | 88 | ||
| 89 | + | // SelectTab shows one exact tab of the attached session on this attachment. | |
| 90 | + | message SelectTab { | |
| 91 | + | string tab_id = 1; | |
| 92 | + | } | |
| 93 | + | ||
| 89 | 94 | // UIFence orders UI operations by action. | |
| 90 | 95 | message UIFence { | |
| 91 | 96 | uint64 action_id = 1; | |
| @@ -86,6 +86,11 @@ message ClientNotice { | |||||
| 86 | // OutputResetRequest restarts the output chain. Empty marker. | 86 | // OutputResetRequest restarts the output chain. Empty marker. | ||
| 87 | message OutputResetRequest {} | 87 | message OutputResetRequest {} | ||
| 88 | 88 | ||||
| 89 | + | // SelectTab shows one exact tab of the attached session on this attachment. | |||
| 90 | + | message SelectTab { | |||
| 91 | + | string tab_id = 1; | |||
| 92 | + | } | |||
| 93 | + | ||||
| 89 | // UIFence orders UI operations by action. | 94 | // UIFence orders UI operations by action. | ||
| 90 | message UIFence { | 95 | message UIFence { | ||
| 91 | uint64 action_id = 1; | 96 | uint64 action_id = 1; | ||
M internal/protocol/wire/terminal.pb.go +92 -44 generated file: collapsed
A internal/usecase/daemon/attachment_select_tab.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // selectTabForAttachment shows one exact tab of the attached session on this | |
| 9 | + | // attachment's own view. It is the client picker's in-place tab switch: the | |
| 10 | + | // attachment stays live and only its view moves. A tab the session no longer | |
| 11 | + | // holds is refused without touching the view, so the picker never lands on a | |
| 12 | + | // repaired neighbour it did not choose. | |
| 13 | + | func (d *Daemon) selectTabForAttachment(effect *attachmentEffect, request protocol.SelectTab) { | |
| 14 | + | if request.Validate() != nil || effect == nil || !effect.current() || effect.sess == nil || effect.ac == nil { | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | sess, ac := effect.sess, effect.ac | |
| 18 | + | var selected *tab | |
| 19 | + | _ = sess.runMutation(func() error { | |
| 20 | + | sess.mu.Lock() | |
| 21 | + | for _, candidate := range sess.tabs { | |
| 22 | + | if candidate == nil || domain.TabStableID(candidate.stableID) != request.TabID { | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | if sess.selectAttachmentTabLocked(ac, request.TabID) { | |
| 26 | + | selected = candidate | |
| 27 | + | } | |
| 28 | + | break | |
| 29 | + | } | |
| 30 | + | sess.mu.Unlock() | |
| 31 | + | if selected != nil { | |
| 32 | + | d.activateTabAfterResizeForLease(sess, selected, false, ac, nil) | |
| 33 | + | } | |
| 34 | + | return nil | |
| 35 | + | }) | |
| 36 | + | if selected == nil { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | d.invalidateRender(sess, ac, true, "attachment_select_tab.go") | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/protocol" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // selectTabForAttachment shows one exact tab of the attached session on this | |||
| 9 | + | // attachment's own view. It is the client picker's in-place tab switch: the | |||
| 10 | + | // attachment stays live and only its view moves. A tab the session no longer | |||
| 11 | + | // holds is refused without touching the view, so the picker never lands on a | |||
| 12 | + | // repaired neighbour it did not choose. | |||
| 13 | + | func (d *Daemon) selectTabForAttachment(effect *attachmentEffect, request protocol.SelectTab) { | |||
| 14 | + | if request.Validate() != nil || effect == nil || !effect.current() || effect.sess == nil || effect.ac == nil { | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 17 | + | sess, ac := effect.sess, effect.ac | |||
| 18 | + | var selected *tab | |||
| 19 | + | _ = sess.runMutation(func() error { | |||
| 20 | + | sess.mu.Lock() | |||
| 21 | + | for _, candidate := range sess.tabs { | |||
| 22 | + | if candidate == nil || domain.TabStableID(candidate.stableID) != request.TabID { | |||
| 23 | + | continue | |||
| 24 | + | } | |||
| 25 | + | if sess.selectAttachmentTabLocked(ac, request.TabID) { | |||
| 26 | + | selected = candidate | |||
| 27 | + | } | |||
| 28 | + | break | |||
| 29 | + | } | |||
| 30 | + | sess.mu.Unlock() | |||
| 31 | + | if selected != nil { | |||
| 32 | + | d.activateTabAfterResizeForLease(sess, selected, false, ac, nil) | |||
| 33 | + | } | |||
| 34 | + | return nil | |||
| 35 | + | }) | |||
| 36 | + | if selected == nil { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | d.invalidateRender(sess, ac, true, "attachment_select_tab.go") | |||
| 40 | + | } | |||
A internal/usecase/daemon/attachment_select_tab_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // TestSelectTabMovesOnlyThisAttachmentView proves the picker's in-place tab | |
| 13 | + | // switch: an exact tab selects it on the requesting attachment, a peer keeps | |
| 14 | + | // its own view, and an unknown or malformed tab leaves the view untouched. | |
| 15 | + | func TestSelectTabMovesOnlyThisAttachmentView(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | request func(second domain.TabStableID) protocol.SelectTab | |
| 19 | + | moves bool | |
| 20 | + | }{ | |
| 21 | + | {name: "exact tab", request: func(second domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: second} }, moves: true}, | |
| 22 | + | {name: "unknown tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: "t_missing"} }}, | |
| 23 | + | {name: "malformed tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{} }}, | |
| 24 | + | } | |
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 28 | + | for _, release := range releases { | |
| 29 | + | defer release() | |
| 30 | + | } | |
| 31 | + | peer := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}}) | |
| 32 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 33 | + | d.attachCoordinator(sess, nil, peer, true) | |
| 34 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 35 | + | token.lease = rc.attachmentLease(ac) | |
| 36 | + | ac.installTestAttachmentCapability(token) | |
| 37 | + | ||
| 38 | + | first := domain.TabStableID(sess.tabs[0].stableID) | |
| 39 | + | second := domain.TabStableID(sess.tabs[1].stableID) | |
| 40 | + | require.True(t, sess.selectAttachmentTab(ac, first)) | |
| 41 | + | require.True(t, sess.selectAttachmentTab(peer, first)) | |
| 42 | + | before := ac.viewSnapshot() | |
| 43 | + | ||
| 44 | + | require.False(t, d.handleAttachmentClientMessage(token, tt.request(second))) | |
| 45 | + | ||
| 46 | + | if tt.moves { | |
| 47 | + | require.Equal(t, second, ac.viewSnapshot().tabID) | |
| 48 | + | } else { | |
| 49 | + | require.Equal(t, before, ac.viewSnapshot(), "a refused selection never repairs the view") | |
| 50 | + | } | |
| 51 | + | require.Equal(t, first, peer.viewSnapshot().tabID, "the peer attachment keeps its own view") | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TestSelectTabMovesOnlyThisAttachmentView proves the picker's in-place tab | |||
| 13 | + | // switch: an exact tab selects it on the requesting attachment, a peer keeps | |||
| 14 | + | // its own view, and an unknown or malformed tab leaves the view untouched. | |||
| 15 | + | func TestSelectTabMovesOnlyThisAttachmentView(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | request func(second domain.TabStableID) protocol.SelectTab | |||
| 19 | + | moves bool | |||
| 20 | + | }{ | |||
| 21 | + | {name: "exact tab", request: func(second domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: second} }, moves: true}, | |||
| 22 | + | {name: "unknown tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: "t_missing"} }}, | |||
| 23 | + | {name: "malformed tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{} }}, | |||
| 24 | + | } | |||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |||
| 28 | + | for _, release := range releases { | |||
| 29 | + | defer release() | |||
| 30 | + | } | |||
| 31 | + | peer := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}}) | |||
| 32 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 33 | + | d.attachCoordinator(sess, nil, peer, true) | |||
| 34 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |||
| 35 | + | token.lease = rc.attachmentLease(ac) | |||
| 36 | + | ac.installTestAttachmentCapability(token) | |||
| 37 | + | ||||
| 38 | + | first := domain.TabStableID(sess.tabs[0].stableID) | |||
| 39 | + | second := domain.TabStableID(sess.tabs[1].stableID) | |||
| 40 | + | require.True(t, sess.selectAttachmentTab(ac, first)) | |||
| 41 | + | require.True(t, sess.selectAttachmentTab(peer, first)) | |||
| 42 | + | before := ac.viewSnapshot() | |||
| 43 | + | ||||
| 44 | + | require.False(t, d.handleAttachmentClientMessage(token, tt.request(second))) | |||
| 45 | + | ||||
| 46 | + | if tt.moves { | |||
| 47 | + | require.Equal(t, second, ac.viewSnapshot().tabID) | |||
| 48 | + | } else { | |||
| 49 | + | require.Equal(t, before, ac.viewSnapshot(), "a refused selection never repairs the view") | |||
| 50 | + | } | |||
| 51 | + | require.Equal(t, first, peer.viewSnapshot().tabID, "the peer attachment keeps its own view") | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
M internal/usecase/daemon/client_frame_routing.go +2 -0
| @@ -167,6 +167,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||
| 167 | 167 | d.resetOutput(effect) | |
| 168 | 168 | case protocol.SamePeerSwitchRequest: | |
| 169 | 169 | d.switchSamePeerForAttachment(effect, message) | |
| 170 | + | case protocol.SelectTab: | |
| 171 | + | d.selectTabForAttachment(effect, message) | |
| 170 | 172 | case protocol.RecentRouteSnapshot: | |
| 171 | 173 | replayIdentity := effect.ac.setRouteSnapshot(message) | |
| 172 | 174 | d.pokeAttentionTicker() | |
| @@ -167,6 +167,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||||
| 167 | d.resetOutput(effect) | 167 | d.resetOutput(effect) | ||
| 168 | case protocol.SamePeerSwitchRequest: | 168 | case protocol.SamePeerSwitchRequest: | ||
| 169 | d.switchSamePeerForAttachment(effect, message) | 169 | d.switchSamePeerForAttachment(effect, message) | ||
| 170 | + | case protocol.SelectTab: | |||
| 171 | + | d.selectTabForAttachment(effect, message) | |||
| 170 | case protocol.RecentRouteSnapshot: | 172 | case protocol.RecentRouteSnapshot: | ||
| 171 | replayIdentity := effect.ac.setRouteSnapshot(message) | 173 | replayIdentity := effect.ac.setRouteSnapshot(message) | ||
| 172 | d.pokeAttentionTicker() | 174 | d.pokeAttentionTicker() | ||