feat(palette): add explicit CNS destinations (#239)
* feat(palette): add explicit session destinations * fix(palette): address review findings
- commit
- c8de7707cca8bf3dff5d4cd0e1195bc47f305f41
- author
- bnema · · 2026-08-29 13:44 UTC
- committer
- GitHub · · 2026-08-29 13:44 UTC
- parent
- b788892
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
38 files · +1189 -97
M README.md +2 -0
| @@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes: | |||
| 65 | 65 | | `MFP` / `MAT` | move pane / tab | `SSP` | session picker | | |
| 66 | 66 | | `DET` | detach | `NTC` | notifications | | |
| 67 | 67 | ||
| 68 | + | When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local. | |
| 69 | + | ||
| 68 | 70 | Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md). | |
| 69 | 71 | ||
| 70 | 72 | ## Remote attach | |
| @@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes: | |||||
| 65 | | `MFP` / `MAT` | move pane / tab | `SSP` | session picker | | 65 | | `MFP` / `MAT` | move pane / tab | `SSP` | session picker | | ||
| 66 | | `DET` | detach | `NTC` | notifications | | 66 | | `DET` | detach | `NTC` | notifications | | ||
| 67 | 67 | ||||
| 68 | + | When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local. | |||
| 69 | + | ||||
| 68 | Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md). | 70 | Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md). | ||
| 69 | 71 | ||||
| 70 | ## Remote attach | 72 | ## Remote attach | ||
M internal/adapters/sessionwire/client.go +10 -1
| @@ -101,6 +101,8 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 101 | 101 | return wire.UnmarshalCommittedRouteIdentity(frame.Payload) | |
| 102 | 102 | case wire.MsgNavigateRecentRoute: | |
| 103 | 103 | return wire.UnmarshalRouteNavigationAction(frame.Payload) | |
| 104 | + | case wire.MsgRouteCreateSession: | |
| 105 | + | return wire.UnmarshalRouteCreateSessionAction(frame.Payload) | |
| 104 | 106 | case wire.MsgRouteNavigationFailure: | |
| 105 | 107 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | |
| 106 | 108 | case wire.MsgRoutePosition: | |
| @@ -114,7 +116,7 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 114 | 116 | wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest, | |
| 115 | 117 | wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription, | |
| 116 | 118 | wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest, | |
| 117 | - | wire.MsgRecentRouteSnapshot: | |
| 119 | + | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure: | |
| 118 | 120 | return nil, ErrWrongDirection | |
| 119 | 121 | default: | |
| 120 | 122 | return nil, ErrUnknownMessageType | |
| @@ -183,6 +185,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 183 | 185 | case protocol.RouteNavigationFailure: | |
| 184 | 186 | payload, err := wire.MarshalRouteNavigationFailure(m) | |
| 185 | 187 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | |
| 188 | + | case protocol.SessionCreationFailure: | |
| 189 | + | payload, err := wire.MarshalSessionCreationFailure(m) | |
| 190 | + | return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: payload}, err | |
| 186 | 191 | case *protocol.Hello: | |
| 187 | 192 | if m != nil { | |
| 188 | 193 | return encodeClient(*m) | |
| @@ -259,6 +264,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 259 | 264 | if m != nil { | |
| 260 | 265 | return encodeClient(*m) | |
| 261 | 266 | } | |
| 267 | + | case *protocol.SessionCreationFailure: | |
| 268 | + | if m != nil { | |
| 269 | + | return encodeClient(*m) | |
| 270 | + | } | |
| 262 | 271 | default: | |
| 263 | 272 | return wire.Frame{}, ErrWrongDirection | |
| 264 | 273 | } | |
| @@ -101,6 +101,8 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||||
| 101 | return wire.UnmarshalCommittedRouteIdentity(frame.Payload) | 101 | return wire.UnmarshalCommittedRouteIdentity(frame.Payload) | ||
| 102 | case wire.MsgNavigateRecentRoute: | 102 | case wire.MsgNavigateRecentRoute: | ||
| 103 | return wire.UnmarshalRouteNavigationAction(frame.Payload) | 103 | return wire.UnmarshalRouteNavigationAction(frame.Payload) | ||
| 104 | + | case wire.MsgRouteCreateSession: | |||
| 105 | + | return wire.UnmarshalRouteCreateSessionAction(frame.Payload) | |||
| 104 | case wire.MsgRouteNavigationFailure: | 106 | case wire.MsgRouteNavigationFailure: | ||
| 105 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | 107 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | ||
| 106 | case wire.MsgRoutePosition: | 108 | case wire.MsgRoutePosition: | ||
| @@ -114,7 +116,7 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||||
| 114 | wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest, | 116 | wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest, | ||
| 115 | wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription, | 117 | wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription, | ||
| 116 | wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest, | 118 | wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest, | ||
| 117 | - | wire.MsgRecentRouteSnapshot: | 119 | + | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure: |
| 118 | return nil, ErrWrongDirection | 120 | return nil, ErrWrongDirection | ||
| 119 | default: | 121 | default: | ||
| 120 | return nil, ErrUnknownMessageType | 122 | return nil, ErrUnknownMessageType | ||
| @@ -183,6 +185,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||||
| 183 | case protocol.RouteNavigationFailure: | 185 | case protocol.RouteNavigationFailure: | ||
| 184 | payload, err := wire.MarshalRouteNavigationFailure(m) | 186 | payload, err := wire.MarshalRouteNavigationFailure(m) | ||
| 185 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | 187 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | ||
| 188 | + | case protocol.SessionCreationFailure: | |||
| 189 | + | payload, err := wire.MarshalSessionCreationFailure(m) | |||
| 190 | + | return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: payload}, err | |||
| 186 | case *protocol.Hello: | 191 | case *protocol.Hello: | ||
| 187 | if m != nil { | 192 | if m != nil { | ||
| 188 | return encodeClient(*m) | 193 | return encodeClient(*m) | ||
| @@ -259,6 +264,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||||
| 259 | if m != nil { | 264 | if m != nil { | ||
| 260 | return encodeClient(*m) | 265 | return encodeClient(*m) | ||
| 261 | } | 266 | } | ||
| 267 | + | case *protocol.SessionCreationFailure: | |||
| 268 | + | if m != nil { | |||
| 269 | + | return encodeClient(*m) | |||
| 270 | + | } | |||
| 262 | default: | 271 | default: | ||
| 263 | return wire.Frame{}, ErrWrongDirection | 272 | return wire.Frame{}, ErrWrongDirection | ||
| 264 | } | 273 | } | ||
M internal/adapters/sessionwire/client_test.go +7 -0
| @@ -40,6 +40,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 40 | 40 | {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest}, | |
| 41 | 41 | {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot}, | |
| 42 | 42 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure}, | |
| 43 | + | {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure}, | |
| 44 | + | {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure}, | |
| 43 | 45 | } | |
| 44 | 46 | for _, tt := range tests { | |
| 45 | 47 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -48,6 +50,10 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 48 | 50 | require.Equal(t, tt.typeID, raw.sent.Type) | |
| 49 | 51 | got, err := decodeClient(raw.sent) | |
| 50 | 52 | require.NoError(t, err) | |
| 53 | + | if message, ok := tt.message.(*protocol.SessionCreationFailure); ok { | |
| 54 | + | require.Equal(t, *message, got) | |
| 55 | + | return | |
| 56 | + | } | |
| 51 | 57 | require.Equal(t, tt.message, got) | |
| 52 | 58 | }) | |
| 53 | 59 | } | |
| @@ -72,6 +78,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 72 | 78 | {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}, | |
| 73 | 79 | {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}}, | |
| 74 | 80 | {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}}, | |
| 81 | + | {name: "route create", message: protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}}, | |
| 75 | 82 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | |
| 76 | 83 | {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}}, | |
| 77 | 84 | {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}}, | |
| @@ -40,6 +40,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||||
| 40 | {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest}, | 40 | {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest}, | ||
| 41 | {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot}, | 41 | {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot}, | ||
| 42 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure}, | 42 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure}, | ||
| 43 | + | {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure}, | |||
| 44 | + | {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure}, | |||
| 43 | } | 45 | } | ||
| 44 | for _, tt := range tests { | 46 | for _, tt := range tests { | ||
| 45 | t.Run(tt.name, func(t *testing.T) { | 47 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -48,6 +50,10 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||||
| 48 | require.Equal(t, tt.typeID, raw.sent.Type) | 50 | require.Equal(t, tt.typeID, raw.sent.Type) | ||
| 49 | got, err := decodeClient(raw.sent) | 51 | got, err := decodeClient(raw.sent) | ||
| 50 | require.NoError(t, err) | 52 | require.NoError(t, err) | ||
| 53 | + | if message, ok := tt.message.(*protocol.SessionCreationFailure); ok { | |||
| 54 | + | require.Equal(t, *message, got) | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 51 | require.Equal(t, tt.message, got) | 57 | require.Equal(t, tt.message, got) | ||
| 52 | }) | 58 | }) | ||
| 53 | } | 59 | } | ||
| @@ -72,6 +78,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||||
| 72 | {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}, | 78 | {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}, | ||
| 73 | {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}}, | 79 | {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}}, | ||
| 74 | {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}}, | 80 | {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}}, | ||
| 81 | + | {name: "route create", message: protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}}, | |||
| 75 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | 82 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | ||
| 76 | {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}}, | 83 | {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}}, | ||
| 77 | {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}}, | 84 | {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}}, | ||
M internal/adapters/sessionwire/server.go +10 -1
| @@ -232,10 +232,12 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 232 | 232 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 233 | 233 | case wire.MsgRouteNavigationFailure: | |
| 234 | 234 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | |
| 235 | + | case wire.MsgSessionCreationFailure: | |
| 236 | + | return wire.UnmarshalSessionCreationFailure(frame.Payload) | |
| 235 | 237 | case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong, | |
| 236 | 238 | wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction, | |
| 237 | 239 | wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | |
| 238 | - | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, | |
| 240 | + | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession, | |
| 239 | 241 | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse: | |
| 240 | 242 | return nil, ErrWrongDirection | |
| 241 | 243 | default: | |
| @@ -291,6 +293,9 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 291 | 293 | case protocol.RouteNavigationAction: | |
| 292 | 294 | payload, err := wire.MarshalRouteNavigationAction(m) | |
| 293 | 295 | return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err | |
| 296 | + | case protocol.RouteCreateSessionAction: | |
| 297 | + | payload, err := wire.MarshalRouteCreateSessionAction(m) | |
| 298 | + | return wire.Frame{Type: wire.MsgRouteCreateSession, Payload: payload}, err | |
| 294 | 299 | case protocol.RouteNavigationFailure: | |
| 295 | 300 | payload, err := wire.MarshalRouteNavigationFailure(m) | |
| 296 | 301 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | |
| @@ -354,6 +359,10 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 354 | 359 | if m != nil { | |
| 355 | 360 | return encodeServer(*m) | |
| 356 | 361 | } | |
| 362 | + | case *protocol.RouteCreateSessionAction: | |
| 363 | + | if m != nil { | |
| 364 | + | return encodeServer(*m) | |
| 365 | + | } | |
| 357 | 366 | case *protocol.RouteNavigationFailure: | |
| 358 | 367 | if m != nil { | |
| 359 | 368 | return encodeServer(*m) | |
| @@ -232,10 +232,12 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||||
| 232 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | 232 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | ||
| 233 | case wire.MsgRouteNavigationFailure: | 233 | case wire.MsgRouteNavigationFailure: | ||
| 234 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | 234 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | ||
| 235 | + | case wire.MsgSessionCreationFailure: | |||
| 236 | + | return wire.UnmarshalSessionCreationFailure(frame.Payload) | |||
| 235 | case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong, | 237 | case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong, | ||
| 236 | wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction, | 238 | wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction, | ||
| 237 | wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | 239 | wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | ||
| 238 | - | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, | 240 | + | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession, |
| 239 | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse: | 241 | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse: | ||
| 240 | return nil, ErrWrongDirection | 242 | return nil, ErrWrongDirection | ||
| 241 | default: | 243 | default: | ||
| @@ -291,6 +293,9 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||||
| 291 | case protocol.RouteNavigationAction: | 293 | case protocol.RouteNavigationAction: | ||
| 292 | payload, err := wire.MarshalRouteNavigationAction(m) | 294 | payload, err := wire.MarshalRouteNavigationAction(m) | ||
| 293 | return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err | 295 | return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err | ||
| 296 | + | case protocol.RouteCreateSessionAction: | |||
| 297 | + | payload, err := wire.MarshalRouteCreateSessionAction(m) | |||
| 298 | + | return wire.Frame{Type: wire.MsgRouteCreateSession, Payload: payload}, err | |||
| 294 | case protocol.RouteNavigationFailure: | 299 | case protocol.RouteNavigationFailure: | ||
| 295 | payload, err := wire.MarshalRouteNavigationFailure(m) | 300 | payload, err := wire.MarshalRouteNavigationFailure(m) | ||
| 296 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | 301 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | ||
| @@ -354,6 +359,10 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||||
| 354 | if m != nil { | 359 | if m != nil { | ||
| 355 | return encodeServer(*m) | 360 | return encodeServer(*m) | ||
| 356 | } | 361 | } | ||
| 362 | + | case *protocol.RouteCreateSessionAction: | |||
| 363 | + | if m != nil { | |||
| 364 | + | return encodeServer(*m) | |||
| 365 | + | } | |||
| 357 | case *protocol.RouteNavigationFailure: | 366 | case *protocol.RouteNavigationFailure: | ||
| 358 | if m != nil { | 367 | if m != nil { | ||
| 359 | return encodeServer(*m) | 368 | return encodeServer(*m) | ||
M internal/adapters/sessionwire/server_test.go +8 -0
| @@ -69,6 +69,8 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 69 | 69 | require.NoError(t, err) | |
| 70 | 70 | failurePayload, err := wire.MarshalRouteNavigationFailure(protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}) | |
| 71 | 71 | require.NoError(t, err) | |
| 72 | + | creationFailurePayload, err := wire.MarshalSessionCreationFailure(protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}) | |
| 73 | + | require.NoError(t, err) | |
| 72 | 74 | ||
| 73 | 75 | tests := []struct { | |
| 74 | 76 | name string | |
| @@ -94,6 +96,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 94 | 96 | {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}}, | |
| 95 | 97 | {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}}, | |
| 96 | 98 | {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | |
| 99 | + | {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}}, | |
| 97 | 100 | } | |
| 98 | 101 | for _, tt := range tests { | |
| 99 | 102 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -120,6 +123,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 120 | 123 | preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable} | |
| 121 | 124 | identity := protocol.CommittedRouteIdentity{Target: exact} | |
| 122 | 125 | routeAction := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1} | |
| 126 | + | routeCreate := protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"} | |
| 123 | 127 | routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable} | |
| 124 | 128 | routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"} | |
| 125 | 129 | switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable} | |
| @@ -131,6 +135,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 131 | 135 | require.NoError(t, err) | |
| 132 | 136 | routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction) | |
| 133 | 137 | require.NoError(t, err) | |
| 138 | + | routeCreatePayload, err := wire.MarshalRouteCreateSessionAction(routeCreate) | |
| 139 | + | require.NoError(t, err) | |
| 134 | 140 | routeFailurePayload, err := wire.MarshalRouteNavigationFailure(routeFailure) | |
| 135 | 141 | require.NoError(t, err) | |
| 136 | 142 | routePositionPayload, err := wire.MarshalRoutePosition(routePosition) | |
| @@ -156,6 +162,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 156 | 162 | {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)}, | |
| 157 | 163 | {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload}, | |
| 158 | 164 | {name: "route action", message: routeAction, typeID: wire.MsgNavigateRecentRoute, payload: routeActionPayload}, | |
| 165 | + | {name: "route create", message: routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload}, | |
| 166 | + | {name: "route create pointer", message: &routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload}, | |
| 159 | 167 | {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload}, | |
| 160 | 168 | {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload}, | |
| 161 | 169 | {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload}, | |
| @@ -69,6 +69,8 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||||
| 69 | require.NoError(t, err) | 69 | require.NoError(t, err) | ||
| 70 | failurePayload, err := wire.MarshalRouteNavigationFailure(protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}) | 70 | failurePayload, err := wire.MarshalRouteNavigationFailure(protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}) | ||
| 71 | require.NoError(t, err) | 71 | require.NoError(t, err) | ||
| 72 | + | creationFailurePayload, err := wire.MarshalSessionCreationFailure(protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}) | |||
| 73 | + | require.NoError(t, err) | |||
| 72 | 74 | ||||
| 73 | tests := []struct { | 75 | tests := []struct { | ||
| 74 | name string | 76 | name string | ||
| @@ -94,6 +96,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||||
| 94 | {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}}, | 96 | {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}}, | ||
| 95 | {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}}, | 97 | {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}}, | ||
| 96 | {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | 98 | {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | ||
| 99 | + | {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}}, | |||
| 97 | } | 100 | } | ||
| 98 | for _, tt := range tests { | 101 | for _, tt := range tests { | ||
| 99 | t.Run(tt.name, func(t *testing.T) { | 102 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -120,6 +123,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||||
| 120 | preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable} | 123 | preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable} | ||
| 121 | identity := protocol.CommittedRouteIdentity{Target: exact} | 124 | identity := protocol.CommittedRouteIdentity{Target: exact} | ||
| 122 | routeAction := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1} | 125 | routeAction := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1} | ||
| 126 | + | routeCreate := protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"} | |||
| 123 | routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable} | 127 | routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable} | ||
| 124 | routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"} | 128 | routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"} | ||
| 125 | switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable} | 129 | switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable} | ||
| @@ -131,6 +135,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||||
| 131 | require.NoError(t, err) | 135 | require.NoError(t, err) | ||
| 132 | routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction) | 136 | routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction) | ||
| 133 | require.NoError(t, err) | 137 | require.NoError(t, err) | ||
| 138 | + | routeCreatePayload, err := wire.MarshalRouteCreateSessionAction(routeCreate) | |||
| 139 | + | require.NoError(t, err) | |||
| 134 | routeFailurePayload, err := wire.MarshalRouteNavigationFailure(routeFailure) | 140 | routeFailurePayload, err := wire.MarshalRouteNavigationFailure(routeFailure) | ||
| 135 | require.NoError(t, err) | 141 | require.NoError(t, err) | ||
| 136 | routePositionPayload, err := wire.MarshalRoutePosition(routePosition) | 142 | routePositionPayload, err := wire.MarshalRoutePosition(routePosition) | ||
| @@ -156,6 +162,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||||
| 156 | {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)}, | 162 | {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)}, | ||
| 157 | {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload}, | 163 | {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload}, | ||
| 158 | {name: "route action", message: routeAction, typeID: wire.MsgNavigateRecentRoute, payload: routeActionPayload}, | 164 | {name: "route action", message: routeAction, typeID: wire.MsgNavigateRecentRoute, payload: routeActionPayload}, | ||
| 165 | + | {name: "route create", message: routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload}, | |||
| 166 | + | {name: "route create pointer", message: &routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload}, | |||
| 159 | {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload}, | 167 | {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload}, | ||
| 160 | {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload}, | 168 | {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload}, | ||
| 161 | {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload}, | 169 | {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload}, | ||
M internal/app/run.go +4 -4
| @@ -907,8 +907,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 907 | 907 | } | |
| 908 | 908 | } | |
| 909 | 909 | pickerHandoff := remoteTarget == "" | |
| 910 | - | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy { | |
| 911 | - | if pickerHandoff && target == nil { | |
| 910 | + | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy { | |
| 911 | + | if pickerHandoff && target == nil && intent != protocol.IntentNew { | |
| 912 | 912 | return protocol.EnvironmentPolicyDaemonOwned | |
| 913 | 913 | } | |
| 914 | 914 | return policy | |
| @@ -932,7 +932,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 932 | 932 | if err != nil { | |
| 933 | 933 | return nil, client.AttachRequest{}, err | |
| 934 | 934 | } | |
| 935 | - | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | |
| 935 | + | policy := pickerEnvironmentPolicy(selection, target.Intent, target.EnvironmentPolicy) | |
| 936 | 936 | displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint) | |
| 937 | 937 | if selection != nil { | |
| 938 | 938 | displayOrigin = selection.DisplayOrigin | |
| @@ -1026,7 +1026,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1026 | 1026 | remoteSelection = ©Target | |
| 1027 | 1027 | remoteDisplayOrigin = copyTarget.DisplayOrigin | |
| 1028 | 1028 | } | |
| 1029 | - | remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy) | |
| 1029 | + | remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.Intent, handoffErr.Target.EnvironmentPolicy) | |
| 1030 | 1030 | if modeErr != nil { | |
| 1031 | 1031 | return modeErr | |
| 1032 | 1032 | } | |
| @@ -907,8 +907,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 907 | } | 907 | } | ||
| 908 | } | 908 | } | ||
| 909 | pickerHandoff := remoteTarget == "" | 909 | pickerHandoff := remoteTarget == "" | ||
| 910 | - | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy { | 910 | + | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy { |
| 911 | - | if pickerHandoff && target == nil { | 911 | + | if pickerHandoff && target == nil && intent != protocol.IntentNew { |
| 912 | return protocol.EnvironmentPolicyDaemonOwned | 912 | return protocol.EnvironmentPolicyDaemonOwned | ||
| 913 | } | 913 | } | ||
| 914 | return policy | 914 | return policy | ||
| @@ -932,7 +932,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 932 | if err != nil { | 932 | if err != nil { | ||
| 933 | return nil, client.AttachRequest{}, err | 933 | return nil, client.AttachRequest{}, err | ||
| 934 | } | 934 | } | ||
| 935 | - | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | 935 | + | policy := pickerEnvironmentPolicy(selection, target.Intent, target.EnvironmentPolicy) |
| 936 | displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint) | 936 | displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint) | ||
| 937 | if selection != nil { | 937 | if selection != nil { | ||
| 938 | displayOrigin = selection.DisplayOrigin | 938 | displayOrigin = selection.DisplayOrigin | ||
| @@ -1026,7 +1026,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 1026 | remoteSelection = ©Target | 1026 | remoteSelection = ©Target | ||
| 1027 | remoteDisplayOrigin = copyTarget.DisplayOrigin | 1027 | remoteDisplayOrigin = copyTarget.DisplayOrigin | ||
| 1028 | } | 1028 | } | ||
| 1029 | - | remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy) | 1029 | + | remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.Intent, handoffErr.Target.EnvironmentPolicy) |
| 1030 | if modeErr != nil { | 1030 | if modeErr != nil { | ||
| 1031 | return modeErr | 1031 | return modeErr | ||
| 1032 | } | 1032 | } | ||
M internal/protocol/messages.go +18 -16
| @@ -25,20 +25,22 @@ func (SamePeerSwitchRequest) clientMessage() {} | |||
| 25 | 25 | func (ParkedRouteRequest) clientMessage() {} | |
| 26 | 26 | func (RecentRouteSnapshot) clientMessage() {} | |
| 27 | 27 | func (RouteNavigationFailure) clientMessage() {} | |
| 28 | + | func (SessionCreationFailure) clientMessage() {} | |
| 28 | 29 | ||
| 29 | - | func (Welcome) serverMessage() {} | |
| 30 | - | func (ErrorMsg) serverMessage() {} | |
| 31 | - | func (Output) serverMessage() {} | |
| 32 | - | func (Detached) serverMessage() {} | |
| 33 | - | func (Pong) serverMessage() {} | |
| 34 | - | func (Sessions) serverMessage() {} | |
| 35 | - | func (CommandResult) serverMessage() {} | |
| 36 | - | func (NavigationDirective) serverMessage() {} | |
| 37 | - | func (AttachTarget) serverMessage() {} | |
| 38 | - | func (RemotePreview) serverMessage() {} | |
| 39 | - | func (CommittedRouteIdentity) serverMessage() {} | |
| 40 | - | func (RouteNavigationAction) serverMessage() {} | |
| 41 | - | func (RouteNavigationFailure) serverMessage() {} | |
| 42 | - | func (RoutePosition) serverMessage() {} | |
| 43 | - | func (SamePeerSwitchFailure) serverMessage() {} | |
| 44 | - | func (ParkedRouteResponse) serverMessage() {} | |
| 30 | + | func (Welcome) serverMessage() {} | |
| 31 | + | func (ErrorMsg) serverMessage() {} | |
| 32 | + | func (Output) serverMessage() {} | |
| 33 | + | func (Detached) serverMessage() {} | |
| 34 | + | func (Pong) serverMessage() {} | |
| 35 | + | func (Sessions) serverMessage() {} | |
| 36 | + | func (CommandResult) serverMessage() {} | |
| 37 | + | func (NavigationDirective) serverMessage() {} | |
| 38 | + | func (AttachTarget) serverMessage() {} | |
| 39 | + | func (RemotePreview) serverMessage() {} | |
| 40 | + | func (CommittedRouteIdentity) serverMessage() {} | |
| 41 | + | func (RouteNavigationAction) serverMessage() {} | |
| 42 | + | func (RouteCreateSessionAction) serverMessage() {} | |
| 43 | + | func (RouteNavigationFailure) serverMessage() {} | |
| 44 | + | func (RoutePosition) serverMessage() {} | |
| 45 | + | func (SamePeerSwitchFailure) serverMessage() {} | |
| 46 | + | func (ParkedRouteResponse) serverMessage() {} | |
| @@ -25,20 +25,22 @@ func (SamePeerSwitchRequest) clientMessage() {} | |||||
| 25 | func (ParkedRouteRequest) clientMessage() {} | 25 | func (ParkedRouteRequest) clientMessage() {} | ||
| 26 | func (RecentRouteSnapshot) clientMessage() {} | 26 | func (RecentRouteSnapshot) clientMessage() {} | ||
| 27 | func (RouteNavigationFailure) clientMessage() {} | 27 | func (RouteNavigationFailure) clientMessage() {} | ||
| 28 | + | func (SessionCreationFailure) clientMessage() {} | |||
| 28 | 29 | ||||
| 29 | - | func (Welcome) serverMessage() {} | 30 | + | func (Welcome) serverMessage() {} |
| 30 | - | func (ErrorMsg) serverMessage() {} | 31 | + | func (ErrorMsg) serverMessage() {} |
| 31 | - | func (Output) serverMessage() {} | 32 | + | func (Output) serverMessage() {} |
| 32 | - | func (Detached) serverMessage() {} | 33 | + | func (Detached) serverMessage() {} |
| 33 | - | func (Pong) serverMessage() {} | 34 | + | func (Pong) serverMessage() {} |
| 34 | - | func (Sessions) serverMessage() {} | 35 | + | func (Sessions) serverMessage() {} |
| 35 | - | func (CommandResult) serverMessage() {} | 36 | + | func (CommandResult) serverMessage() {} |
| 36 | - | func (NavigationDirective) serverMessage() {} | 37 | + | func (NavigationDirective) serverMessage() {} |
| 37 | - | func (AttachTarget) serverMessage() {} | 38 | + | func (AttachTarget) serverMessage() {} |
| 38 | - | func (RemotePreview) serverMessage() {} | 39 | + | func (RemotePreview) serverMessage() {} |
| 39 | - | func (CommittedRouteIdentity) serverMessage() {} | 40 | + | func (CommittedRouteIdentity) serverMessage() {} |
| 40 | - | func (RouteNavigationAction) serverMessage() {} | 41 | + | func (RouteNavigationAction) serverMessage() {} |
| 41 | - | func (RouteNavigationFailure) serverMessage() {} | 42 | + | func (RouteCreateSessionAction) serverMessage() {} |
| 42 | - | func (RoutePosition) serverMessage() {} | 43 | + | func (RouteNavigationFailure) serverMessage() {} |
| 43 | - | func (SamePeerSwitchFailure) serverMessage() {} | 44 | + | func (RoutePosition) serverMessage() {} |
| 44 | - | func (ParkedRouteResponse) serverMessage() {} | 45 | + | func (SamePeerSwitchFailure) serverMessage() {} |
| 46 | + | func (ParkedRouteResponse) serverMessage() {} | |||
M internal/protocol/navigation.go +3 -0
| @@ -87,6 +87,9 @@ func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOver | |||
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | func validateHelloNavigation(h Hello) error { | |
| 90 | + | if h.Intent == IntentNew { | |
| 91 | + | return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, true) | |
| 92 | + | } | |
| 90 | 93 | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 91 | 94 | if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone { | |
| 92 | 95 | return ErrInvalidNavigation | |
| @@ -87,6 +87,9 @@ func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOver | |||||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | func validateHelloNavigation(h Hello) error { | 89 | func validateHelloNavigation(h Hello) error { | ||
| 90 | + | if h.Intent == IntentNew { | |||
| 91 | + | return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, true) | |||
| 92 | + | } | |||
| 90 | if h.Intent != IntentAttach && h.Intent != IntentResume { | 93 | if h.Intent != IntentAttach && h.Intent != IntentResume { | ||
| 91 | if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone { | 94 | if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone { | ||
| 92 | return ErrInvalidNavigation | 95 | return ErrInvalidNavigation | ||
M internal/protocol/routes.go +31 -0
| @@ -234,6 +234,23 @@ type RouteNavigationAction struct { | |||
| 234 | 234 | Generation uint64 | |
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | + | // RouteCreateSessionAction asks the client to create a named session through | |
| 238 | + | // one exact route authority from the latest complete snapshot. | |
| 239 | + | type RouteCreateSessionAction struct { | |
| 240 | + | RequestID uint64 | |
| 241 | + | SnapshotGeneration uint64 | |
| 242 | + | Key uint64 | |
| 243 | + | Generation uint64 | |
| 244 | + | SessionName string | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func (a RouteCreateSessionAction) Validate() error { | |
| 248 | + | if a.RequestID == 0 || a.SnapshotGeneration == 0 || a.Key == 0 || a.Generation == 0 || domain.ValidateSessionName(a.SessionName) != nil { | |
| 249 | + | return ErrInvalidRouteWire | |
| 250 | + | } | |
| 251 | + | return nil | |
| 252 | + | } | |
| 253 | + | ||
| 237 | 254 | // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It | |
| 238 | 255 | // carries only the exact lifecycle identity and the client-owned tab cursor; | |
| 239 | 256 | // transport origin remains proven by the existing authenticated connection. | |
| @@ -325,6 +342,20 @@ func (c RouteFailureCode) Validate() error { | |||
| 325 | 342 | return nil | |
| 326 | 343 | } | |
| 327 | 344 | ||
| 345 | + | // SessionCreationFailure reports a correlated pre-commit create transition | |
| 346 | + | // failure after the client has restored the source route. | |
| 347 | + | type SessionCreationFailure struct { | |
| 348 | + | RequestID uint64 | |
| 349 | + | Code RouteFailureCode | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func (f SessionCreationFailure) Validate() error { | |
| 353 | + | if f.RequestID == 0 || !f.Code.valid() { | |
| 354 | + | return ErrInvalidRouteWire | |
| 355 | + | } | |
| 356 | + | return nil | |
| 357 | + | } | |
| 358 | + | ||
| 328 | 359 | const ( | |
| 329 | 360 | // RouteSnapshotMaxEntries bounds one immutable publication and the amount | |
| 330 | 361 | // of work a receiver performs before returning to its transport loop. The | |
| @@ -234,6 +234,23 @@ type RouteNavigationAction struct { | |||||
| 234 | Generation uint64 | 234 | Generation uint64 | ||
| 235 | } | 235 | } | ||
| 236 | 236 | ||||
| 237 | + | // RouteCreateSessionAction asks the client to create a named session through | |||
| 238 | + | // one exact route authority from the latest complete snapshot. | |||
| 239 | + | type RouteCreateSessionAction struct { | |||
| 240 | + | RequestID uint64 | |||
| 241 | + | SnapshotGeneration uint64 | |||
| 242 | + | Key uint64 | |||
| 243 | + | Generation uint64 | |||
| 244 | + | SessionName string | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func (a RouteCreateSessionAction) Validate() error { | |||
| 248 | + | if a.RequestID == 0 || a.SnapshotGeneration == 0 || a.Key == 0 || a.Generation == 0 || domain.ValidateSessionName(a.SessionName) != nil { | |||
| 249 | + | return ErrInvalidRouteWire | |||
| 250 | + | } | |||
| 251 | + | return nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 237 | // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It | 254 | // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It | ||
| 238 | // carries only the exact lifecycle identity and the client-owned tab cursor; | 255 | // carries only the exact lifecycle identity and the client-owned tab cursor; | ||
| 239 | // transport origin remains proven by the existing authenticated connection. | 256 | // transport origin remains proven by the existing authenticated connection. | ||
| @@ -325,6 +342,20 @@ func (c RouteFailureCode) Validate() error { | |||||
| 325 | return nil | 342 | return nil | ||
| 326 | } | 343 | } | ||
| 327 | 344 | ||||
| 345 | + | // SessionCreationFailure reports a correlated pre-commit create transition | |||
| 346 | + | // failure after the client has restored the source route. | |||
| 347 | + | type SessionCreationFailure struct { | |||
| 348 | + | RequestID uint64 | |||
| 349 | + | Code RouteFailureCode | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func (f SessionCreationFailure) Validate() error { | |||
| 353 | + | if f.RequestID == 0 || !f.Code.valid() { | |||
| 354 | + | return ErrInvalidRouteWire | |||
| 355 | + | } | |||
| 356 | + | return nil | |||
| 357 | + | } | |||
| 358 | + | ||||
| 328 | const ( | 359 | const ( | ||
| 329 | // RouteSnapshotMaxEntries bounds one immutable publication and the amount | 360 | // RouteSnapshotMaxEntries bounds one immutable publication and the amount | ||
| 330 | // of work a receiver performs before returning to its transport loop. The | 361 | // of work a receiver performs before returning to its transport loop. The | ||
M internal/protocol/session.go +5 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 37 | |
| 16 | + | const Version uint16 = 38 | |
| 17 | 17 | ||
| 18 | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 19 | 19 | // first committed publication. | |
| @@ -197,6 +197,7 @@ type Output struct { | |||
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | 199 | type AttachTarget struct { | |
| 200 | + | RequestID uint64 | |
| 200 | 201 | Endpoint string | |
| 201 | 202 | Session string | |
| 202 | 203 | Intent uint8 | |
| @@ -375,6 +376,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||
| 375 | 376 | if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) { | |
| 376 | 377 | return ErrInvalidAttachTarget | |
| 377 | 378 | } | |
| 379 | + | if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew { | |
| 380 | + | return ErrInvalidAttachTarget | |
| 381 | + | } | |
| 378 | 382 | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | |
| 379 | 383 | return ErrInvalidAttachTarget | |
| 380 | 384 | } | |
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // Version is the negotiated vev session protocol version. | 15 | // Version is the negotiated vev session protocol version. | ||
| 16 | - | const Version uint16 = 37 | 16 | + | const Version uint16 = 38 |
| 17 | 17 | ||||
| 18 | // HandshakeTimeout bounds every transport handshake from connect through the | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 19 | // first committed publication. | 19 | // first committed publication. | ||
| @@ -197,6 +197,7 @@ type Output struct { | |||||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | type AttachTarget struct { | 199 | type AttachTarget struct { | ||
| 200 | + | RequestID uint64 | |||
| 200 | Endpoint string | 201 | Endpoint string | ||
| 201 | Session string | 202 | Session string | ||
| 202 | Intent uint8 | 203 | Intent uint8 | ||
| @@ -375,6 +376,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||||
| 375 | if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) { | 376 | if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) { | ||
| 376 | return ErrInvalidAttachTarget | 377 | return ErrInvalidAttachTarget | ||
| 377 | } | 378 | } | ||
| 379 | + | if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew { | |||
| 380 | + | return ErrInvalidAttachTarget | |||
| 381 | + | } | |||
| 378 | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | 382 | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | ||
| 379 | return ErrInvalidAttachTarget | 383 | return ErrInvalidAttachTarget | ||
| 380 | } | 384 | } | ||
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(37), Version) | |
| 13 | + | require.Equal(t, uint16(38), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-55, 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(37), Version) | 13 | + | require.Equal(t, uint16(38), Version) |
| 14 | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
M internal/protocol/wire/frame.go +4 -2
| @@ -8,8 +8,8 @@ const MaxFrameLen = 16 << 20 | |||
| 8 | 8 | type MsgType uint8 | |
| 9 | 9 | ||
| 10 | 10 | // Frame message types use non-contiguous allocations: the legacy client range | |
| 11 | - | // is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated | |
| 12 | - | // messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for | |
| 11 | + | // is 1–13 and 15, and newer client controls use 32–33, 35, and 37. Server-originated | |
| 12 | + | // messages occupy 16–23, 25–31, 34, 36, and 38. Values 14 and 24 remain reserved for | |
| 13 | 13 | // future extensions. | |
| 14 | 14 | const ( | |
| 15 | 15 | MsgHello MsgType = 1 | |
| @@ -29,6 +29,7 @@ const ( | |||
| 29 | 29 | MsgRouteAttentionSubscription MsgType = 32 | |
| 30 | 30 | MsgSamePeerSwitchRequest MsgType = 33 | |
| 31 | 31 | MsgParkedRouteRequest MsgType = 35 | |
| 32 | + | MsgSessionCreationFailure MsgType = 37 | |
| 32 | 33 | ||
| 33 | 34 | MsgWelcome MsgType = 16 | |
| 34 | 35 | MsgError MsgType = 17 | |
| @@ -47,6 +48,7 @@ const ( | |||
| 47 | 48 | MsgRoutePosition MsgType = 31 | |
| 48 | 49 | MsgSamePeerSwitchFailure MsgType = 34 | |
| 49 | 50 | MsgParkedRouteResponse MsgType = 36 | |
| 51 | + | MsgRouteCreateSession MsgType = 38 | |
| 50 | 52 | ) | |
| 51 | 53 | ||
| 52 | 54 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -8,8 +8,8 @@ const MaxFrameLen = 16 << 20 | |||||
| 8 | type MsgType uint8 | 8 | type MsgType uint8 | ||
| 9 | 9 | ||||
| 10 | // Frame message types use non-contiguous allocations: the legacy client range | 10 | // Frame message types use non-contiguous allocations: the legacy client range | ||
| 11 | - | // is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated | 11 | + | // is 1–13 and 15, and newer client controls use 32–33, 35, and 37. Server-originated |
| 12 | - | // messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for | 12 | + | // messages occupy 16–23, 25–31, 34, 36, and 38. Values 14 and 24 remain reserved for |
| 13 | // future extensions. | 13 | // future extensions. | ||
| 14 | const ( | 14 | const ( | ||
| 15 | MsgHello MsgType = 1 | 15 | MsgHello MsgType = 1 | ||
| @@ -29,6 +29,7 @@ const ( | |||||
| 29 | MsgRouteAttentionSubscription MsgType = 32 | 29 | MsgRouteAttentionSubscription MsgType = 32 | ||
| 30 | MsgSamePeerSwitchRequest MsgType = 33 | 30 | MsgSamePeerSwitchRequest MsgType = 33 | ||
| 31 | MsgParkedRouteRequest MsgType = 35 | 31 | MsgParkedRouteRequest MsgType = 35 | ||
| 32 | + | MsgSessionCreationFailure MsgType = 37 | |||
| 32 | 33 | ||||
| 33 | MsgWelcome MsgType = 16 | 34 | MsgWelcome MsgType = 16 | ||
| 34 | MsgError MsgType = 17 | 35 | MsgError MsgType = 17 | ||
| @@ -47,6 +48,7 @@ const ( | |||||
| 47 | MsgRoutePosition MsgType = 31 | 48 | MsgRoutePosition MsgType = 31 | ||
| 48 | MsgSamePeerSwitchFailure MsgType = 34 | 49 | MsgSamePeerSwitchFailure MsgType = 34 | ||
| 49 | MsgParkedRouteResponse MsgType = 36 | 50 | MsgParkedRouteResponse MsgType = 36 | ||
| 51 | + | MsgRouteCreateSession MsgType = 38 | |||
| 50 | ) | 52 | ) | ||
| 51 | 53 | ||||
| 52 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 54 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/protocol/wire/frame_test.go +4 -4
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestProtocolVersion(t *testing.T) { | |
| 10 | - | if protocol.Version != 37 { | |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version) | |
| 10 | + | if protocol.Version != 38 { | |
| 11 | + | t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version) | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
| @@ -37,14 +37,14 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 37 | 37 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | |
| 38 | 38 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | |
| 39 | 39 | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | |
| 40 | - | {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, | |
| 40 | + | {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure}, | |
| 41 | 41 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | |
| 42 | 42 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | |
| 43 | 43 | {"MsgCommandResult", MsgCommandResult}, | |
| 44 | 44 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | |
| 45 | 45 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | |
| 46 | 46 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | |
| 47 | - | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, | |
| 47 | + | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, {"MsgRouteCreateSession", MsgRouteCreateSession}, | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | seen := make(map[MsgType]string, len(tests)) | |
| @@ -7,8 +7,8 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | func TestProtocolVersion(t *testing.T) { | 9 | func TestProtocolVersion(t *testing.T) { | ||
| 10 | - | if protocol.Version != 37 { | 10 | + | if protocol.Version != 38 { |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version) | 11 | + | t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version) |
| 12 | } | 12 | } | ||
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| @@ -37,14 +37,14 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 37 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | 37 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | ||
| 38 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | 38 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | ||
| 39 | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | 39 | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | ||
| 40 | - | {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, | 40 | + | {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure}, |
| 41 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | 41 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | ||
| 42 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | 42 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | ||
| 43 | {"MsgCommandResult", MsgCommandResult}, | 43 | {"MsgCommandResult", MsgCommandResult}, | ||
| 44 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | 44 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | ||
| 45 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | 45 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | ||
| 46 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | 46 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | ||
| 47 | - | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, | 47 | + | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, {"MsgRouteCreateSession", MsgRouteCreateSession}, |
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | seen := make(map[MsgType]string, len(tests)) | 50 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/protocol/wire/navigation_test.go +3 -2
| @@ -153,8 +153,9 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 153 | 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | |
| 154 | 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 155 | 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 156 | - | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002502" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 157 | - | {name: "new rejects navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 156 | + | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002602" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 157 | + | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | |
| 158 | + | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 158 | 159 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 159 | 160 | {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | |
| 160 | 161 | {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| @@ -153,8 +153,9 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | ||
| 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 156 | - | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002502" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | 156 | + | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002602" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, |
| 157 | - | {name: "new rejects navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | 157 | + | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, |
| 158 | + | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |||
| 158 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 159 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
| 159 | {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | 160 | {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | ||
| 160 | {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | 161 | {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | ||
M internal/protocol/wire/remote_preview_hardening_test.go +1 -0
| @@ -194,6 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 194 | 194 | RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 195 | 195 | }) | |
| 196 | 196 | want := []byte{ | |
| 197 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 197 | 198 | 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2', | |
| 198 | 199 | 0x00, 0x04, 'w', 'o', 'r', 'k', protocol.IntentAttach, | |
| 199 | 200 | 0x01, | |
| @@ -194,6 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||||
| 194 | RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | 194 | RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | ||
| 195 | }) | 195 | }) | ||
| 196 | want := []byte{ | 196 | want := []byte{ | ||
| 197 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 197 | 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2', | 198 | 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2', | ||
| 198 | 0x00, 0x04, 'w', 'o', 'r', 'k', protocol.IntentAttach, | 199 | 0x00, 0x04, 'w', 'o', 'r', 'k', protocol.IntentAttach, | ||
| 199 | 0x01, | 200 | 0x01, | ||
M internal/protocol/wire/remote_target_wire_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | |
| 199 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | |
| 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | |
| 200 | 200 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | |
| 201 | 201 | if !bytes.Equal(got, want) { | |
| 202 | 202 | t.Fatalf("target bytes = %x, want %x", got, want) | |
| @@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||||
| 196 | } | 196 | } | ||
| 197 | 197 | ||||
| 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | ||
| 199 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} |
| 200 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | 200 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | ||
| 201 | if !bytes.Equal(got, want) { | 201 | if !bytes.Equal(got, want) { | ||
| 202 | t.Fatalf("target bytes = %x, want %x", got, want) | 202 | t.Fatalf("target bytes = %x, want %x", got, want) | ||
M internal/protocol/wire/routes_test.go +52 -0
| @@ -244,6 +244,58 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |||
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | func TestRouteCreateSessionCodecsAreStrict(t *testing.T) { | |
| 248 | + | action := protocol.RouteCreateSessionAction{RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"} | |
| 249 | + | failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable} | |
| 250 | + | tests := []struct { | |
| 251 | + | name string | |
| 252 | + | golden string | |
| 253 | + | want any | |
| 254 | + | marshal func() ([]byte, error) | |
| 255 | + | unmarshal func([]byte) (any, error) | |
| 256 | + | }{ | |
| 257 | + | { | |
| 258 | + | name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action, | |
| 259 | + | marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) }, | |
| 260 | + | unmarshal: func(data []byte) (any, error) { | |
| 261 | + | return UnmarshalRouteCreateSessionAction(data) | |
| 262 | + | }, | |
| 263 | + | }, | |
| 264 | + | { | |
| 265 | + | name: "creation failure", golden: "000000000000000503", want: failure, | |
| 266 | + | marshal: func() ([]byte, error) { return MarshalSessionCreationFailure(failure) }, | |
| 267 | + | unmarshal: func(data []byte) (any, error) { | |
| 268 | + | return UnmarshalSessionCreationFailure(data) | |
| 269 | + | }, | |
| 270 | + | }, | |
| 271 | + | } | |
| 272 | + | for _, tt := range tests { | |
| 273 | + | t.Run(tt.name, func(t *testing.T) { | |
| 274 | + | encoded, err := tt.marshal() | |
| 275 | + | require.NoError(t, err) | |
| 276 | + | require.Equal(t, tt.golden, hex.EncodeToString(encoded)) | |
| 277 | + | for i := range encoded { | |
| 278 | + | _, err := tt.unmarshal(encoded[:i]) | |
| 279 | + | require.Error(t, err, "prefix length %d", i) | |
| 280 | + | } | |
| 281 | + | _, err = tt.unmarshal(append(append([]byte(nil), encoded...), 0)) | |
| 282 | + | require.Error(t, err) | |
| 283 | + | got, err := tt.unmarshal(encoded) | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | require.Equal(t, tt.want, got) | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | for _, invalid := range []protocol.RouteCreateSessionAction{ | |
| 290 | + | {}, | |
| 291 | + | {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1}, | |
| 292 | + | {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "bad name"}, | |
| 293 | + | } { | |
| 294 | + | _, err := MarshalRouteCreateSessionAction(invalid) | |
| 295 | + | require.Error(t, err) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 247 | 299 | func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) { | |
| 248 | 300 | action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8} | |
| 249 | 301 | encoded, err := MarshalRouteNavigationAction(action) | |
| @@ -244,6 +244,58 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |||||
| 244 | } | 244 | } | ||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | + | func TestRouteCreateSessionCodecsAreStrict(t *testing.T) { | |||
| 248 | + | action := protocol.RouteCreateSessionAction{RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"} | |||
| 249 | + | failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable} | |||
| 250 | + | tests := []struct { | |||
| 251 | + | name string | |||
| 252 | + | golden string | |||
| 253 | + | want any | |||
| 254 | + | marshal func() ([]byte, error) | |||
| 255 | + | unmarshal func([]byte) (any, error) | |||
| 256 | + | }{ | |||
| 257 | + | { | |||
| 258 | + | name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action, | |||
| 259 | + | marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) }, | |||
| 260 | + | unmarshal: func(data []byte) (any, error) { | |||
| 261 | + | return UnmarshalRouteCreateSessionAction(data) | |||
| 262 | + | }, | |||
| 263 | + | }, | |||
| 264 | + | { | |||
| 265 | + | name: "creation failure", golden: "000000000000000503", want: failure, | |||
| 266 | + | marshal: func() ([]byte, error) { return MarshalSessionCreationFailure(failure) }, | |||
| 267 | + | unmarshal: func(data []byte) (any, error) { | |||
| 268 | + | return UnmarshalSessionCreationFailure(data) | |||
| 269 | + | }, | |||
| 270 | + | }, | |||
| 271 | + | } | |||
| 272 | + | for _, tt := range tests { | |||
| 273 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 274 | + | encoded, err := tt.marshal() | |||
| 275 | + | require.NoError(t, err) | |||
| 276 | + | require.Equal(t, tt.golden, hex.EncodeToString(encoded)) | |||
| 277 | + | for i := range encoded { | |||
| 278 | + | _, err := tt.unmarshal(encoded[:i]) | |||
| 279 | + | require.Error(t, err, "prefix length %d", i) | |||
| 280 | + | } | |||
| 281 | + | _, err = tt.unmarshal(append(append([]byte(nil), encoded...), 0)) | |||
| 282 | + | require.Error(t, err) | |||
| 283 | + | got, err := tt.unmarshal(encoded) | |||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | require.Equal(t, tt.want, got) | |||
| 286 | + | }) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | for _, invalid := range []protocol.RouteCreateSessionAction{ | |||
| 290 | + | {}, | |||
| 291 | + | {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1}, | |||
| 292 | + | {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "bad name"}, | |||
| 293 | + | } { | |||
| 294 | + | _, err := MarshalRouteCreateSessionAction(invalid) | |||
| 295 | + | require.Error(t, err) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 247 | func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) { | 299 | func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) { | ||
| 248 | action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8} | 300 | action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8} | ||
| 249 | encoded, err := MarshalRouteNavigationAction(action) | 301 | encoded, err := MarshalRouteNavigationAction(action) | ||
M internal/protocol/wire/routes_wire.go +72 -0
| @@ -631,6 +631,47 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e | |||
| 631 | 631 | return action, nil | |
| 632 | 632 | } | |
| 633 | 633 | ||
| 634 | + | func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) ([]byte, error) { | |
| 635 | + | if err := action.Validate(); err != nil { | |
| 636 | + | return nil, err | |
| 637 | + | } | |
| 638 | + | w := payloadWriter{} | |
| 639 | + | w.putUint64(action.RequestID) | |
| 640 | + | w.putUint64(action.SnapshotGeneration) | |
| 641 | + | w.putUint64(action.Key) | |
| 642 | + | w.putUint64(action.Generation) | |
| 643 | + | w.putString(action.SessionName) | |
| 644 | + | return w.b, nil | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAction, error) { | |
| 648 | + | r := payloadReader{b: b} | |
| 649 | + | var action protocol.RouteCreateSessionAction | |
| 650 | + | var err error | |
| 651 | + | if action.RequestID, err = r.getUint64(); err != nil { | |
| 652 | + | return protocol.RouteCreateSessionAction{}, err | |
| 653 | + | } | |
| 654 | + | if action.SnapshotGeneration, err = r.getUint64(); err != nil { | |
| 655 | + | return protocol.RouteCreateSessionAction{}, err | |
| 656 | + | } | |
| 657 | + | if action.Key, err = r.getUint64(); err != nil { | |
| 658 | + | return protocol.RouteCreateSessionAction{}, err | |
| 659 | + | } | |
| 660 | + | if action.Generation, err = r.getUint64(); err != nil { | |
| 661 | + | return protocol.RouteCreateSessionAction{}, err | |
| 662 | + | } | |
| 663 | + | if action.SessionName, err = r.getString(); err != nil { | |
| 664 | + | return protocol.RouteCreateSessionAction{}, err | |
| 665 | + | } | |
| 666 | + | if err := r.done(); err != nil { | |
| 667 | + | return protocol.RouteCreateSessionAction{}, err | |
| 668 | + | } | |
| 669 | + | if err := action.Validate(); err != nil { | |
| 670 | + | return protocol.RouteCreateSessionAction{}, err | |
| 671 | + | } | |
| 672 | + | return action, nil | |
| 673 | + | } | |
| 674 | + | ||
| 634 | 675 | func validateRouteNavigationFailure(failure protocol.RouteNavigationFailure) error { | |
| 635 | 676 | if failure.Key == 0 || failure.Generation == 0 { | |
| 636 | 677 | return fmt.Errorf("%w: failure identity is zero", protocol.ErrInvalidRouteWire) | |
| @@ -652,6 +693,37 @@ func MarshalRouteNavigationFailure(failure protocol.RouteNavigationFailure) ([]b | |||
| 652 | 693 | return w.b, nil | |
| 653 | 694 | } | |
| 654 | 695 | ||
| 696 | + | func MarshalSessionCreationFailure(failure protocol.SessionCreationFailure) ([]byte, error) { | |
| 697 | + | if err := failure.Validate(); err != nil { | |
| 698 | + | return nil, err | |
| 699 | + | } | |
| 700 | + | w := payloadWriter{} | |
| 701 | + | w.putUint64(failure.RequestID) | |
| 702 | + | w.putUint8(uint8(failure.Code)) | |
| 703 | + | return w.b, nil | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | func UnmarshalSessionCreationFailure(b []byte) (protocol.SessionCreationFailure, error) { | |
| 707 | + | r := payloadReader{b: b} | |
| 708 | + | var failure protocol.SessionCreationFailure | |
| 709 | + | var err error | |
| 710 | + | if failure.RequestID, err = r.getUint64(); err != nil { | |
| 711 | + | return protocol.SessionCreationFailure{}, err | |
| 712 | + | } | |
| 713 | + | code, err := r.getUint8() | |
| 714 | + | if err != nil { | |
| 715 | + | return protocol.SessionCreationFailure{}, err | |
| 716 | + | } | |
| 717 | + | failure.Code = protocol.RouteFailureCode(code) | |
| 718 | + | if err := r.done(); err != nil { | |
| 719 | + | return protocol.SessionCreationFailure{}, err | |
| 720 | + | } | |
| 721 | + | if err := failure.Validate(); err != nil { | |
| 722 | + | return protocol.SessionCreationFailure{}, err | |
| 723 | + | } | |
| 724 | + | return failure, nil | |
| 725 | + | } | |
| 726 | + | ||
| 655 | 727 | func UnmarshalRouteNavigationFailure(b []byte) (protocol.RouteNavigationFailure, error) { | |
| 656 | 728 | r := payloadReader{b: b} | |
| 657 | 729 | var failure protocol.RouteNavigationFailure | |
| @@ -631,6 +631,47 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e | |||||
| 631 | return action, nil | 631 | return action, nil | ||
| 632 | } | 632 | } | ||
| 633 | 633 | ||||
| 634 | + | func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) ([]byte, error) { | |||
| 635 | + | if err := action.Validate(); err != nil { | |||
| 636 | + | return nil, err | |||
| 637 | + | } | |||
| 638 | + | w := payloadWriter{} | |||
| 639 | + | w.putUint64(action.RequestID) | |||
| 640 | + | w.putUint64(action.SnapshotGeneration) | |||
| 641 | + | w.putUint64(action.Key) | |||
| 642 | + | w.putUint64(action.Generation) | |||
| 643 | + | w.putString(action.SessionName) | |||
| 644 | + | return w.b, nil | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAction, error) { | |||
| 648 | + | r := payloadReader{b: b} | |||
| 649 | + | var action protocol.RouteCreateSessionAction | |||
| 650 | + | var err error | |||
| 651 | + | if action.RequestID, err = r.getUint64(); err != nil { | |||
| 652 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 653 | + | } | |||
| 654 | + | if action.SnapshotGeneration, err = r.getUint64(); err != nil { | |||
| 655 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 656 | + | } | |||
| 657 | + | if action.Key, err = r.getUint64(); err != nil { | |||
| 658 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 659 | + | } | |||
| 660 | + | if action.Generation, err = r.getUint64(); err != nil { | |||
| 661 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 662 | + | } | |||
| 663 | + | if action.SessionName, err = r.getString(); err != nil { | |||
| 664 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 665 | + | } | |||
| 666 | + | if err := r.done(); err != nil { | |||
| 667 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 668 | + | } | |||
| 669 | + | if err := action.Validate(); err != nil { | |||
| 670 | + | return protocol.RouteCreateSessionAction{}, err | |||
| 671 | + | } | |||
| 672 | + | return action, nil | |||
| 673 | + | } | |||
| 674 | + | ||||
| 634 | func validateRouteNavigationFailure(failure protocol.RouteNavigationFailure) error { | 675 | func validateRouteNavigationFailure(failure protocol.RouteNavigationFailure) error { | ||
| 635 | if failure.Key == 0 || failure.Generation == 0 { | 676 | if failure.Key == 0 || failure.Generation == 0 { | ||
| 636 | return fmt.Errorf("%w: failure identity is zero", protocol.ErrInvalidRouteWire) | 677 | return fmt.Errorf("%w: failure identity is zero", protocol.ErrInvalidRouteWire) | ||
| @@ -652,6 +693,37 @@ func MarshalRouteNavigationFailure(failure protocol.RouteNavigationFailure) ([]b | |||||
| 652 | return w.b, nil | 693 | return w.b, nil | ||
| 653 | } | 694 | } | ||
| 654 | 695 | ||||
| 696 | + | func MarshalSessionCreationFailure(failure protocol.SessionCreationFailure) ([]byte, error) { | |||
| 697 | + | if err := failure.Validate(); err != nil { | |||
| 698 | + | return nil, err | |||
| 699 | + | } | |||
| 700 | + | w := payloadWriter{} | |||
| 701 | + | w.putUint64(failure.RequestID) | |||
| 702 | + | w.putUint8(uint8(failure.Code)) | |||
| 703 | + | return w.b, nil | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | func UnmarshalSessionCreationFailure(b []byte) (protocol.SessionCreationFailure, error) { | |||
| 707 | + | r := payloadReader{b: b} | |||
| 708 | + | var failure protocol.SessionCreationFailure | |||
| 709 | + | var err error | |||
| 710 | + | if failure.RequestID, err = r.getUint64(); err != nil { | |||
| 711 | + | return protocol.SessionCreationFailure{}, err | |||
| 712 | + | } | |||
| 713 | + | code, err := r.getUint8() | |||
| 714 | + | if err != nil { | |||
| 715 | + | return protocol.SessionCreationFailure{}, err | |||
| 716 | + | } | |||
| 717 | + | failure.Code = protocol.RouteFailureCode(code) | |||
| 718 | + | if err := r.done(); err != nil { | |||
| 719 | + | return protocol.SessionCreationFailure{}, err | |||
| 720 | + | } | |||
| 721 | + | if err := failure.Validate(); err != nil { | |||
| 722 | + | return protocol.SessionCreationFailure{}, err | |||
| 723 | + | } | |||
| 724 | + | return failure, nil | |||
| 725 | + | } | |||
| 726 | + | ||||
| 655 | func UnmarshalRouteNavigationFailure(b []byte) (protocol.RouteNavigationFailure, error) { | 727 | func UnmarshalRouteNavigationFailure(b []byte) (protocol.RouteNavigationFailure, error) { | ||
| 656 | r := payloadReader{b: b} | 728 | r := payloadReader{b: b} | ||
| 657 | var failure protocol.RouteNavigationFailure | 729 | var failure protocol.RouteNavigationFailure | ||
M internal/protocol/wire/wire.go +7 -0
| @@ -1200,6 +1200,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||
| 1200 | 1200 | return nil | |
| 1201 | 1201 | } | |
| 1202 | 1202 | w := payloadWriter{} | |
| 1203 | + | w.putUint64(m.RequestID) | |
| 1203 | 1204 | w.putString(m.Endpoint) | |
| 1204 | 1205 | w.putString(m.Session) | |
| 1205 | 1206 | w.putUint8(m.Intent) | |
| @@ -1213,6 +1214,9 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||
| 1213 | 1214 | func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |
| 1214 | 1215 | // Preflight lengths and intent before getString can allocate either value. | |
| 1215 | 1216 | probe := payloadReader{b: b} | |
| 1217 | + | if _, err := probe.getUint64(); err != nil { | |
| 1218 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1219 | + | } | |
| 1216 | 1220 | endpointLen, err := probe.getUint16() | |
| 1217 | 1221 | if err != nil || int(endpointLen) > len(probe.b) { | |
| 1218 | 1222 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| @@ -1242,6 +1246,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||
| 1242 | 1246 | ||
| 1243 | 1247 | r := payloadReader{b: b} | |
| 1244 | 1248 | var m protocol.AttachTarget | |
| 1249 | + | if m.RequestID, err = r.getUint64(); err != nil { | |
| 1250 | + | return protocol.AttachTarget{}, err | |
| 1251 | + | } | |
| 1245 | 1252 | if m.Endpoint, err = r.getString(); err != nil { | |
| 1246 | 1253 | return protocol.AttachTarget{}, err | |
| 1247 | 1254 | } | |
| @@ -1200,6 +1200,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||||
| 1200 | return nil | 1200 | return nil | ||
| 1201 | } | 1201 | } | ||
| 1202 | w := payloadWriter{} | 1202 | w := payloadWriter{} | ||
| 1203 | + | w.putUint64(m.RequestID) | |||
| 1203 | w.putString(m.Endpoint) | 1204 | w.putString(m.Endpoint) | ||
| 1204 | w.putString(m.Session) | 1205 | w.putString(m.Session) | ||
| 1205 | w.putUint8(m.Intent) | 1206 | w.putUint8(m.Intent) | ||
| @@ -1213,6 +1214,9 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||||
| 1213 | func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | 1214 | func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | ||
| 1214 | // Preflight lengths and intent before getString can allocate either value. | 1215 | // Preflight lengths and intent before getString can allocate either value. | ||
| 1215 | probe := payloadReader{b: b} | 1216 | probe := payloadReader{b: b} | ||
| 1217 | + | if _, err := probe.getUint64(); err != nil { | |||
| 1218 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |||
| 1219 | + | } | |||
| 1216 | endpointLen, err := probe.getUint16() | 1220 | endpointLen, err := probe.getUint16() | ||
| 1217 | if err != nil || int(endpointLen) > len(probe.b) { | 1221 | if err != nil || int(endpointLen) > len(probe.b) { | ||
| 1218 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | 1222 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | ||
| @@ -1242,6 +1246,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||||
| 1242 | 1246 | ||||
| 1243 | r := payloadReader{b: b} | 1247 | r := payloadReader{b: b} | ||
| 1244 | var m protocol.AttachTarget | 1248 | var m protocol.AttachTarget | ||
| 1249 | + | if m.RequestID, err = r.getUint64(); err != nil { | |||
| 1250 | + | return protocol.AttachTarget{}, err | |||
| 1251 | + | } | |||
| 1245 | if m.Endpoint, err = r.getString(); err != nil { | 1252 | if m.Endpoint, err = r.getString(); err != nil { | ||
| 1246 | return protocol.AttachTarget{}, err | 1253 | return protocol.AttachTarget{}, err | ||
| 1247 | } | 1254 | } | ||
M internal/protocol/wire/wire_final_test.go +20 -7
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 15 | - | if protocol.Version != 37 { | |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version) | |
| 15 | + | if protocol.Version != 38 { | |
| 16 | + | t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version) | |
| 17 | 17 | } | |
| 18 | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 19 | 19 | if len(payload) < 2 { | |
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 27 | 27 | ||
| 28 | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 29 | 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 30 | - | want := append([]byte{0, 37, 2}, make([]byte, 16+8)...) | |
| 30 | + | want := append([]byte{0, 38, 2}, make([]byte, 16+8)...) | |
| 31 | 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 32 | 32 | want = append(want, 0, 0) | |
| 33 | 33 | got := MarshalHello(msg) | |
| @@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 196 | 196 | ||
| 197 | 197 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |
| 198 | 198 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | |
| 199 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} | |
| 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} | |
| 200 | 200 | got := MarshalAttachTarget(msg) | |
| 201 | 201 | if !bytes.Equal(got, want) { | |
| 202 | 202 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | |
| @@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 212 | 212 | if localPayload == nil { | |
| 213 | 213 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | |
| 214 | 214 | } | |
| 215 | - | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | |
| 215 | + | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | |
| 216 | 216 | if !bytes.Equal(localPayload, wantLocal) { | |
| 217 | 217 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | |
| 218 | 218 | } | |
| @@ -222,7 +222,19 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 222 | 222 | } | |
| 223 | 223 | assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget) | |
| 224 | 224 | assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget) | |
| 225 | - | for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | |
| 225 | + | creation := protocol.AttachTarget{RequestID: 7, Endpoint: "host", Session: "example", Intent: protocol.IntentNew, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned} | |
| 226 | + | creationPayload := MarshalAttachTarget(creation) | |
| 227 | + | if creationPayload == nil { | |
| 228 | + | t.Fatal("MarshalAttachTarget rejected correlated remote creation") | |
| 229 | + | } | |
| 230 | + | decodedCreation, err := UnmarshalAttachTarget(creationPayload) | |
| 231 | + | if err != nil || decodedCreation != creation { | |
| 232 | + | t.Fatalf("creation target = %+v, error %v, want %+v", decodedCreation, err, creation) | |
| 233 | + | } | |
| 234 | + | assertAllPrefixesFail(t, creationPayload, UnmarshalAttachTarget) | |
| 235 | + | assertTrailingGarbageFails(t, creationPayload, UnmarshalAttachTarget) | |
| 236 | + | ||
| 237 | + | for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}, {Endpoint: "host", Session: "example", Intent: protocol.IntentNew}} { | |
| 226 | 238 | if got := MarshalAttachTarget(bad); got != nil { | |
| 227 | 239 | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | |
| 228 | 240 | } | |
| @@ -234,7 +246,8 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |||
| 234 | 246 | lifecycle[0] = 1 | |
| 235 | 247 | target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach, ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true} | |
| 236 | 248 | payload := MarshalAttachTarget(target) | |
| 237 | - | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | |
| 249 | + | want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...) | |
| 250 | + | want = append(want, make([]byte, 15)...) | |
| 238 | 251 | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) | |
| 239 | 252 | if !bytes.Equal(payload, want) { | |
| 240 | 253 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | |
| @@ -12,8 +12,8 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 15 | - | if protocol.Version != 37 { | 15 | + | if protocol.Version != 38 { |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version) | 16 | + | t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version) |
| 17 | } | 17 | } | ||
| 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 19 | if len(payload) < 2 { | 19 | if len(payload) < 2 { | ||
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 27 | 27 | ||||
| 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 30 | - | want := append([]byte{0, 37, 2}, make([]byte, 16+8)...) | 30 | + | want := append([]byte{0, 38, 2}, make([]byte, 16+8)...) |
| 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | ||
| 32 | want = append(want, 0, 0) | 32 | want = append(want, 0, 0) | ||
| 33 | got := MarshalHello(msg) | 33 | got := MarshalHello(msg) | ||
| @@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||||
| 196 | 196 | ||||
| 197 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | 197 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | ||
| 198 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | 198 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | ||
| 199 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} | 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} |
| 200 | got := MarshalAttachTarget(msg) | 200 | got := MarshalAttachTarget(msg) | ||
| 201 | if !bytes.Equal(got, want) { | 201 | if !bytes.Equal(got, want) { | ||
| 202 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | 202 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | ||
| @@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||||
| 212 | if localPayload == nil { | 212 | if localPayload == nil { | ||
| 213 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | 213 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | ||
| 214 | } | 214 | } | ||
| 215 | - | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | 215 | + | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} |
| 216 | if !bytes.Equal(localPayload, wantLocal) { | 216 | if !bytes.Equal(localPayload, wantLocal) { | ||
| 217 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | 217 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | ||
| 218 | } | 218 | } | ||
| @@ -222,7 +222,19 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||||
| 222 | } | 222 | } | ||
| 223 | assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget) | 223 | assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget) | ||
| 224 | assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget) | 224 | assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget) | ||
| 225 | - | for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | 225 | + | creation := protocol.AttachTarget{RequestID: 7, Endpoint: "host", Session: "example", Intent: protocol.IntentNew, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned} |
| 226 | + | creationPayload := MarshalAttachTarget(creation) | |||
| 227 | + | if creationPayload == nil { | |||
| 228 | + | t.Fatal("MarshalAttachTarget rejected correlated remote creation") | |||
| 229 | + | } | |||
| 230 | + | decodedCreation, err := UnmarshalAttachTarget(creationPayload) | |||
| 231 | + | if err != nil || decodedCreation != creation { | |||
| 232 | + | t.Fatalf("creation target = %+v, error %v, want %+v", decodedCreation, err, creation) | |||
| 233 | + | } | |||
| 234 | + | assertAllPrefixesFail(t, creationPayload, UnmarshalAttachTarget) | |||
| 235 | + | assertTrailingGarbageFails(t, creationPayload, UnmarshalAttachTarget) | |||
| 236 | + | ||||
| 237 | + | for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}, {Endpoint: "host", Session: "example", Intent: protocol.IntentNew}} { | |||
| 226 | if got := MarshalAttachTarget(bad); got != nil { | 238 | if got := MarshalAttachTarget(bad); got != nil { | ||
| 227 | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | 239 | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | ||
| 228 | } | 240 | } | ||
| @@ -234,7 +246,8 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |||||
| 234 | lifecycle[0] = 1 | 246 | lifecycle[0] = 1 | ||
| 235 | target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach, ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true} | 247 | target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach, ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true} | ||
| 236 | payload := MarshalAttachTarget(target) | 248 | payload := MarshalAttachTarget(target) | ||
| 237 | - | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | 249 | + | want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...) |
| 250 | + | want = append(want, make([]byte, 15)...) | |||
| 238 | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) | 251 | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) | ||
| 239 | if !bytes.Equal(payload, want) { | 252 | if !bytes.Equal(payload, want) { | ||
| 240 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | 253 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | ||
M internal/protocol/wire/wire_test.go +6 -6
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | got := MarshalHello(msg) | |
| 138 | 138 | want := []byte{ | |
| 139 | - | 0x00, 0x25, 0x00, // version, intent, first client ID byte | |
| 139 | + | 0x00, 0x26, 0x00, // version, intent, first client ID byte | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 142 | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 367 | 367 | } | |
| 368 | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 369 | 369 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 370 | - | require.Equal(t, uint16(37), protocol.Version) | |
| 370 | + | require.Equal(t, uint16(38), protocol.Version) | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 528 | 528 | name: "minimal", | |
| 529 | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | |
| 530 | 530 | want: []byte{ | |
| 531 | - | 0x00, 0x25, | |
| 531 | + | 0x00, 0x26, | |
| 532 | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 533 | 533 | 0x00, // attached | |
| 534 | 534 | 0x00, // self | |
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 551 | 551 | JSON: true, | |
| 552 | 552 | }, | |
| 553 | 553 | want: []byte{ | |
| 554 | - | 0x00, 0x25, | |
| 554 | + | 0x00, 0x26, | |
| 555 | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 556 | 556 | 0x00, // attached | |
| 557 | 557 | 0x01, // self | |
| @@ -988,7 +988,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 988 | 988 | name: "attached", | |
| 989 | 989 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 990 | 990 | want: []byte{ | |
| 991 | - | 0x00, 0x25, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 991 | + | 0x00, 0x26, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 992 | 992 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 993 | 993 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 994 | 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -998,7 +998,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 998 | 998 | name: "control", | |
| 999 | 999 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | |
| 1000 | 1000 | want: []byte{ | |
| 1001 | - | 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1001 | + | 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1002 | 1002 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 1003 | 1003 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1004 | 1004 | }, | |
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 136 | } | 136 | } | ||
| 137 | got := MarshalHello(msg) | 137 | got := MarshalHello(msg) | ||
| 138 | want := []byte{ | 138 | want := []byte{ | ||
| 139 | - | 0x00, 0x25, 0x00, // version, intent, first client ID byte | 139 | + | 0x00, 0x26, 0x00, // version, intent, first client ID byte |
| 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 367 | } | 367 | } | ||
| 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 369 | require.Equal(t, want, MarshalTheme(cleared)) | 369 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 370 | - | require.Equal(t, uint16(37), protocol.Version) | 370 | + | require.Equal(t, uint16(38), protocol.Version) |
| 371 | } | 371 | } | ||
| 372 | 372 | ||||
| 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 528 | name: "minimal", | 528 | name: "minimal", | ||
| 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | ||
| 530 | want: []byte{ | 530 | want: []byte{ | ||
| 531 | - | 0x00, 0x25, | 531 | + | 0x00, 0x26, |
| 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 533 | 0x00, // attached | 533 | 0x00, // attached | ||
| 534 | 0x00, // self | 534 | 0x00, // self | ||
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 551 | JSON: true, | 551 | JSON: true, | ||
| 552 | }, | 552 | }, | ||
| 553 | want: []byte{ | 553 | want: []byte{ | ||
| 554 | - | 0x00, 0x25, | 554 | + | 0x00, 0x26, |
| 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 556 | 0x00, // attached | 556 | 0x00, // attached | ||
| 557 | 0x01, // self | 557 | 0x01, // self | ||
| @@ -988,7 +988,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 988 | name: "attached", | 988 | name: "attached", | ||
| 989 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 989 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 990 | want: []byte{ | 990 | want: []byte{ | ||
| 991 | - | 0x00, 0x25, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 991 | + | 0x00, 0x26, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 992 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 992 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 993 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 993 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -998,7 +998,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 998 | name: "control", | 998 | name: "control", | ||
| 999 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | 999 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | ||
| 1000 | want: []byte{ | 1000 | want: []byte{ | ||
| 1001 | - | 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 1001 | + | 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 1002 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 1002 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 1003 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 1003 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 1004 | }, | 1004 | }, | ||
M internal/usecase/client/client.go +156 -3
| @@ -229,6 +229,7 @@ type Runner struct { | |||
| 229 | 229 | origin protocol.RouteOrigin | |
| 230 | 230 | ledger *routeLedger | |
| 231 | 231 | routeFailure *protocol.RouteNavigationFailure | |
| 232 | + | creationFailure *protocol.SessionCreationFailure | |
| 232 | 233 | ||
| 233 | 234 | inputMu sync.Mutex | |
| 234 | 235 | input *terminalInputPump | |
| @@ -393,7 +394,8 @@ func validateAttachRequest(request AttachRequest) error { | |||
| 393 | 394 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | |
| 394 | 395 | return fmt.Errorf("vev: invalid session target: %w", err) | |
| 395 | 396 | } | |
| 396 | - | if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned); err != nil { | |
| 397 | + | homePickerRoute := request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || request.Intent == protocol.IntentNew | |
| 398 | + | if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, homePickerRoute); err != nil { | |
| 397 | 399 | return fmt.Errorf("vev: invalid navigation route: %w", err) | |
| 398 | 400 | } | |
| 399 | 401 | if request.RemoteTarget == nil { | |
| @@ -468,6 +470,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 468 | 470 | var routeNavigationSelection *routeNavigationSelection | |
| 469 | 471 | var routeNavigationFallback *attachRoute | |
| 470 | 472 | var routeNavigationAction *protocol.RouteNavigationAction | |
| 473 | + | creationPending := false | |
| 474 | + | var creationFallback *attachRoute | |
| 475 | + | var creationRequestID uint64 | |
| 471 | 476 | backoff := defaultReconnectBackoff.initial | |
| 472 | 477 | themeState := &terminalThemeState{} | |
| 473 | 478 | var rememberOnce sync.Once | |
| @@ -628,6 +633,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 628 | 633 | if err != nil { | |
| 629 | 634 | err = handshakeContextError(ctx, timedOut, err) | |
| 630 | 635 | finishHandshake() | |
| 636 | + | if creationPending && creationFallback != nil && ctx.Err() == nil { | |
| 637 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(err)} | |
| 638 | + | route := *creationFallback | |
| 639 | + | creationFallback = nil | |
| 640 | + | creationPending = false | |
| 641 | + | creationRequestID = 0 | |
| 642 | + | returnNavigationPending = true | |
| 643 | + | restoreReturnRoute(route) | |
| 644 | + | continue | |
| 645 | + | } | |
| 631 | 646 | if resumeToken == 0 || ctx.Err() != nil { | |
| 632 | 647 | if clearErr := reconnect.clear(); clearErr != nil { | |
| 633 | 648 | return errors.Join(err, clearErr) | |
| @@ -655,7 +670,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 655 | 670 | ms.dialed = true | |
| 656 | 671 | if transport == nil { | |
| 657 | 672 | finishHandshake() | |
| 658 | - | return errors.New("vev: dialer returned nil transport") | |
| 673 | + | err := errors.New("vev: dialer returned nil transport") | |
| 674 | + | if creationPending && creationFallback != nil { | |
| 675 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable} | |
| 676 | + | route := *creationFallback | |
| 677 | + | creationFallback = nil | |
| 678 | + | creationPending = false | |
| 679 | + | creationRequestID = 0 | |
| 680 | + | returnNavigationPending = true | |
| 681 | + | restoreReturnRoute(route) | |
| 682 | + | continue | |
| 683 | + | } | |
| 684 | + | return err | |
| 659 | 685 | } | |
| 660 | 686 | stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport) | |
| 661 | 687 | connection, err := NewSessionConnection(transport, SessionTarget{ | |
| @@ -666,6 +692,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 666 | 692 | stopHandshakeTransport() | |
| 667 | 693 | finishHandshake() | |
| 668 | 694 | _ = transport.Close() | |
| 695 | + | if creationPending && creationFallback != nil { | |
| 696 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable} | |
| 697 | + | route := *creationFallback | |
| 698 | + | creationFallback = nil | |
| 699 | + | creationPending = false | |
| 700 | + | creationRequestID = 0 | |
| 701 | + | returnNavigationPending = true | |
| 702 | + | restoreReturnRoute(route) | |
| 703 | + | continue | |
| 704 | + | } | |
| 669 | 705 | return err | |
| 670 | 706 | } | |
| 671 | 707 | ||
| @@ -731,6 +767,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 731 | 767 | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | |
| 732 | 768 | attemptRequest.RemoteTarget = nil | |
| 733 | 769 | } | |
| 770 | + | if result.err == nil && result.welcomed && creationPending { | |
| 771 | + | creationPending = false | |
| 772 | + | creationFallback = nil | |
| 773 | + | creationRequestID = 0 | |
| 774 | + | } | |
| 734 | 775 | if result.err == nil && result.welcomed && routeNavigationPending { | |
| 735 | 776 | routeNavigationPending = false | |
| 736 | 777 | routeNavigationSelection = nil | |
| @@ -738,6 +779,40 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 738 | 779 | routeNavigationFallback = nil | |
| 739 | 780 | routeNavigationAction = nil | |
| 740 | 781 | } | |
| 782 | + | if result.routeCreateAction != nil { | |
| 783 | + | action := *result.routeCreateAction | |
| 784 | + | source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken} | |
| 785 | + | if r.ledger == nil { | |
| 786 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureUnavailable} | |
| 787 | + | returnNavigationPending = true | |
| 788 | + | restoreReturnRoute(source) | |
| 789 | + | continue | |
| 790 | + | } | |
| 791 | + | selection, valid := r.ledger.creationSelection(action) | |
| 792 | + | if !valid { | |
| 793 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureStaleSelection} | |
| 794 | + | returnNavigationPending = true | |
| 795 | + | restoreReturnRoute(source) | |
| 796 | + | continue | |
| 797 | + | } | |
| 798 | + | creationFallback = &attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken} | |
| 799 | + | dialer = selection.selected.dialer | |
| 800 | + | attemptRequest = cloneAttachRequest(selection.selected.request) | |
| 801 | + | attemptRequest.Intent = protocol.IntentNew | |
| 802 | + | attemptRequest.SessionName = action.SessionName | |
| 803 | + | attemptRequest.ExactTarget = nil | |
| 804 | + | attemptRequest.RemoteTarget = nil | |
| 805 | + | attemptRequest.PreferredTabID = "" | |
| 806 | + | attemptRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 807 | + | attemptRequest.NavigationCapabilities = 0 | |
| 808 | + | attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |
| 809 | + | resumeToken = 0 | |
| 810 | + | creationPending = true | |
| 811 | + | creationRequestID = action.RequestID | |
| 812 | + | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| 813 | + | backoff = defaultReconnectBackoff.initial | |
| 814 | + | continue | |
| 815 | + | } | |
| 741 | 816 | if result.routeAction != nil { | |
| 742 | 817 | if r.ledger == nil { | |
| 743 | 818 | return errors.New("vev: route ledger unavailable") | |
| @@ -846,13 +921,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 846 | 921 | } | |
| 847 | 922 | nextDialer, nextRequest, handoffErr = r.attachHandoff(*target) | |
| 848 | 923 | if handoffErr != nil { | |
| 924 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| 925 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)} | |
| 926 | + | returnNavigationPending = true | |
| 927 | + | restoreReturnRoute(source) | |
| 928 | + | continue | |
| 929 | + | } | |
| 849 | 930 | return handoffErr | |
| 850 | 931 | } | |
| 851 | 932 | } | |
| 852 | 933 | if target.ExactTarget != nil { | |
| 853 | 934 | nextRequest.ExactTarget = target.ExactTarget | |
| 854 | 935 | } | |
| 855 | - | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned) { | |
| 936 | + | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || target.Intent == protocol.IntentNew) { | |
| 856 | 937 | nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker | |
| 857 | 938 | } | |
| 858 | 939 | if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker { | |
| @@ -861,11 +942,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 861 | 942 | } | |
| 862 | 943 | nextRequest = cloneAttachRequest(nextRequest) | |
| 863 | 944 | if err := validateAttachRequest(nextRequest); err != nil { | |
| 945 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| 946 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable} | |
| 947 | + | returnNavigationPending = true | |
| 948 | + | restoreReturnRoute(source) | |
| 949 | + | continue | |
| 950 | + | } | |
| 864 | 951 | return fmt.Errorf("vev: invalid route handoff request: %w", err) | |
| 865 | 952 | } | |
| 866 | 953 | if nextDialer == nil { | |
| 954 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| 955 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable} | |
| 956 | + | returnNavigationPending = true | |
| 957 | + | restoreReturnRoute(source) | |
| 958 | + | continue | |
| 959 | + | } | |
| 867 | 960 | return errors.New("vev: route handoff returned nil dialer") | |
| 868 | 961 | } | |
| 962 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| 963 | + | fallback := source | |
| 964 | + | creationFallback = &fallback | |
| 965 | + | creationPending = true | |
| 966 | + | creationRequestID = target.RequestID | |
| 967 | + | } | |
| 869 | 968 | dialer = nextDialer | |
| 870 | 969 | attemptRequest = nextRequest | |
| 871 | 970 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| @@ -894,6 +993,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 894 | 993 | restoreReturnRoute(route) | |
| 895 | 994 | continue | |
| 896 | 995 | } | |
| 996 | + | if creationPending && creationFallback != nil { | |
| 997 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(result.err)} | |
| 998 | + | route := *creationFallback | |
| 999 | + | creationFallback = nil | |
| 1000 | + | creationPending = false | |
| 1001 | + | creationRequestID = 0 | |
| 1002 | + | returnNavigationPending = true | |
| 1003 | + | restoreReturnRoute(route) | |
| 1004 | + | continue | |
| 1005 | + | } | |
| 897 | 1006 | if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) { | |
| 898 | 1007 | attemptRequest.Intent = protocol.IntentAttach | |
| 899 | 1008 | resumeToken = 0 | |
| @@ -1121,6 +1230,7 @@ type attachResult struct { | |||
| 1121 | 1230 | handoff *attachHandoff | |
| 1122 | 1231 | action protocol.NavigationAction | |
| 1123 | 1232 | routeAction *protocol.RouteNavigationAction | |
| 1233 | + | routeCreateAction *protocol.RouteCreateSessionAction | |
| 1124 | 1234 | err error | |
| 1125 | 1235 | } | |
| 1126 | 1236 | ||
| @@ -1325,6 +1435,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1325 | 1435 | if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target { | |
| 1326 | 1436 | return welcomedResult(errRouteTargetChanged) | |
| 1327 | 1437 | } | |
| 1438 | + | if request.Intent == protocol.IntentNew && request.SessionName != committedIdentity.Target.SessionName { | |
| 1439 | + | return welcomedResult(errRouteTargetChanged) | |
| 1440 | + | } | |
| 1328 | 1441 | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) { | |
| 1329 | 1442 | return welcomedResult(errRouteTargetChanged) | |
| 1330 | 1443 | } | |
| @@ -1352,6 +1465,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1352 | 1465 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | |
| 1353 | 1466 | } | |
| 1354 | 1467 | } | |
| 1468 | + | if a.runner.creationFailure != nil { | |
| 1469 | + | if err := sendHandshake(func() error { | |
| 1470 | + | return transport.SendClient(*a.runner.creationFailure) | |
| 1471 | + | }); err != nil { | |
| 1472 | + | return welcomedResult(fmt.Errorf("vev: publishing session creation failure: %w", err)) | |
| 1473 | + | } | |
| 1474 | + | a.runner.creationFailure = nil | |
| 1475 | + | } | |
| 1355 | 1476 | if a.runner.routeFailure != nil { | |
| 1356 | 1477 | if err := sendHandshake(func() error { | |
| 1357 | 1478 | return transport.SendClient(*a.runner.routeFailure) | |
| @@ -1777,6 +1898,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1777 | 1898 | return context.Canceled | |
| 1778 | 1899 | } | |
| 1779 | 1900 | } | |
| 1901 | + | sendCreationFailure := func(requestID uint64, code protocol.RouteFailureCode) error { | |
| 1902 | + | failure := protocol.SessionCreationFailure{RequestID: requestID, Code: code} | |
| 1903 | + | if failure.Validate() != nil { | |
| 1904 | + | return protocol.ErrInvalidRouteWire | |
| 1905 | + | } | |
| 1906 | + | select { | |
| 1907 | + | case sendCh <- failure: | |
| 1908 | + | return nil | |
| 1909 | + | case <-loopCtx.Done(): | |
| 1910 | + | return context.Canceled | |
| 1911 | + | } | |
| 1912 | + | } | |
| 1780 | 1913 | dismissReconnect := func() error { | |
| 1781 | 1914 | if !reconnect.showing { | |
| 1782 | 1915 | return nil | |
| @@ -2038,6 +2171,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2038 | 2171 | navigation := welcomedResult(nil) | |
| 2039 | 2172 | navigation.action = directive.Action | |
| 2040 | 2173 | return navigation | |
| 2174 | + | case protocol.RouteCreateSessionAction: | |
| 2175 | + | action := message | |
| 2176 | + | if action.Validate() != nil { | |
| 2177 | + | continue | |
| 2178 | + | } | |
| 2179 | + | if a.runner.ledger == nil { | |
| 2180 | + | if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureUnavailable); ferr != nil { | |
| 2181 | + | return welcomedResult(ferr) | |
| 2182 | + | } | |
| 2183 | + | continue | |
| 2184 | + | } | |
| 2185 | + | if _, valid := a.runner.ledger.creationSelection(action); !valid { | |
| 2186 | + | if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureStaleSelection); ferr != nil { | |
| 2187 | + | return welcomedResult(ferr) | |
| 2188 | + | } | |
| 2189 | + | continue | |
| 2190 | + | } | |
| 2191 | + | result := welcomedResult(nil) | |
| 2192 | + | result.routeCreateAction = &action | |
| 2193 | + | return result | |
| 2041 | 2194 | case protocol.RouteNavigationAction: | |
| 2042 | 2195 | action := message | |
| 2043 | 2196 | if a.runner.ledger == nil { | |
| @@ -229,6 +229,7 @@ type Runner struct { | |||||
| 229 | origin protocol.RouteOrigin | 229 | origin protocol.RouteOrigin | ||
| 230 | ledger *routeLedger | 230 | ledger *routeLedger | ||
| 231 | routeFailure *protocol.RouteNavigationFailure | 231 | routeFailure *protocol.RouteNavigationFailure | ||
| 232 | + | creationFailure *protocol.SessionCreationFailure | |||
| 232 | 233 | ||||
| 233 | inputMu sync.Mutex | 234 | inputMu sync.Mutex | ||
| 234 | input *terminalInputPump | 235 | input *terminalInputPump | ||
| @@ -393,7 +394,8 @@ func validateAttachRequest(request AttachRequest) error { | |||||
| 393 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | 394 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | ||
| 394 | return fmt.Errorf("vev: invalid session target: %w", err) | 395 | return fmt.Errorf("vev: invalid session target: %w", err) | ||
| 395 | } | 396 | } | ||
| 396 | - | if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned); err != nil { | 397 | + | homePickerRoute := request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || request.Intent == protocol.IntentNew |
| 398 | + | if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, homePickerRoute); err != nil { | |||
| 397 | return fmt.Errorf("vev: invalid navigation route: %w", err) | 399 | return fmt.Errorf("vev: invalid navigation route: %w", err) | ||
| 398 | } | 400 | } | ||
| 399 | if request.RemoteTarget == nil { | 401 | if request.RemoteTarget == nil { | ||
| @@ -468,6 +470,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 468 | var routeNavigationSelection *routeNavigationSelection | 470 | var routeNavigationSelection *routeNavigationSelection | ||
| 469 | var routeNavigationFallback *attachRoute | 471 | var routeNavigationFallback *attachRoute | ||
| 470 | var routeNavigationAction *protocol.RouteNavigationAction | 472 | var routeNavigationAction *protocol.RouteNavigationAction | ||
| 473 | + | creationPending := false | |||
| 474 | + | var creationFallback *attachRoute | |||
| 475 | + | var creationRequestID uint64 | |||
| 471 | backoff := defaultReconnectBackoff.initial | 476 | backoff := defaultReconnectBackoff.initial | ||
| 472 | themeState := &terminalThemeState{} | 477 | themeState := &terminalThemeState{} | ||
| 473 | var rememberOnce sync.Once | 478 | var rememberOnce sync.Once | ||
| @@ -628,6 +633,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 628 | if err != nil { | 633 | if err != nil { | ||
| 629 | err = handshakeContextError(ctx, timedOut, err) | 634 | err = handshakeContextError(ctx, timedOut, err) | ||
| 630 | finishHandshake() | 635 | finishHandshake() | ||
| 636 | + | if creationPending && creationFallback != nil && ctx.Err() == nil { | |||
| 637 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(err)} | |||
| 638 | + | route := *creationFallback | |||
| 639 | + | creationFallback = nil | |||
| 640 | + | creationPending = false | |||
| 641 | + | creationRequestID = 0 | |||
| 642 | + | returnNavigationPending = true | |||
| 643 | + | restoreReturnRoute(route) | |||
| 644 | + | continue | |||
| 645 | + | } | |||
| 631 | if resumeToken == 0 || ctx.Err() != nil { | 646 | if resumeToken == 0 || ctx.Err() != nil { | ||
| 632 | if clearErr := reconnect.clear(); clearErr != nil { | 647 | if clearErr := reconnect.clear(); clearErr != nil { | ||
| 633 | return errors.Join(err, clearErr) | 648 | return errors.Join(err, clearErr) | ||
| @@ -655,7 +670,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 655 | ms.dialed = true | 670 | ms.dialed = true | ||
| 656 | if transport == nil { | 671 | if transport == nil { | ||
| 657 | finishHandshake() | 672 | finishHandshake() | ||
| 658 | - | return errors.New("vev: dialer returned nil transport") | 673 | + | err := errors.New("vev: dialer returned nil transport") |
| 674 | + | if creationPending && creationFallback != nil { | |||
| 675 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable} | |||
| 676 | + | route := *creationFallback | |||
| 677 | + | creationFallback = nil | |||
| 678 | + | creationPending = false | |||
| 679 | + | creationRequestID = 0 | |||
| 680 | + | returnNavigationPending = true | |||
| 681 | + | restoreReturnRoute(route) | |||
| 682 | + | continue | |||
| 683 | + | } | |||
| 684 | + | return err | |||
| 659 | } | 685 | } | ||
| 660 | stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport) | 686 | stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport) | ||
| 661 | connection, err := NewSessionConnection(transport, SessionTarget{ | 687 | connection, err := NewSessionConnection(transport, SessionTarget{ | ||
| @@ -666,6 +692,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 666 | stopHandshakeTransport() | 692 | stopHandshakeTransport() | ||
| 667 | finishHandshake() | 693 | finishHandshake() | ||
| 668 | _ = transport.Close() | 694 | _ = transport.Close() | ||
| 695 | + | if creationPending && creationFallback != nil { | |||
| 696 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable} | |||
| 697 | + | route := *creationFallback | |||
| 698 | + | creationFallback = nil | |||
| 699 | + | creationPending = false | |||
| 700 | + | creationRequestID = 0 | |||
| 701 | + | returnNavigationPending = true | |||
| 702 | + | restoreReturnRoute(route) | |||
| 703 | + | continue | |||
| 704 | + | } | |||
| 669 | return err | 705 | return err | ||
| 670 | } | 706 | } | ||
| 671 | 707 | ||||
| @@ -731,6 +767,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 731 | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | 767 | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | ||
| 732 | attemptRequest.RemoteTarget = nil | 768 | attemptRequest.RemoteTarget = nil | ||
| 733 | } | 769 | } | ||
| 770 | + | if result.err == nil && result.welcomed && creationPending { | |||
| 771 | + | creationPending = false | |||
| 772 | + | creationFallback = nil | |||
| 773 | + | creationRequestID = 0 | |||
| 774 | + | } | |||
| 734 | if result.err == nil && result.welcomed && routeNavigationPending { | 775 | if result.err == nil && result.welcomed && routeNavigationPending { | ||
| 735 | routeNavigationPending = false | 776 | routeNavigationPending = false | ||
| 736 | routeNavigationSelection = nil | 777 | routeNavigationSelection = nil | ||
| @@ -738,6 +779,40 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 738 | routeNavigationFallback = nil | 779 | routeNavigationFallback = nil | ||
| 739 | routeNavigationAction = nil | 780 | routeNavigationAction = nil | ||
| 740 | } | 781 | } | ||
| 782 | + | if result.routeCreateAction != nil { | |||
| 783 | + | action := *result.routeCreateAction | |||
| 784 | + | source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken} | |||
| 785 | + | if r.ledger == nil { | |||
| 786 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureUnavailable} | |||
| 787 | + | returnNavigationPending = true | |||
| 788 | + | restoreReturnRoute(source) | |||
| 789 | + | continue | |||
| 790 | + | } | |||
| 791 | + | selection, valid := r.ledger.creationSelection(action) | |||
| 792 | + | if !valid { | |||
| 793 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureStaleSelection} | |||
| 794 | + | returnNavigationPending = true | |||
| 795 | + | restoreReturnRoute(source) | |||
| 796 | + | continue | |||
| 797 | + | } | |||
| 798 | + | creationFallback = &attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken} | |||
| 799 | + | dialer = selection.selected.dialer | |||
| 800 | + | attemptRequest = cloneAttachRequest(selection.selected.request) | |||
| 801 | + | attemptRequest.Intent = protocol.IntentNew | |||
| 802 | + | attemptRequest.SessionName = action.SessionName | |||
| 803 | + | attemptRequest.ExactTarget = nil | |||
| 804 | + | attemptRequest.RemoteTarget = nil | |||
| 805 | + | attemptRequest.PreferredTabID = "" | |||
| 806 | + | attemptRequest.StartupOverlay = protocol.StartupOverlayNone | |||
| 807 | + | attemptRequest.NavigationCapabilities = 0 | |||
| 808 | + | attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |||
| 809 | + | resumeToken = 0 | |||
| 810 | + | creationPending = true | |||
| 811 | + | creationRequestID = action.RequestID | |||
| 812 | + | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |||
| 813 | + | backoff = defaultReconnectBackoff.initial | |||
| 814 | + | continue | |||
| 815 | + | } | |||
| 741 | if result.routeAction != nil { | 816 | if result.routeAction != nil { | ||
| 742 | if r.ledger == nil { | 817 | if r.ledger == nil { | ||
| 743 | return errors.New("vev: route ledger unavailable") | 818 | return errors.New("vev: route ledger unavailable") | ||
| @@ -846,13 +921,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 846 | } | 921 | } | ||
| 847 | nextDialer, nextRequest, handoffErr = r.attachHandoff(*target) | 922 | nextDialer, nextRequest, handoffErr = r.attachHandoff(*target) | ||
| 848 | if handoffErr != nil { | 923 | if handoffErr != nil { | ||
| 924 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |||
| 925 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)} | |||
| 926 | + | returnNavigationPending = true | |||
| 927 | + | restoreReturnRoute(source) | |||
| 928 | + | continue | |||
| 929 | + | } | |||
| 849 | return handoffErr | 930 | return handoffErr | ||
| 850 | } | 931 | } | ||
| 851 | } | 932 | } | ||
| 852 | if target.ExactTarget != nil { | 933 | if target.ExactTarget != nil { | ||
| 853 | nextRequest.ExactTarget = target.ExactTarget | 934 | nextRequest.ExactTarget = target.ExactTarget | ||
| 854 | } | 935 | } | ||
| 855 | - | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned) { | 936 | + | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || target.Intent == protocol.IntentNew) { |
| 856 | nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker | 937 | nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker | ||
| 857 | } | 938 | } | ||
| 858 | if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker { | 939 | if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker { | ||
| @@ -861,11 +942,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 861 | } | 942 | } | ||
| 862 | nextRequest = cloneAttachRequest(nextRequest) | 943 | nextRequest = cloneAttachRequest(nextRequest) | ||
| 863 | if err := validateAttachRequest(nextRequest); err != nil { | 944 | if err := validateAttachRequest(nextRequest); err != nil { | ||
| 945 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |||
| 946 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable} | |||
| 947 | + | returnNavigationPending = true | |||
| 948 | + | restoreReturnRoute(source) | |||
| 949 | + | continue | |||
| 950 | + | } | |||
| 864 | return fmt.Errorf("vev: invalid route handoff request: %w", err) | 951 | return fmt.Errorf("vev: invalid route handoff request: %w", err) | ||
| 865 | } | 952 | } | ||
| 866 | if nextDialer == nil { | 953 | if nextDialer == nil { | ||
| 954 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |||
| 955 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable} | |||
| 956 | + | returnNavigationPending = true | |||
| 957 | + | restoreReturnRoute(source) | |||
| 958 | + | continue | |||
| 959 | + | } | |||
| 867 | return errors.New("vev: route handoff returned nil dialer") | 960 | return errors.New("vev: route handoff returned nil dialer") | ||
| 868 | } | 961 | } | ||
| 962 | + | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |||
| 963 | + | fallback := source | |||
| 964 | + | creationFallback = &fallback | |||
| 965 | + | creationPending = true | |||
| 966 | + | creationRequestID = target.RequestID | |||
| 967 | + | } | |||
| 869 | dialer = nextDialer | 968 | dialer = nextDialer | ||
| 870 | attemptRequest = nextRequest | 969 | attemptRequest = nextRequest | ||
| 871 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | 970 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | ||
| @@ -894,6 +993,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 894 | restoreReturnRoute(route) | 993 | restoreReturnRoute(route) | ||
| 895 | continue | 994 | continue | ||
| 896 | } | 995 | } | ||
| 996 | + | if creationPending && creationFallback != nil { | |||
| 997 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(result.err)} | |||
| 998 | + | route := *creationFallback | |||
| 999 | + | creationFallback = nil | |||
| 1000 | + | creationPending = false | |||
| 1001 | + | creationRequestID = 0 | |||
| 1002 | + | returnNavigationPending = true | |||
| 1003 | + | restoreReturnRoute(route) | |||
| 1004 | + | continue | |||
| 1005 | + | } | |||
| 897 | if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) { | 1006 | if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) { | ||
| 898 | attemptRequest.Intent = protocol.IntentAttach | 1007 | attemptRequest.Intent = protocol.IntentAttach | ||
| 899 | resumeToken = 0 | 1008 | resumeToken = 0 | ||
| @@ -1121,6 +1230,7 @@ type attachResult struct { | |||||
| 1121 | handoff *attachHandoff | 1230 | handoff *attachHandoff | ||
| 1122 | action protocol.NavigationAction | 1231 | action protocol.NavigationAction | ||
| 1123 | routeAction *protocol.RouteNavigationAction | 1232 | routeAction *protocol.RouteNavigationAction | ||
| 1233 | + | routeCreateAction *protocol.RouteCreateSessionAction | |||
| 1124 | err error | 1234 | err error | ||
| 1125 | } | 1235 | } | ||
| 1126 | 1236 | ||||
| @@ -1325,6 +1435,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1325 | if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target { | 1435 | if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target { | ||
| 1326 | return welcomedResult(errRouteTargetChanged) | 1436 | return welcomedResult(errRouteTargetChanged) | ||
| 1327 | } | 1437 | } | ||
| 1438 | + | if request.Intent == protocol.IntentNew && request.SessionName != committedIdentity.Target.SessionName { | |||
| 1439 | + | return welcomedResult(errRouteTargetChanged) | |||
| 1440 | + | } | |||
| 1328 | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) { | 1441 | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) { | ||
| 1329 | return welcomedResult(errRouteTargetChanged) | 1442 | return welcomedResult(errRouteTargetChanged) | ||
| 1330 | } | 1443 | } | ||
| @@ -1352,6 +1465,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1352 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | 1465 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | ||
| 1353 | } | 1466 | } | ||
| 1354 | } | 1467 | } | ||
| 1468 | + | if a.runner.creationFailure != nil { | |||
| 1469 | + | if err := sendHandshake(func() error { | |||
| 1470 | + | return transport.SendClient(*a.runner.creationFailure) | |||
| 1471 | + | }); err != nil { | |||
| 1472 | + | return welcomedResult(fmt.Errorf("vev: publishing session creation failure: %w", err)) | |||
| 1473 | + | } | |||
| 1474 | + | a.runner.creationFailure = nil | |||
| 1475 | + | } | |||
| 1355 | if a.runner.routeFailure != nil { | 1476 | if a.runner.routeFailure != nil { | ||
| 1356 | if err := sendHandshake(func() error { | 1477 | if err := sendHandshake(func() error { | ||
| 1357 | return transport.SendClient(*a.runner.routeFailure) | 1478 | return transport.SendClient(*a.runner.routeFailure) | ||
| @@ -1777,6 +1898,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1777 | return context.Canceled | 1898 | return context.Canceled | ||
| 1778 | } | 1899 | } | ||
| 1779 | } | 1900 | } | ||
| 1901 | + | sendCreationFailure := func(requestID uint64, code protocol.RouteFailureCode) error { | |||
| 1902 | + | failure := protocol.SessionCreationFailure{RequestID: requestID, Code: code} | |||
| 1903 | + | if failure.Validate() != nil { | |||
| 1904 | + | return protocol.ErrInvalidRouteWire | |||
| 1905 | + | } | |||
| 1906 | + | select { | |||
| 1907 | + | case sendCh <- failure: | |||
| 1908 | + | return nil | |||
| 1909 | + | case <-loopCtx.Done(): | |||
| 1910 | + | return context.Canceled | |||
| 1911 | + | } | |||
| 1912 | + | } | |||
| 1780 | dismissReconnect := func() error { | 1913 | dismissReconnect := func() error { | ||
| 1781 | if !reconnect.showing { | 1914 | if !reconnect.showing { | ||
| 1782 | return nil | 1915 | return nil | ||
| @@ -2038,6 +2171,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 2038 | navigation := welcomedResult(nil) | 2171 | navigation := welcomedResult(nil) | ||
| 2039 | navigation.action = directive.Action | 2172 | navigation.action = directive.Action | ||
| 2040 | return navigation | 2173 | return navigation | ||
| 2174 | + | case protocol.RouteCreateSessionAction: | |||
| 2175 | + | action := message | |||
| 2176 | + | if action.Validate() != nil { | |||
| 2177 | + | continue | |||
| 2178 | + | } | |||
| 2179 | + | if a.runner.ledger == nil { | |||
| 2180 | + | if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureUnavailable); ferr != nil { | |||
| 2181 | + | return welcomedResult(ferr) | |||
| 2182 | + | } | |||
| 2183 | + | continue | |||
| 2184 | + | } | |||
| 2185 | + | if _, valid := a.runner.ledger.creationSelection(action); !valid { | |||
| 2186 | + | if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureStaleSelection); ferr != nil { | |||
| 2187 | + | return welcomedResult(ferr) | |||
| 2188 | + | } | |||
| 2189 | + | continue | |||
| 2190 | + | } | |||
| 2191 | + | result := welcomedResult(nil) | |||
| 2192 | + | result.routeCreateAction = &action | |||
| 2193 | + | return result | |||
| 2041 | case protocol.RouteNavigationAction: | 2194 | case protocol.RouteNavigationAction: | ||
| 2042 | action := message | 2195 | action := message | ||
| 2043 | if a.runner.ledger == nil { | 2196 | if a.runner.ledger == nil { | ||
M internal/usecase/client/client_test.go +63 -0
| @@ -1158,6 +1158,69 @@ func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) { | |||
| 1158 | 1158 | require.Positive(t, sourceRemote.closed.Load()) | |
| 1159 | 1159 | } | |
| 1160 | 1160 | ||
| 1161 | + | func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) { | |
| 1162 | + | tests := []struct { | |
| 1163 | + | name string | |
| 1164 | + | handoffErr error | |
| 1165 | + | dialErr error | |
| 1166 | + | wantTargetCalls int32 | |
| 1167 | + | }{ | |
| 1168 | + | {name: "handoff resolution", handoffErr: errors.New("handoff failed")}, | |
| 1169 | + | {name: "target dial", dialErr: errors.New("dial failed"), wantTargetCalls: 1}, | |
| 1170 | + | } | |
| 1171 | + | for _, tt := range tests { | |
| 1172 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1173 | + | term := newRunTerminal() | |
| 1174 | + | defer term.in.unblock() | |
| 1175 | + | ||
| 1176 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 1177 | + | target := protocol.AttachTarget{ | |
| 1178 | + | RequestID: 7, Endpoint: "host-a", Session: "example", Intent: protocol.IntentNew, | |
| 1179 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 1180 | + | } | |
| 1181 | + | initial := &recordingTransport{recvs: []recvItem{ | |
| 1182 | + | {f: hybridWelcomeFrame("work", lifecycle)}, | |
| 1183 | + | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(target))}, | |
| 1184 | + | }} | |
| 1185 | + | restored := &recordingTransport{recvs: []recvItem{ | |
| 1186 | + | {f: hybridWelcomeFrame("work", lifecycle)}, | |
| 1187 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1188 | + | }} | |
| 1189 | + | sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}} | |
| 1190 | + | targetDialer := &sequenceDialer{errs: []error{tt.dialErr}} | |
| 1191 | + | deps := testDependencies(sourceDialer, term, realClock{}, nil, nil) | |
| 1192 | + | deps.AttachHandoff = func(got protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 1193 | + | require.Equal(t, target, got) | |
| 1194 | + | if tt.handoffErr != nil { | |
| 1195 | + | return nil, client.AttachRequest{}, tt.handoffErr | |
| 1196 | + | } | |
| 1197 | + | return targetDialer, client.AttachRequest{ | |
| 1198 | + | Intent: protocol.IntentNew, SessionName: "example", Remote: true, | |
| 1199 | + | Origin: protocol.RouteOriginDiscovery, OriginKey: "host-a", HostLabel: "host-a", | |
| 1200 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 1201 | + | }, nil | |
| 1202 | + | } | |
| 1203 | + | ||
| 1204 | + | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1205 | + | Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1206 | + | })) | |
| 1207 | + | require.Equal(t, int32(2), sourceDialer.calls.Load()) | |
| 1208 | + | require.Equal(t, tt.wantTargetCalls, targetDialer.calls.Load()) | |
| 1209 | + | var failure protocol.SessionCreationFailure | |
| 1210 | + | for _, frame := range restored.Sends() { | |
| 1211 | + | if frame.Type != wire.MsgSessionCreationFailure { | |
| 1212 | + | continue | |
| 1213 | + | } | |
| 1214 | + | var err error | |
| 1215 | + | failure, err = wire.UnmarshalSessionCreationFailure(frame.Payload) | |
| 1216 | + | require.NoError(t, err) | |
| 1217 | + | } | |
| 1218 | + | require.Equal(t, uint64(7), failure.RequestID) | |
| 1219 | + | require.Equal(t, protocol.RouteFailureUnavailable, failure.Code) | |
| 1220 | + | }) | |
| 1221 | + | } | |
| 1222 | + | } | |
| 1223 | + | ||
| 1161 | 1224 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | |
| 1162 | 1225 | term := newRunTerminal() | |
| 1163 | 1226 | defer term.in.unblock() | |
| @@ -1158,6 +1158,69 @@ func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) { | |||||
| 1158 | require.Positive(t, sourceRemote.closed.Load()) | 1158 | require.Positive(t, sourceRemote.closed.Load()) | ||
| 1159 | } | 1159 | } | ||
| 1160 | 1160 | ||||
| 1161 | + | func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) { | |||
| 1162 | + | tests := []struct { | |||
| 1163 | + | name string | |||
| 1164 | + | handoffErr error | |||
| 1165 | + | dialErr error | |||
| 1166 | + | wantTargetCalls int32 | |||
| 1167 | + | }{ | |||
| 1168 | + | {name: "handoff resolution", handoffErr: errors.New("handoff failed")}, | |||
| 1169 | + | {name: "target dial", dialErr: errors.New("dial failed"), wantTargetCalls: 1}, | |||
| 1170 | + | } | |||
| 1171 | + | for _, tt := range tests { | |||
| 1172 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1173 | + | term := newRunTerminal() | |||
| 1174 | + | defer term.in.unblock() | |||
| 1175 | + | ||||
| 1176 | + | lifecycle := domain.SessionLifecycleID{1} | |||
| 1177 | + | target := protocol.AttachTarget{ | |||
| 1178 | + | RequestID: 7, Endpoint: "host-a", Session: "example", Intent: protocol.IntentNew, | |||
| 1179 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 1180 | + | } | |||
| 1181 | + | initial := &recordingTransport{recvs: []recvItem{ | |||
| 1182 | + | {f: hybridWelcomeFrame("work", lifecycle)}, | |||
| 1183 | + | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(target))}, | |||
| 1184 | + | }} | |||
| 1185 | + | restored := &recordingTransport{recvs: []recvItem{ | |||
| 1186 | + | {f: hybridWelcomeFrame("work", lifecycle)}, | |||
| 1187 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |||
| 1188 | + | }} | |||
| 1189 | + | sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}} | |||
| 1190 | + | targetDialer := &sequenceDialer{errs: []error{tt.dialErr}} | |||
| 1191 | + | deps := testDependencies(sourceDialer, term, realClock{}, nil, nil) | |||
| 1192 | + | deps.AttachHandoff = func(got protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |||
| 1193 | + | require.Equal(t, target, got) | |||
| 1194 | + | if tt.handoffErr != nil { | |||
| 1195 | + | return nil, client.AttachRequest{}, tt.handoffErr | |||
| 1196 | + | } | |||
| 1197 | + | return targetDialer, client.AttachRequest{ | |||
| 1198 | + | Intent: protocol.IntentNew, SessionName: "example", Remote: true, | |||
| 1199 | + | Origin: protocol.RouteOriginDiscovery, OriginKey: "host-a", HostLabel: "host-a", | |||
| 1200 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 1201 | + | }, nil | |||
| 1202 | + | } | |||
| 1203 | + | ||||
| 1204 | + | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |||
| 1205 | + | Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |||
| 1206 | + | })) | |||
| 1207 | + | require.Equal(t, int32(2), sourceDialer.calls.Load()) | |||
| 1208 | + | require.Equal(t, tt.wantTargetCalls, targetDialer.calls.Load()) | |||
| 1209 | + | var failure protocol.SessionCreationFailure | |||
| 1210 | + | for _, frame := range restored.Sends() { | |||
| 1211 | + | if frame.Type != wire.MsgSessionCreationFailure { | |||
| 1212 | + | continue | |||
| 1213 | + | } | |||
| 1214 | + | var err error | |||
| 1215 | + | failure, err = wire.UnmarshalSessionCreationFailure(frame.Payload) | |||
| 1216 | + | require.NoError(t, err) | |||
| 1217 | + | } | |||
| 1218 | + | require.Equal(t, uint64(7), failure.RequestID) | |||
| 1219 | + | require.Equal(t, protocol.RouteFailureUnavailable, failure.Code) | |||
| 1220 | + | }) | |||
| 1221 | + | } | |||
| 1222 | + | } | |||
| 1223 | + | ||||
| 1161 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | 1224 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | ||
| 1162 | term := newRunTerminal() | 1225 | term := newRunTerminal() | ||
| 1163 | defer term.in.unblock() | 1226 | defer term.in.unblock() | ||
M internal/usecase/client/navigation_test.go +1 -0
| @@ -39,6 +39,7 @@ func TestValidateAttachRequestNavigationTable(t *testing.T) { | |||
| 39 | 39 | {name: "ordinary route", request: AttachRequest{}, valid: true}, | |
| 40 | 40 | {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | |
| 41 | 41 | {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | |
| 42 | + | {name: "home picker on client-owned remote new route", request: AttachRequest{Intent: protocol.IntentNew, SessionName: "example", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | |
| 42 | 43 | {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true}, | |
| 43 | 44 | {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | |
| 44 | 45 | {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false}, | |
| @@ -39,6 +39,7 @@ func TestValidateAttachRequestNavigationTable(t *testing.T) { | |||||
| 39 | {name: "ordinary route", request: AttachRequest{}, valid: true}, | 39 | {name: "ordinary route", request: AttachRequest{}, valid: true}, | ||
| 40 | {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | 40 | {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | ||
| 41 | {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | 41 | {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | ||
| 42 | + | {name: "home picker on client-owned remote new route", request: AttachRequest{Intent: protocol.IntentNew, SessionName: "example", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | |||
| 42 | {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true}, | 43 | {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true}, | ||
| 43 | {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | 44 | {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | ||
| 44 | {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false}, | 45 | {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false}, | ||
M internal/usecase/client/routes.go +15 -0
| @@ -679,6 +679,21 @@ func (l *routeLedger) navigationSelection(action protocol.RouteNavigationAction) | |||
| 679 | 679 | return selection, true | |
| 680 | 680 | } | |
| 681 | 681 | ||
| 682 | + | func (l *routeLedger) creationSelection(action protocol.RouteCreateSessionAction) (routeNavigationSelection, bool) { | |
| 683 | + | if action.Validate() != nil { | |
| 684 | + | return routeNavigationSelection{}, false | |
| 685 | + | } | |
| 686 | + | selection, ok := l.navigationSelection(protocol.RouteNavigationAction{ | |
| 687 | + | SnapshotGeneration: action.SnapshotGeneration, | |
| 688 | + | Key: action.Key, | |
| 689 | + | Generation: action.Generation, | |
| 690 | + | }) | |
| 691 | + | if !ok || selection.noOp || selection.selected.presentation.kind != protocol.RouteKindLocal { | |
| 692 | + | return routeNavigationSelection{}, false | |
| 693 | + | } | |
| 694 | + | return selection, true | |
| 695 | + | } | |
| 696 | + | ||
| 682 | 697 | // navigationRecord validates that an action belongs to the latest complete | |
| 683 | 698 | // snapshot. The active route is metadata-only and selecting it is a no-op. | |
| 684 | 699 | func (l *routeLedger) navigationRecord(action protocol.RouteNavigationAction) (routeRecord, bool, bool) { | |
| @@ -679,6 +679,21 @@ func (l *routeLedger) navigationSelection(action protocol.RouteNavigationAction) | |||||
| 679 | return selection, true | 679 | return selection, true | ||
| 680 | } | 680 | } | ||
| 681 | 681 | ||||
| 682 | + | func (l *routeLedger) creationSelection(action protocol.RouteCreateSessionAction) (routeNavigationSelection, bool) { | |||
| 683 | + | if action.Validate() != nil { | |||
| 684 | + | return routeNavigationSelection{}, false | |||
| 685 | + | } | |||
| 686 | + | selection, ok := l.navigationSelection(protocol.RouteNavigationAction{ | |||
| 687 | + | SnapshotGeneration: action.SnapshotGeneration, | |||
| 688 | + | Key: action.Key, | |||
| 689 | + | Generation: action.Generation, | |||
| 690 | + | }) | |||
| 691 | + | if !ok || selection.noOp || selection.selected.presentation.kind != protocol.RouteKindLocal { | |||
| 692 | + | return routeNavigationSelection{}, false | |||
| 693 | + | } | |||
| 694 | + | return selection, true | |||
| 695 | + | } | |||
| 696 | + | ||||
| 682 | // navigationRecord validates that an action belongs to the latest complete | 697 | // navigationRecord validates that an action belongs to the latest complete | ||
| 683 | // snapshot. The active route is metadata-only and selecting it is a no-op. | 698 | // snapshot. The active route is metadata-only and selecting it is a no-op. | ||
| 684 | func (l *routeLedger) navigationRecord(action protocol.RouteNavigationAction) (routeRecord, bool, bool) { | 699 | func (l *routeLedger) navigationRecord(action protocol.RouteNavigationAction) (routeRecord, bool, bool) { | ||
M internal/usecase/client/routes_test.go +35 -0
| @@ -331,6 +331,41 @@ func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) { | |||
| 331 | 331 | require.Equal(t, 1, homeCount) | |
| 332 | 332 | } | |
| 333 | 333 | ||
| 334 | + | func TestRouteLedgerCreationSelectionRequiresExactLocalRoute(t *testing.T) { | |
| 335 | + | ledger := newRouteLedger() | |
| 336 | + | local, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | |
| 337 | + | require.NoError(t, err) | |
| 338 | + | remoteCandidate := routeTestCandidate(2, protocol.RouteOriginRemote) | |
| 339 | + | remoteCandidate.presentation.kind = protocol.RouteKindRemote | |
| 340 | + | remote, err := ledger.commit(remoteCandidate) | |
| 341 | + | require.NoError(t, err) | |
| 342 | + | snapshotGeneration := ledger.snapshot().Generation | |
| 343 | + | ||
| 344 | + | tests := []struct { | |
| 345 | + | name string | |
| 346 | + | selected routeIdentity | |
| 347 | + | snapshotGeneration uint64 | |
| 348 | + | want bool | |
| 349 | + | }{ | |
| 350 | + | {name: "exact local route", selected: local, snapshotGeneration: snapshotGeneration, want: true}, | |
| 351 | + | {name: "stale snapshot", selected: local, snapshotGeneration: snapshotGeneration - 1}, | |
| 352 | + | {name: "remote route", selected: remote, snapshotGeneration: snapshotGeneration}, | |
| 353 | + | } | |
| 354 | + | for _, tt := range tests { | |
| 355 | + | t.Run(tt.name, func(t *testing.T) { | |
| 356 | + | action := protocol.RouteCreateSessionAction{ | |
| 357 | + | RequestID: 1, SnapshotGeneration: tt.snapshotGeneration, | |
| 358 | + | Key: uint64(tt.selected.key), Generation: uint64(tt.selected.generation), SessionName: "example", | |
| 359 | + | } | |
| 360 | + | selection, ok := ledger.creationSelection(action) | |
| 361 | + | require.Equal(t, tt.want, ok) | |
| 362 | + | if ok { | |
| 363 | + | require.Equal(t, protocol.RouteKindLocal, selection.selected.presentation.kind) | |
| 364 | + | } | |
| 365 | + | }) | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 334 | 369 | func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) { | |
| 335 | 370 | ledger := newRouteLedger() | |
| 336 | 371 | selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | |
| @@ -331,6 +331,41 @@ func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) { | |||||
| 331 | require.Equal(t, 1, homeCount) | 331 | require.Equal(t, 1, homeCount) | ||
| 332 | } | 332 | } | ||
| 333 | 333 | ||||
| 334 | + | func TestRouteLedgerCreationSelectionRequiresExactLocalRoute(t *testing.T) { | |||
| 335 | + | ledger := newRouteLedger() | |||
| 336 | + | local, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | |||
| 337 | + | require.NoError(t, err) | |||
| 338 | + | remoteCandidate := routeTestCandidate(2, protocol.RouteOriginRemote) | |||
| 339 | + | remoteCandidate.presentation.kind = protocol.RouteKindRemote | |||
| 340 | + | remote, err := ledger.commit(remoteCandidate) | |||
| 341 | + | require.NoError(t, err) | |||
| 342 | + | snapshotGeneration := ledger.snapshot().Generation | |||
| 343 | + | ||||
| 344 | + | tests := []struct { | |||
| 345 | + | name string | |||
| 346 | + | selected routeIdentity | |||
| 347 | + | snapshotGeneration uint64 | |||
| 348 | + | want bool | |||
| 349 | + | }{ | |||
| 350 | + | {name: "exact local route", selected: local, snapshotGeneration: snapshotGeneration, want: true}, | |||
| 351 | + | {name: "stale snapshot", selected: local, snapshotGeneration: snapshotGeneration - 1}, | |||
| 352 | + | {name: "remote route", selected: remote, snapshotGeneration: snapshotGeneration}, | |||
| 353 | + | } | |||
| 354 | + | for _, tt := range tests { | |||
| 355 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 356 | + | action := protocol.RouteCreateSessionAction{ | |||
| 357 | + | RequestID: 1, SnapshotGeneration: tt.snapshotGeneration, | |||
| 358 | + | Key: uint64(tt.selected.key), Generation: uint64(tt.selected.generation), SessionName: "example", | |||
| 359 | + | } | |||
| 360 | + | selection, ok := ledger.creationSelection(action) | |||
| 361 | + | require.Equal(t, tt.want, ok) | |||
| 362 | + | if ok { | |||
| 363 | + | require.Equal(t, protocol.RouteKindLocal, selection.selected.presentation.kind) | |||
| 364 | + | } | |||
| 365 | + | }) | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | ||||
| 334 | func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) { | 369 | func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) { | ||
| 335 | ledger := newRouteLedger() | 370 | ledger := newRouteLedger() | ||
| 336 | selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | 371 | selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | ||
M internal/usecase/client/typed_connection_external_test.go +5 -0
| @@ -137,6 +137,9 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) { | |||
| 137 | 137 | case protocol.RouteNavigationFailure: | |
| 138 | 138 | p, e := wire.MarshalRouteNavigationFailure(x) | |
| 139 | 139 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e | |
| 140 | + | case protocol.SessionCreationFailure: | |
| 141 | + | p, e := wire.MarshalSessionCreationFailure(x) | |
| 142 | + | return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: p}, e | |
| 140 | 143 | default: | |
| 141 | 144 | return wire.Frame{}, errors.New("test client: unsupported message") | |
| 142 | 145 | } | |
| @@ -161,6 +164,8 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) { | |||
| 161 | 164 | return wire.UnmarshalParkedRouteResponse(f.Payload) | |
| 162 | 165 | case wire.MsgNavigateRecentRoute: | |
| 163 | 166 | return wire.UnmarshalRouteNavigationAction(f.Payload) | |
| 167 | + | case wire.MsgRouteCreateSession: | |
| 168 | + | return wire.UnmarshalRouteCreateSessionAction(f.Payload) | |
| 164 | 169 | case wire.MsgCommittedRouteIdentity: | |
| 165 | 170 | return wire.UnmarshalCommittedRouteIdentity(f.Payload) | |
| 166 | 171 | case wire.MsgSamePeerSwitchFailure: | |
| @@ -137,6 +137,9 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) { | |||||
| 137 | case protocol.RouteNavigationFailure: | 137 | case protocol.RouteNavigationFailure: | ||
| 138 | p, e := wire.MarshalRouteNavigationFailure(x) | 138 | p, e := wire.MarshalRouteNavigationFailure(x) | ||
| 139 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e | 139 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e | ||
| 140 | + | case protocol.SessionCreationFailure: | |||
| 141 | + | p, e := wire.MarshalSessionCreationFailure(x) | |||
| 142 | + | return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: p}, e | |||
| 140 | default: | 143 | default: | ||
| 141 | return wire.Frame{}, errors.New("test client: unsupported message") | 144 | return wire.Frame{}, errors.New("test client: unsupported message") | ||
| 142 | } | 145 | } | ||
| @@ -161,6 +164,8 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) { | |||||
| 161 | return wire.UnmarshalParkedRouteResponse(f.Payload) | 164 | return wire.UnmarshalParkedRouteResponse(f.Payload) | ||
| 162 | case wire.MsgNavigateRecentRoute: | 165 | case wire.MsgNavigateRecentRoute: | ||
| 163 | return wire.UnmarshalRouteNavigationAction(f.Payload) | 166 | return wire.UnmarshalRouteNavigationAction(f.Payload) | ||
| 167 | + | case wire.MsgRouteCreateSession: | |||
| 168 | + | return wire.UnmarshalRouteCreateSessionAction(f.Payload) | |||
| 164 | case wire.MsgCommittedRouteIdentity: | 169 | case wire.MsgCommittedRouteIdentity: | ||
| 165 | return wire.UnmarshalCommittedRouteIdentity(f.Payload) | 170 | return wire.UnmarshalCommittedRouteIdentity(f.Payload) | ||
| 166 | case wire.MsgSamePeerSwitchFailure: | 171 | case wire.MsgSamePeerSwitchFailure: | ||
M internal/usecase/daemon/client_frame_routing.go +13 -0
| @@ -140,6 +140,19 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||
| 140 | 140 | case protocol.RouteAttentionSubscription: | |
| 141 | 141 | effect.ac.setRouteAttentionSubscription(message) | |
| 142 | 142 | d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention") | |
| 143 | + | case protocol.SessionCreationFailure: | |
| 144 | + | notice := "couldn't create session" | |
| 145 | + | switch message.Code { | |
| 146 | + | case protocol.RouteFailureStaleSelection, protocol.RouteFailureNoSuchRoute: | |
| 147 | + | notice = "that destination is no longer available" | |
| 148 | + | case protocol.RouteFailureTargetChanged: | |
| 149 | + | notice = "the destination returned an unexpected session" | |
| 150 | + | case protocol.RouteFailureOriginUnavailable: | |
| 151 | + | notice = "couldn't restore the original session after creation failed" | |
| 152 | + | case protocol.RouteFailureUnavailable: | |
| 153 | + | notice = "the selected destination is unavailable" | |
| 154 | + | } | |
| 155 | + | d.notify(effect.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, notice, nil) | |
| 143 | 156 | case protocol.RouteNavigationFailure: | |
| 144 | 157 | notice := "route navigation failed" | |
| 145 | 158 | switch message.Code { | |
| @@ -140,6 +140,19 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||||
| 140 | case protocol.RouteAttentionSubscription: | 140 | case protocol.RouteAttentionSubscription: | ||
| 141 | effect.ac.setRouteAttentionSubscription(message) | 141 | effect.ac.setRouteAttentionSubscription(message) | ||
| 142 | d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention") | 142 | d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention") | ||
| 143 | + | case protocol.SessionCreationFailure: | |||
| 144 | + | notice := "couldn't create session" | |||
| 145 | + | switch message.Code { | |||
| 146 | + | case protocol.RouteFailureStaleSelection, protocol.RouteFailureNoSuchRoute: | |||
| 147 | + | notice = "that destination is no longer available" | |||
| 148 | + | case protocol.RouteFailureTargetChanged: | |||
| 149 | + | notice = "the destination returned an unexpected session" | |||
| 150 | + | case protocol.RouteFailureOriginUnavailable: | |||
| 151 | + | notice = "couldn't restore the original session after creation failed" | |||
| 152 | + | case protocol.RouteFailureUnavailable: | |||
| 153 | + | notice = "the selected destination is unavailable" | |||
| 154 | + | } | |||
| 155 | + | d.notify(effect.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, notice, nil) | |||
| 143 | case protocol.RouteNavigationFailure: | 156 | case protocol.RouteNavigationFailure: | ||
| 144 | notice := "route navigation failed" | 157 | notice := "route navigation failed" | ||
| 145 | switch message.Code { | 158 | switch message.Code { | ||
M internal/usecase/daemon/daemon.go +1 -0
| @@ -84,6 +84,7 @@ type Daemon struct { | |||
| 84 | 84 | // reusing a lifecycle identity. | |
| 85 | 85 | lastAllocatedCreatedAt int64 | |
| 86 | 86 | mruSeq atomic.Uint64 | |
| 87 | + | creationRequestSeq atomic.Uint64 | |
| 87 | 88 | // pickerSort is the picker ordering mode for this daemon's lifetime | |
| 88 | 89 | // (pickerSortMode); not persisted across restarts. | |
| 89 | 90 | pickerSort atomic.Uint32 | |
| @@ -84,6 +84,7 @@ type Daemon struct { | |||||
| 84 | // reusing a lifecycle identity. | 84 | // reusing a lifecycle identity. | ||
| 85 | lastAllocatedCreatedAt int64 | 85 | lastAllocatedCreatedAt int64 | ||
| 86 | mruSeq atomic.Uint64 | 86 | mruSeq atomic.Uint64 | ||
| 87 | + | creationRequestSeq atomic.Uint64 | |||
| 87 | // pickerSort is the picker ordering mode for this daemon's lifetime | 88 | // pickerSort is the picker ordering mode for this daemon's lifetime | ||
| 88 | // (pickerSortMode); not persisted across restarts. | 89 | // (pickerSortMode); not persisted across restarts. | ||
| 89 | pickerSort atomic.Uint32 | 90 | pickerSort atomic.Uint32 | ||
M internal/usecase/daemon/palette.go +202 -4
| @@ -22,7 +22,10 @@ const ( | |||
| 22 | 22 | paletteRailWidth = 64 | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | - | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} | |
| 25 | + | var ( | |
| 26 | + | paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} | |
| 27 | + | errCreateDestinationUnavailable = errors.New("that destination is no longer available") | |
| 28 | + | ) | |
| 26 | 29 | ||
| 27 | 30 | func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | |
| 28 | 31 | modal := paletteModal | |
| @@ -97,6 +100,56 @@ func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle | |||
| 97 | 100 | return paletteRemoteDisplayOrigin(active.HostLabel) | |
| 98 | 101 | } | |
| 99 | 102 | ||
| 103 | + | func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) { | |
| 104 | + | if ref.IsZero() { | |
| 105 | + | return protocol.RecentRouteEntry{}, false | |
| 106 | + | } | |
| 107 | + | if snapshot.Active == ref { | |
| 108 | + | entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation} | |
| 109 | + | if entryRef == ref { | |
| 110 | + | return snapshot.ActiveEntry, true | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | for _, entry := range snapshot.Entries { | |
| 114 | + | if entry.Key == ref.Key && entry.Generation == ref.Generation { | |
| 115 | + | return entry, true | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | return protocol.RecentRouteEntry{}, false | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, remoteCatalog []remoteCatalogPresentationEntry, hostRanks map[string]int) []palette.Result { | |
| 122 | + | results := make([]palette.Result, 0, len(remoteCatalog)+2) | |
| 123 | + | servingOrigin := "" | |
| 124 | + | if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { | |
| 125 | + | results = append(results, palette.NewCreateSessionDestination( | |
| 126 | + | palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, | |
| 127 | + | )) | |
| 128 | + | } | |
| 129 | + | if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote { | |
| 130 | + | servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel) | |
| 131 | + | results = append(results, palette.NewCreateSessionDestination( | |
| 132 | + | palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0, | |
| 133 | + | )) | |
| 134 | + | } | |
| 135 | + | for _, host := range remoteCatalog { | |
| 136 | + | if host.status != remoteHostFresh || host.entry.Host == "" { | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | if _, configured := hostRanks[host.entry.Host]; !configured { | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host) | |
| 143 | + | if servingOrigin != "" && displayOrigin == servingOrigin { | |
| 144 | + | continue | |
| 145 | + | } | |
| 146 | + | results = append(results, palette.NewCreateSessionDestination( | |
| 147 | + | palette.CreateSessionOnRemoteHost, displayOrigin, host.entry.Host, protocol.RouteRef{}, 0, | |
| 148 | + | )) | |
| 149 | + | } | |
| 150 | + | return results | |
| 151 | + | } | |
| 152 | + | ||
| 100 | 153 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | |
| 101 | 154 | if entry.Kind == protocol.RouteKindLocal { | |
| 102 | 155 | return true | |
| @@ -135,16 +188,19 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 135 | 188 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | |
| 136 | 189 | ||
| 137 | 190 | remoteCatalog := d.remoteCatalogSnapshot() | |
| 138 | - | sortRemoteCatalog(remoteCatalog, d.remoteHostRanks()) | |
| 191 | + | hostRanks := d.remoteHostRanks() | |
| 192 | + | sortRemoteCatalog(remoteCatalog, hostRanks) | |
| 139 | 193 | remoteSessionCount := 0 | |
| 140 | 194 | for _, host := range remoteCatalog { | |
| 141 | 195 | remoteSessionCount += len(host.entry.Sessions) | |
| 142 | 196 | } | |
| 143 | 197 | ||
| 144 | - | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | |
| 198 | + | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks) | |
| 199 | + | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | |
| 145 | 200 | for _, cmd := range commands { | |
| 146 | 201 | results = append(results, palette.NewCommandResult(cmd)) | |
| 147 | 202 | } | |
| 203 | + | results = append(results, destinations...) | |
| 148 | 204 | active := make([]palette.Result, 0, len(sessions)) | |
| 149 | 205 | for _, candidate := range sessions { | |
| 150 | 206 | snap := candidate.snapshotView(viewOptions{}) | |
| @@ -345,11 +401,13 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 345 | 401 | var hasRemoteTarget bool | |
| 346 | 402 | var routeTarget protocol.RouteNavigationAction | |
| 347 | 403 | var hasRouteTarget bool | |
| 404 | + | var createDestination palette.Result | |
| 405 | + | var hasCreateDestination bool | |
| 348 | 406 | var args []string | |
| 349 | 407 | var routeSnapshot protocol.RecentRouteSnapshot | |
| 350 | 408 | var generation uint64 | |
| 351 | 409 | var rawQuery string | |
| 352 | - | changed, cancel, execute := false, false, false | |
| 410 | + | changed, cancel, execute, chooseDestination := false, false, false, false | |
| 353 | 411 | var closedDiscovery remoteDiscoveryInstance | |
| 354 | 412 | var effect *attachmentEffect | |
| 355 | 413 | if len(effects) != 0 { | |
| @@ -404,6 +462,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 404 | 462 | selected, ok := ac.overlays.palette.Selected() | |
| 405 | 463 | if !ok { | |
| 406 | 464 | changed = true | |
| 465 | + | chooseDestination = true | |
| 407 | 466 | return | |
| 408 | 467 | } | |
| 409 | 468 | rawQuery = ac.overlays.palette.Query() | |
| @@ -435,6 +494,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 435 | 494 | } else if action, ok := selected.RouteNavigationAction(); ok { | |
| 436 | 495 | routeTarget = action | |
| 437 | 496 | hasRouteTarget = true | |
| 497 | + | } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok { | |
| 498 | + | createDestination = selected | |
| 499 | + | hasCreateDestination = true | |
| 438 | 500 | } else if _, ok := selected.SessionTarget(); ok { | |
| 439 | 501 | sessionTarget = selected | |
| 440 | 502 | hasSessionTarget = true | |
| @@ -448,6 +510,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 448 | 510 | }) | |
| 449 | 511 | if changed { | |
| 450 | 512 | ac.overlays.paletteFeedback = "" | |
| 513 | + | if chooseDestination { | |
| 514 | + | ac.overlays.paletteFeedback = "Choose a destination" | |
| 515 | + | } | |
| 451 | 516 | ac.overlays.palettePreview = "" | |
| 452 | 517 | active, ok := ac.overlays.palette.ArgumentCommand() | |
| 453 | 518 | if ok { | |
| @@ -480,6 +545,35 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 480 | 545 | return | |
| 481 | 546 | } | |
| 482 | 547 | ||
| 548 | + | if hasCreateDestination { | |
| 549 | + | name, _, _, _, _, _ := createDestination.CreateSessionDestination() | |
| 550 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | |
| 551 | + | if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil { | |
| 552 | + | ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error()) | |
| 553 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 554 | + | return | |
| 555 | + | } | |
| 556 | + | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 557 | + | return | |
| 558 | + | } | |
| 559 | + | if name == "" { | |
| 560 | + | d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error { | |
| 561 | + | return exec.createSessionOnDestination(promptEffect, createDestination, name) | |
| 562 | + | }) | |
| 563 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 564 | + | return | |
| 565 | + | } | |
| 566 | + | err := exec.createSessionOnValidatedDestination(effect, createDestination, name) | |
| 567 | + | if err != nil && !errors.Is(err, errAttachmentTransition) { | |
| 568 | + | d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err)) | |
| 569 | + | return | |
| 570 | + | } | |
| 571 | + | if current := ac.currentAttachmentSession(); current != nil { | |
| 572 | + | d.invalidateRender(current, ac, true, "palette.go") | |
| 573 | + | } | |
| 574 | + | return | |
| 575 | + | } | |
| 576 | + | ||
| 483 | 577 | if hasRemoteTarget { | |
| 484 | 578 | if effect == nil { | |
| 485 | 579 | ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable") | |
| @@ -739,6 +833,110 @@ func (e paletteExec) CreateSessionNamed(name string) error { | |||
| 739 | 833 | return e.d.createSessionAndSwitchForAttachment(e.effect, name) | |
| 740 | 834 | } | |
| 741 | 835 | ||
| 836 | + | func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error { | |
| 837 | + | _, kind, _, endpoint, route, ok := result.CreateSessionDestination() | |
| 838 | + | if !ok { | |
| 839 | + | return errCreateDestinationUnavailable | |
| 840 | + | } | |
| 841 | + | switch kind { | |
| 842 | + | case palette.CreateSessionOnServingDaemon: | |
| 843 | + | return nil | |
| 844 | + | case palette.CreateSessionOnLocalRoute: | |
| 845 | + | if e.ac == nil { | |
| 846 | + | return errCreateDestinationUnavailable | |
| 847 | + | } | |
| 848 | + | snapshotGeneration, ok := result.CreateSessionSnapshotGeneration() | |
| 849 | + | if !ok || snapshotGeneration == 0 { | |
| 850 | + | return errCreateDestinationUnavailable | |
| 851 | + | } | |
| 852 | + | snapshot := e.ac.routeSnapshotCopy() | |
| 853 | + | entry, valid := routeEntryForRef(snapshot, route) | |
| 854 | + | if !valid || snapshot.Generation != snapshotGeneration || entry.Kind != protocol.RouteKindLocal { | |
| 855 | + | return errCreateDestinationUnavailable | |
| 856 | + | } | |
| 857 | + | if effect == nil && snapshot.Active != route { | |
| 858 | + | return errCreateDestinationUnavailable | |
| 859 | + | } | |
| 860 | + | return nil | |
| 861 | + | case palette.CreateSessionOnRemoteHost: | |
| 862 | + | if effect == nil || !e.d.remoteCreateHostReady(endpoint) { | |
| 863 | + | return errCreateDestinationUnavailable | |
| 864 | + | } | |
| 865 | + | return nil | |
| 866 | + | default: | |
| 867 | + | return errCreateDestinationUnavailable | |
| 868 | + | } | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error { | |
| 872 | + | if err := e.validateCreateSessionDestination(effect, result); err != nil { | |
| 873 | + | return err | |
| 874 | + | } | |
| 875 | + | return e.createSessionOnValidatedDestination(effect, result, name) | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error { | |
| 879 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 880 | + | return err | |
| 881 | + | } | |
| 882 | + | _, kind, _, endpoint, route, _ := result.CreateSessionDestination() | |
| 883 | + | if kind == palette.CreateSessionOnServingDaemon || | |
| 884 | + | (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) { | |
| 885 | + | if effect == nil { | |
| 886 | + | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |
| 887 | + | } | |
| 888 | + | return e.d.createSessionAndSwitchForAttachment(effect, name) | |
| 889 | + | } | |
| 890 | + | if effect == nil { | |
| 891 | + | return errAttachmentTransition | |
| 892 | + | } | |
| 893 | + | requestID := e.d.creationRequestSeq.Add(1) | |
| 894 | + | if requestID == 0 { | |
| 895 | + | requestID = e.d.creationRequestSeq.Add(1) | |
| 896 | + | } | |
| 897 | + | switch kind { | |
| 898 | + | case palette.CreateSessionOnLocalRoute: | |
| 899 | + | snapshotGeneration, _ := result.CreateSessionSnapshotGeneration() | |
| 900 | + | action := protocol.RouteCreateSessionAction{ | |
| 901 | + | RequestID: requestID, SnapshotGeneration: snapshotGeneration, | |
| 902 | + | Key: route.Key, Generation: route.Generation, SessionName: name, | |
| 903 | + | } | |
| 904 | + | if err := effect.sendControl(action); err != nil { | |
| 905 | + | return err | |
| 906 | + | } | |
| 907 | + | return nil | |
| 908 | + | case palette.CreateSessionOnRemoteHost: | |
| 909 | + | handoff := protocol.AttachTarget{ | |
| 910 | + | RequestID: requestID, Endpoint: endpoint, Session: name, Intent: protocol.IntentNew, | |
| 911 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 912 | + | } | |
| 913 | + | if err := protocol.ValidateAttachTarget(handoff); err != nil { | |
| 914 | + | return errAttachmentTransition | |
| 915 | + | } | |
| 916 | + | if err := effect.sendControl(handoff); err != nil { | |
| 917 | + | return err | |
| 918 | + | } | |
| 919 | + | e.d.clientGoneForAttachment(effect, false) | |
| 920 | + | return nil | |
| 921 | + | default: | |
| 922 | + | return errAttachmentTransition | |
| 923 | + | } | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | func (d *Daemon) remoteCreateHostReady(endpoint string) bool { | |
| 927 | + | if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil { | |
| 928 | + | return false | |
| 929 | + | } | |
| 930 | + | ranks := d.remoteHostRanks() | |
| 931 | + | if _, ok := ranks[endpoint]; !ok { | |
| 932 | + | return false | |
| 933 | + | } | |
| 934 | + | d.remoteCatalog.mu.Lock() | |
| 935 | + | defer d.remoteCatalog.mu.Unlock() | |
| 936 | + | _, ok := d.remoteCatalogEntryLocked(endpoint) | |
| 937 | + | return ok | |
| 938 | + | } | |
| 939 | + | ||
| 742 | 940 | func (e paletteExec) CreateEphemeralSession() error { | |
| 743 | 941 | if e.effect == nil { | |
| 744 | 942 | return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac) | |
| @@ -22,7 +22,10 @@ const ( | |||||
| 22 | paletteRailWidth = 64 | 22 | paletteRailWidth = 64 | ||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | - | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} | 25 | + | var ( |
| 26 | + | paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} | |||
| 27 | + | errCreateDestinationUnavailable = errors.New("that destination is no longer available") | |||
| 28 | + | ) | |||
| 26 | 29 | ||||
| 27 | func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | 30 | func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | ||
| 28 | modal := paletteModal | 31 | modal := paletteModal | ||
| @@ -97,6 +100,56 @@ func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle | |||||
| 97 | return paletteRemoteDisplayOrigin(active.HostLabel) | 100 | return paletteRemoteDisplayOrigin(active.HostLabel) | ||
| 98 | } | 101 | } | ||
| 99 | 102 | ||||
| 103 | + | func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) { | |||
| 104 | + | if ref.IsZero() { | |||
| 105 | + | return protocol.RecentRouteEntry{}, false | |||
| 106 | + | } | |||
| 107 | + | if snapshot.Active == ref { | |||
| 108 | + | entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation} | |||
| 109 | + | if entryRef == ref { | |||
| 110 | + | return snapshot.ActiveEntry, true | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | for _, entry := range snapshot.Entries { | |||
| 114 | + | if entry.Key == ref.Key && entry.Generation == ref.Generation { | |||
| 115 | + | return entry, true | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | return protocol.RecentRouteEntry{}, false | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, remoteCatalog []remoteCatalogPresentationEntry, hostRanks map[string]int) []palette.Result { | |||
| 122 | + | results := make([]palette.Result, 0, len(remoteCatalog)+2) | |||
| 123 | + | servingOrigin := "" | |||
| 124 | + | if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { | |||
| 125 | + | results = append(results, palette.NewCreateSessionDestination( | |||
| 126 | + | palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, | |||
| 127 | + | )) | |||
| 128 | + | } | |||
| 129 | + | if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote { | |||
| 130 | + | servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel) | |||
| 131 | + | results = append(results, palette.NewCreateSessionDestination( | |||
| 132 | + | palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0, | |||
| 133 | + | )) | |||
| 134 | + | } | |||
| 135 | + | for _, host := range remoteCatalog { | |||
| 136 | + | if host.status != remoteHostFresh || host.entry.Host == "" { | |||
| 137 | + | continue | |||
| 138 | + | } | |||
| 139 | + | if _, configured := hostRanks[host.entry.Host]; !configured { | |||
| 140 | + | continue | |||
| 141 | + | } | |||
| 142 | + | displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host) | |||
| 143 | + | if servingOrigin != "" && displayOrigin == servingOrigin { | |||
| 144 | + | continue | |||
| 145 | + | } | |||
| 146 | + | results = append(results, palette.NewCreateSessionDestination( | |||
| 147 | + | palette.CreateSessionOnRemoteHost, displayOrigin, host.entry.Host, protocol.RouteRef{}, 0, | |||
| 148 | + | )) | |||
| 149 | + | } | |||
| 150 | + | return results | |||
| 151 | + | } | |||
| 152 | + | ||||
| 100 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | 153 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | ||
| 101 | if entry.Kind == protocol.RouteKindLocal { | 154 | if entry.Kind == protocol.RouteKindLocal { | ||
| 102 | return true | 155 | return true | ||
| @@ -135,16 +188,19 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 135 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | 188 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | ||
| 136 | 189 | ||||
| 137 | remoteCatalog := d.remoteCatalogSnapshot() | 190 | remoteCatalog := d.remoteCatalogSnapshot() | ||
| 138 | - | sortRemoteCatalog(remoteCatalog, d.remoteHostRanks()) | 191 | + | hostRanks := d.remoteHostRanks() |
| 192 | + | sortRemoteCatalog(remoteCatalog, hostRanks) | |||
| 139 | remoteSessionCount := 0 | 193 | remoteSessionCount := 0 | ||
| 140 | for _, host := range remoteCatalog { | 194 | for _, host := range remoteCatalog { | ||
| 141 | remoteSessionCount += len(host.entry.Sessions) | 195 | remoteSessionCount += len(host.entry.Sessions) | ||
| 142 | } | 196 | } | ||
| 143 | 197 | ||||
| 144 | - | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | 198 | + | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks) |
| 199 | + | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | |||
| 145 | for _, cmd := range commands { | 200 | for _, cmd := range commands { | ||
| 146 | results = append(results, palette.NewCommandResult(cmd)) | 201 | results = append(results, palette.NewCommandResult(cmd)) | ||
| 147 | } | 202 | } | ||
| 203 | + | results = append(results, destinations...) | |||
| 148 | active := make([]palette.Result, 0, len(sessions)) | 204 | active := make([]palette.Result, 0, len(sessions)) | ||
| 149 | for _, candidate := range sessions { | 205 | for _, candidate := range sessions { | ||
| 150 | snap := candidate.snapshotView(viewOptions{}) | 206 | snap := candidate.snapshotView(viewOptions{}) | ||
| @@ -345,11 +401,13 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 345 | var hasRemoteTarget bool | 401 | var hasRemoteTarget bool | ||
| 346 | var routeTarget protocol.RouteNavigationAction | 402 | var routeTarget protocol.RouteNavigationAction | ||
| 347 | var hasRouteTarget bool | 403 | var hasRouteTarget bool | ||
| 404 | + | var createDestination palette.Result | |||
| 405 | + | var hasCreateDestination bool | |||
| 348 | var args []string | 406 | var args []string | ||
| 349 | var routeSnapshot protocol.RecentRouteSnapshot | 407 | var routeSnapshot protocol.RecentRouteSnapshot | ||
| 350 | var generation uint64 | 408 | var generation uint64 | ||
| 351 | var rawQuery string | 409 | var rawQuery string | ||
| 352 | - | changed, cancel, execute := false, false, false | 410 | + | changed, cancel, execute, chooseDestination := false, false, false, false |
| 353 | var closedDiscovery remoteDiscoveryInstance | 411 | var closedDiscovery remoteDiscoveryInstance | ||
| 354 | var effect *attachmentEffect | 412 | var effect *attachmentEffect | ||
| 355 | if len(effects) != 0 { | 413 | if len(effects) != 0 { | ||
| @@ -404,6 +462,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 404 | selected, ok := ac.overlays.palette.Selected() | 462 | selected, ok := ac.overlays.palette.Selected() | ||
| 405 | if !ok { | 463 | if !ok { | ||
| 406 | changed = true | 464 | changed = true | ||
| 465 | + | chooseDestination = true | |||
| 407 | return | 466 | return | ||
| 408 | } | 467 | } | ||
| 409 | rawQuery = ac.overlays.palette.Query() | 468 | rawQuery = ac.overlays.palette.Query() | ||
| @@ -435,6 +494,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 435 | } else if action, ok := selected.RouteNavigationAction(); ok { | 494 | } else if action, ok := selected.RouteNavigationAction(); ok { | ||
| 436 | routeTarget = action | 495 | routeTarget = action | ||
| 437 | hasRouteTarget = true | 496 | hasRouteTarget = true | ||
| 497 | + | } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok { | |||
| 498 | + | createDestination = selected | |||
| 499 | + | hasCreateDestination = true | |||
| 438 | } else if _, ok := selected.SessionTarget(); ok { | 500 | } else if _, ok := selected.SessionTarget(); ok { | ||
| 439 | sessionTarget = selected | 501 | sessionTarget = selected | ||
| 440 | hasSessionTarget = true | 502 | hasSessionTarget = true | ||
| @@ -448,6 +510,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 448 | }) | 510 | }) | ||
| 449 | if changed { | 511 | if changed { | ||
| 450 | ac.overlays.paletteFeedback = "" | 512 | ac.overlays.paletteFeedback = "" | ||
| 513 | + | if chooseDestination { | |||
| 514 | + | ac.overlays.paletteFeedback = "Choose a destination" | |||
| 515 | + | } | |||
| 451 | ac.overlays.palettePreview = "" | 516 | ac.overlays.palettePreview = "" | ||
| 452 | active, ok := ac.overlays.palette.ArgumentCommand() | 517 | active, ok := ac.overlays.palette.ArgumentCommand() | ||
| 453 | if ok { | 518 | if ok { | ||
| @@ -480,6 +545,35 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 480 | return | 545 | return | ||
| 481 | } | 546 | } | ||
| 482 | 547 | ||||
| 548 | + | if hasCreateDestination { | |||
| 549 | + | name, _, _, _, _, _ := createDestination.CreateSessionDestination() | |||
| 550 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | |||
| 551 | + | if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil { | |||
| 552 | + | ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error()) | |||
| 553 | + | d.invalidateRender(entry, ac, true, "palette.go") | |||
| 554 | + | return | |||
| 555 | + | } | |||
| 556 | + | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |||
| 557 | + | return | |||
| 558 | + | } | |||
| 559 | + | if name == "" { | |||
| 560 | + | d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error { | |||
| 561 | + | return exec.createSessionOnDestination(promptEffect, createDestination, name) | |||
| 562 | + | }) | |||
| 563 | + | d.invalidateRender(entry, ac, true, "palette.go") | |||
| 564 | + | return | |||
| 565 | + | } | |||
| 566 | + | err := exec.createSessionOnValidatedDestination(effect, createDestination, name) | |||
| 567 | + | if err != nil && !errors.Is(err, errAttachmentTransition) { | |||
| 568 | + | d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err)) | |||
| 569 | + | return | |||
| 570 | + | } | |||
| 571 | + | if current := ac.currentAttachmentSession(); current != nil { | |||
| 572 | + | d.invalidateRender(current, ac, true, "palette.go") | |||
| 573 | + | } | |||
| 574 | + | return | |||
| 575 | + | } | |||
| 576 | + | ||||
| 483 | if hasRemoteTarget { | 577 | if hasRemoteTarget { | ||
| 484 | if effect == nil { | 578 | if effect == nil { | ||
| 485 | ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable") | 579 | ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable") | ||
| @@ -739,6 +833,110 @@ func (e paletteExec) CreateSessionNamed(name string) error { | |||||
| 739 | return e.d.createSessionAndSwitchForAttachment(e.effect, name) | 833 | return e.d.createSessionAndSwitchForAttachment(e.effect, name) | ||
| 740 | } | 834 | } | ||
| 741 | 835 | ||||
| 836 | + | func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error { | |||
| 837 | + | _, kind, _, endpoint, route, ok := result.CreateSessionDestination() | |||
| 838 | + | if !ok { | |||
| 839 | + | return errCreateDestinationUnavailable | |||
| 840 | + | } | |||
| 841 | + | switch kind { | |||
| 842 | + | case palette.CreateSessionOnServingDaemon: | |||
| 843 | + | return nil | |||
| 844 | + | case palette.CreateSessionOnLocalRoute: | |||
| 845 | + | if e.ac == nil { | |||
| 846 | + | return errCreateDestinationUnavailable | |||
| 847 | + | } | |||
| 848 | + | snapshotGeneration, ok := result.CreateSessionSnapshotGeneration() | |||
| 849 | + | if !ok || snapshotGeneration == 0 { | |||
| 850 | + | return errCreateDestinationUnavailable | |||
| 851 | + | } | |||
| 852 | + | snapshot := e.ac.routeSnapshotCopy() | |||
| 853 | + | entry, valid := routeEntryForRef(snapshot, route) | |||
| 854 | + | if !valid || snapshot.Generation != snapshotGeneration || entry.Kind != protocol.RouteKindLocal { | |||
| 855 | + | return errCreateDestinationUnavailable | |||
| 856 | + | } | |||
| 857 | + | if effect == nil && snapshot.Active != route { | |||
| 858 | + | return errCreateDestinationUnavailable | |||
| 859 | + | } | |||
| 860 | + | return nil | |||
| 861 | + | case palette.CreateSessionOnRemoteHost: | |||
| 862 | + | if effect == nil || !e.d.remoteCreateHostReady(endpoint) { | |||
| 863 | + | return errCreateDestinationUnavailable | |||
| 864 | + | } | |||
| 865 | + | return nil | |||
| 866 | + | default: | |||
| 867 | + | return errCreateDestinationUnavailable | |||
| 868 | + | } | |||
| 869 | + | } | |||
| 870 | + | ||||
| 871 | + | func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error { | |||
| 872 | + | if err := e.validateCreateSessionDestination(effect, result); err != nil { | |||
| 873 | + | return err | |||
| 874 | + | } | |||
| 875 | + | return e.createSessionOnValidatedDestination(effect, result, name) | |||
| 876 | + | } | |||
| 877 | + | ||||
| 878 | + | func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error { | |||
| 879 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 880 | + | return err | |||
| 881 | + | } | |||
| 882 | + | _, kind, _, endpoint, route, _ := result.CreateSessionDestination() | |||
| 883 | + | if kind == palette.CreateSessionOnServingDaemon || | |||
| 884 | + | (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) { | |||
| 885 | + | if effect == nil { | |||
| 886 | + | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |||
| 887 | + | } | |||
| 888 | + | return e.d.createSessionAndSwitchForAttachment(effect, name) | |||
| 889 | + | } | |||
| 890 | + | if effect == nil { | |||
| 891 | + | return errAttachmentTransition | |||
| 892 | + | } | |||
| 893 | + | requestID := e.d.creationRequestSeq.Add(1) | |||
| 894 | + | if requestID == 0 { | |||
| 895 | + | requestID = e.d.creationRequestSeq.Add(1) | |||
| 896 | + | } | |||
| 897 | + | switch kind { | |||
| 898 | + | case palette.CreateSessionOnLocalRoute: | |||
| 899 | + | snapshotGeneration, _ := result.CreateSessionSnapshotGeneration() | |||
| 900 | + | action := protocol.RouteCreateSessionAction{ | |||
| 901 | + | RequestID: requestID, SnapshotGeneration: snapshotGeneration, | |||
| 902 | + | Key: route.Key, Generation: route.Generation, SessionName: name, | |||
| 903 | + | } | |||
| 904 | + | if err := effect.sendControl(action); err != nil { | |||
| 905 | + | return err | |||
| 906 | + | } | |||
| 907 | + | return nil | |||
| 908 | + | case palette.CreateSessionOnRemoteHost: | |||
| 909 | + | handoff := protocol.AttachTarget{ | |||
| 910 | + | RequestID: requestID, Endpoint: endpoint, Session: name, Intent: protocol.IntentNew, | |||
| 911 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 912 | + | } | |||
| 913 | + | if err := protocol.ValidateAttachTarget(handoff); err != nil { | |||
| 914 | + | return errAttachmentTransition | |||
| 915 | + | } | |||
| 916 | + | if err := effect.sendControl(handoff); err != nil { | |||
| 917 | + | return err | |||
| 918 | + | } | |||
| 919 | + | e.d.clientGoneForAttachment(effect, false) | |||
| 920 | + | return nil | |||
| 921 | + | default: | |||
| 922 | + | return errAttachmentTransition | |||
| 923 | + | } | |||
| 924 | + | } | |||
| 925 | + | ||||
| 926 | + | func (d *Daemon) remoteCreateHostReady(endpoint string) bool { | |||
| 927 | + | if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil { | |||
| 928 | + | return false | |||
| 929 | + | } | |||
| 930 | + | ranks := d.remoteHostRanks() | |||
| 931 | + | if _, ok := ranks[endpoint]; !ok { | |||
| 932 | + | return false | |||
| 933 | + | } | |||
| 934 | + | d.remoteCatalog.mu.Lock() | |||
| 935 | + | defer d.remoteCatalog.mu.Unlock() | |||
| 936 | + | _, ok := d.remoteCatalogEntryLocked(endpoint) | |||
| 937 | + | return ok | |||
| 938 | + | } | |||
| 939 | + | ||||
| 742 | func (e paletteExec) CreateEphemeralSession() error { | 940 | func (e paletteExec) CreateEphemeralSession() error { | ||
| 743 | if e.effect == nil { | 941 | if e.effect == nil { | ||
| 744 | return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac) | 942 | return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac) | ||
M internal/usecase/daemon/palette_test.go +70 -0
| @@ -47,6 +47,76 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a | |||
| 47 | 47 | return effect | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T) { | |
| 51 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 52 | + | snapshot := protocol.RecentRouteSnapshot{ | |
| 53 | + | Generation: 4, | |
| 54 | + | Active: protocol.RouteRef{Key: 2, Generation: 3}, | |
| 55 | + | ActiveEntry: protocol.RecentRouteEntry{ | |
| 56 | + | Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a", | |
| 57 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work", | |
| 58 | + | }, | |
| 59 | + | Home: protocol.RouteRef{Key: 1, Generation: 2}, | |
| 60 | + | Entries: []protocol.RecentRouteEntry{{ | |
| 61 | + | Key: 1, Generation: 2, Kind: protocol.RouteKindLocal, | |
| 62 | + | Target: testRouteTarget("home", 2), Name: "home", | |
| 63 | + | }}, | |
| 64 | + | } | |
| 65 | + | catalog := []remoteCatalogPresentationEntry{ | |
| 66 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-a"}, status: remoteHostFresh}, | |
| 67 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-b"}, status: remoteHostFresh}, | |
| 68 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-c"}, status: remoteHostStale}, | |
| 69 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-d"}, status: remoteHostFresh}, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | results := createSessionDestinationResults(snapshot, lifecycle, catalog, map[string]int{"host-a": 0, "host-b": 1, "host-c": 2}) | |
| 73 | + | require.Len(t, results, 3) | |
| 74 | + | _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination() | |
| 75 | + | require.True(t, ok) | |
| 76 | + | require.Equal(t, palette.CreateSessionOnLocalRoute, kind) | |
| 77 | + | require.Equal(t, snapshot.Home, route) | |
| 78 | + | require.Empty(t, endpoint) | |
| 79 | + | snapshotGeneration, ok := results[0].CreateSessionSnapshotGeneration() | |
| 80 | + | require.True(t, ok) | |
| 81 | + | require.Equal(t, snapshot.Generation, snapshotGeneration) | |
| 82 | + | _, kind, origin, endpoint, _, ok := results[1].CreateSessionDestination() | |
| 83 | + | require.True(t, ok) | |
| 84 | + | require.Equal(t, palette.CreateSessionOnServingDaemon, kind) | |
| 85 | + | require.Equal(t, "host-a", origin) | |
| 86 | + | require.Empty(t, endpoint) | |
| 87 | + | _, kind, origin, endpoint, _, ok = results[2].CreateSessionDestination() | |
| 88 | + | require.True(t, ok) | |
| 89 | + | require.Equal(t, palette.CreateSessionOnRemoteHost, kind) | |
| 90 | + | require.Equal(t, "host-b", origin) | |
| 91 | + | require.Equal(t, "host-b", endpoint) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | |
| 95 | + | result := palette.NewCreateSessionDestination( | |
| 96 | + | palette.CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0, | |
| 97 | + | ) | |
| 98 | + | err := (paletteExec{}).validateCreateSessionDestination(nil, result) | |
| 99 | + | require.ErrorIs(t, err, errCreateDestinationUnavailable) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) { | |
| 103 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 104 | + | snapshot := protocol.RecentRouteSnapshot{ | |
| 105 | + | Generation: 1, | |
| 106 | + | Active: protocol.RouteRef{Key: 1, Generation: 1}, | |
| 107 | + | Home: protocol.RouteRef{Key: 1, Generation: 1}, | |
| 108 | + | ActiveEntry: protocol.RecentRouteEntry{ | |
| 109 | + | Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "host-a", | |
| 110 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work", | |
| 111 | + | }, | |
| 112 | + | } | |
| 113 | + | results := createSessionDestinationResults(snapshot, lifecycle, nil, nil) | |
| 114 | + | require.Len(t, results, 1) | |
| 115 | + | _, kind, _, _, _, ok := results[0].CreateSessionDestination() | |
| 116 | + | require.True(t, ok) | |
| 117 | + | require.Equal(t, palette.CreateSessionOnServingDaemon, kind) | |
| 118 | + | } | |
| 119 | + | ||
| 50 | 120 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |
| 51 | 121 | paletteColors := [16]renderer.RGB{} | |
| 52 | 122 | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| @@ -47,6 +47,76 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a | |||||
| 47 | return effect | 47 | return effect | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T) { | |||
| 51 | + | lifecycle := domain.SessionLifecycleID{1} | |||
| 52 | + | snapshot := protocol.RecentRouteSnapshot{ | |||
| 53 | + | Generation: 4, | |||
| 54 | + | Active: protocol.RouteRef{Key: 2, Generation: 3}, | |||
| 55 | + | ActiveEntry: protocol.RecentRouteEntry{ | |||
| 56 | + | Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a", | |||
| 57 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work", | |||
| 58 | + | }, | |||
| 59 | + | Home: protocol.RouteRef{Key: 1, Generation: 2}, | |||
| 60 | + | Entries: []protocol.RecentRouteEntry{{ | |||
| 61 | + | Key: 1, Generation: 2, Kind: protocol.RouteKindLocal, | |||
| 62 | + | Target: testRouteTarget("home", 2), Name: "home", | |||
| 63 | + | }}, | |||
| 64 | + | } | |||
| 65 | + | catalog := []remoteCatalogPresentationEntry{ | |||
| 66 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-a"}, status: remoteHostFresh}, | |||
| 67 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-b"}, status: remoteHostFresh}, | |||
| 68 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-c"}, status: remoteHostStale}, | |||
| 69 | + | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-d"}, status: remoteHostFresh}, | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | results := createSessionDestinationResults(snapshot, lifecycle, catalog, map[string]int{"host-a": 0, "host-b": 1, "host-c": 2}) | |||
| 73 | + | require.Len(t, results, 3) | |||
| 74 | + | _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination() | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | require.Equal(t, palette.CreateSessionOnLocalRoute, kind) | |||
| 77 | + | require.Equal(t, snapshot.Home, route) | |||
| 78 | + | require.Empty(t, endpoint) | |||
| 79 | + | snapshotGeneration, ok := results[0].CreateSessionSnapshotGeneration() | |||
| 80 | + | require.True(t, ok) | |||
| 81 | + | require.Equal(t, snapshot.Generation, snapshotGeneration) | |||
| 82 | + | _, kind, origin, endpoint, _, ok := results[1].CreateSessionDestination() | |||
| 83 | + | require.True(t, ok) | |||
| 84 | + | require.Equal(t, palette.CreateSessionOnServingDaemon, kind) | |||
| 85 | + | require.Equal(t, "host-a", origin) | |||
| 86 | + | require.Empty(t, endpoint) | |||
| 87 | + | _, kind, origin, endpoint, _, ok = results[2].CreateSessionDestination() | |||
| 88 | + | require.True(t, ok) | |||
| 89 | + | require.Equal(t, palette.CreateSessionOnRemoteHost, kind) | |||
| 90 | + | require.Equal(t, "host-b", origin) | |||
| 91 | + | require.Equal(t, "host-b", endpoint) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | |||
| 95 | + | result := palette.NewCreateSessionDestination( | |||
| 96 | + | palette.CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0, | |||
| 97 | + | ) | |||
| 98 | + | err := (paletteExec{}).validateCreateSessionDestination(nil, result) | |||
| 99 | + | require.ErrorIs(t, err, errCreateDestinationUnavailable) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) { | |||
| 103 | + | lifecycle := domain.SessionLifecycleID{1} | |||
| 104 | + | snapshot := protocol.RecentRouteSnapshot{ | |||
| 105 | + | Generation: 1, | |||
| 106 | + | Active: protocol.RouteRef{Key: 1, Generation: 1}, | |||
| 107 | + | Home: protocol.RouteRef{Key: 1, Generation: 1}, | |||
| 108 | + | ActiveEntry: protocol.RecentRouteEntry{ | |||
| 109 | + | Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "host-a", | |||
| 110 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work", | |||
| 111 | + | }, | |||
| 112 | + | } | |||
| 113 | + | results := createSessionDestinationResults(snapshot, lifecycle, nil, nil) | |||
| 114 | + | require.Len(t, results, 1) | |||
| 115 | + | _, kind, _, _, _, ok := results[0].CreateSessionDestination() | |||
| 116 | + | require.True(t, ok) | |||
| 117 | + | require.Equal(t, palette.CreateSessionOnServingDaemon, kind) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 50 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | 120 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | ||
| 51 | paletteColors := [16]renderer.RGB{} | 121 | paletteColors := [16]renderer.RGB{} | ||
| 52 | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | 122 | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | ||
M internal/usecase/daemon/prompt_test.go +30 -0
| @@ -81,6 +81,36 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) { | |||
| 81 | 81 | require.Contains(t, string(out.Data), "name already in use") | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | func TestTransitionPromptFailuresStayVisible(t *testing.T) { | |
| 85 | + | tests := []struct { | |
| 86 | + | name string | |
| 87 | + | submit error | |
| 88 | + | wantErr string | |
| 89 | + | }{ | |
| 90 | + | {name: "destination unavailable", submit: errCreateDestinationUnavailable, wantErr: "that destination is no longer available"}, | |
| 91 | + | {name: "transition failed", submit: errors.New("transition failed"), wantErr: "transition failed"}, | |
| 92 | + | } | |
| 93 | + | for _, tt := range tests { | |
| 94 | + | t.Run(tt.name, func(t *testing.T) { | |
| 95 | + | p, release := newBlockingPTY(t) | |
| 96 | + | defer release() | |
| 97 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 98 | + | ||
| 99 | + | d.enterTransitionPrompt(sess, ac, " Create session ", "", func(string, *attachmentEffect) error { | |
| 100 | + | return tt.submit | |
| 101 | + | }) | |
| 102 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 103 | + | d.handlePromptInput(ac, []byte("example\r")) | |
| 104 | + | repaint := awaitFrame(t, sends, wire.MsgOutput) | |
| 105 | + | ||
| 106 | + | require.True(t, ac.overlays.promptActive()) | |
| 107 | + | out, err := wire.UnmarshalOutput(repaint.Payload) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | require.Contains(t, string(out.Data), tt.wantErr) | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 84 | 114 | func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) { | |
| 85 | 115 | p, release := newBlockingPTY(t) | |
| 86 | 116 | defer release() | |
| @@ -81,6 +81,36 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) { | |||||
| 81 | require.Contains(t, string(out.Data), "name already in use") | 81 | require.Contains(t, string(out.Data), "name already in use") | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | + | func TestTransitionPromptFailuresStayVisible(t *testing.T) { | |||
| 85 | + | tests := []struct { | |||
| 86 | + | name string | |||
| 87 | + | submit error | |||
| 88 | + | wantErr string | |||
| 89 | + | }{ | |||
| 90 | + | {name: "destination unavailable", submit: errCreateDestinationUnavailable, wantErr: "that destination is no longer available"}, | |||
| 91 | + | {name: "transition failed", submit: errors.New("transition failed"), wantErr: "transition failed"}, | |||
| 92 | + | } | |||
| 93 | + | for _, tt := range tests { | |||
| 94 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 95 | + | p, release := newBlockingPTY(t) | |||
| 96 | + | defer release() | |||
| 97 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 98 | + | ||||
| 99 | + | d.enterTransitionPrompt(sess, ac, " Create session ", "", func(string, *attachmentEffect) error { | |||
| 100 | + | return tt.submit | |||
| 101 | + | }) | |||
| 102 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 103 | + | d.handlePromptInput(ac, []byte("example\r")) | |||
| 104 | + | repaint := awaitFrame(t, sends, wire.MsgOutput) | |||
| 105 | + | ||||
| 106 | + | require.True(t, ac.overlays.promptActive()) | |||
| 107 | + | out, err := wire.UnmarshalOutput(repaint.Payload) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | require.Contains(t, string(out.Data), tt.wantErr) | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 84 | func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) { | 114 | func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) { | ||
| 85 | p, release := newBlockingPTY(t) | 115 | p, release := newBlockingPTY(t) | ||
| 86 | defer release() | 116 | defer release() | ||
M internal/usecase/daemon/recent_route_presentation.go +4 -13
| @@ -40,15 +40,10 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR | |||
| 40 | 40 | if entries == nil { | |
| 41 | 41 | return nil | |
| 42 | 42 | } | |
| 43 | - | nameCounts := make(map[string]int, len(entries)) | |
| 44 | - | for _, entry := range entries { | |
| 45 | - | nameCounts[entry.name]++ | |
| 46 | - | } | |
| 47 | - | ||
| 48 | 43 | out := make([]recentRouteDisplay, len(entries)) | |
| 49 | 44 | for i, entry := range entries { | |
| 50 | 45 | out[i] = recentRouteDisplay{ | |
| 51 | - | name: formatRecentRouteName(entry, nameCounts[entry.name] > 1), | |
| 46 | + | name: formatRecentRouteName(entry), | |
| 52 | 47 | kind: entry.kind, | |
| 53 | 48 | ephemeral: entry.ephemeral, | |
| 54 | 49 | attention: entry.attention, | |
| @@ -57,22 +52,18 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR | |||
| 57 | 52 | return out | |
| 58 | 53 | } | |
| 59 | 54 | ||
| 60 | - | func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string { | |
| 55 | + | func formatRecentRouteName(entry recentRoutePresentation) string { | |
| 61 | 56 | name := entry.name | |
| 62 | 57 | if entry.ephemeral { | |
| 63 | 58 | name += "*" | |
| 64 | 59 | } | |
| 65 | - | if entry.kind != recentRouteRemote && !ambiguous { | |
| 60 | + | if entry.kind != recentRouteRemote { | |
| 66 | 61 | return name | |
| 67 | 62 | } | |
| 68 | 63 | ||
| 69 | 64 | host := entry.hostLabel | |
| 70 | 65 | if host == "" { | |
| 71 | - | if entry.kind == recentRouteRemote { | |
| 72 | - | host = "remote" | |
| 73 | - | } else { | |
| 74 | - | host = "local" | |
| 75 | - | } | |
| 66 | + | host = "remote" | |
| 76 | 67 | } | |
| 77 | 68 | return domain.RemoteSessionDisplay(name, host) | |
| 78 | 69 | } | |
| @@ -40,15 +40,10 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR | |||||
| 40 | if entries == nil { | 40 | if entries == nil { | ||
| 41 | return nil | 41 | return nil | ||
| 42 | } | 42 | } | ||
| 43 | - | nameCounts := make(map[string]int, len(entries)) | |||
| 44 | - | for _, entry := range entries { | |||
| 45 | - | nameCounts[entry.name]++ | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | out := make([]recentRouteDisplay, len(entries)) | 43 | out := make([]recentRouteDisplay, len(entries)) | ||
| 49 | for i, entry := range entries { | 44 | for i, entry := range entries { | ||
| 50 | out[i] = recentRouteDisplay{ | 45 | out[i] = recentRouteDisplay{ | ||
| 51 | - | name: formatRecentRouteName(entry, nameCounts[entry.name] > 1), | 46 | + | name: formatRecentRouteName(entry), |
| 52 | kind: entry.kind, | 47 | kind: entry.kind, | ||
| 53 | ephemeral: entry.ephemeral, | 48 | ephemeral: entry.ephemeral, | ||
| 54 | attention: entry.attention, | 49 | attention: entry.attention, | ||
| @@ -57,22 +52,18 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR | |||||
| 57 | return out | 52 | return out | ||
| 58 | } | 53 | } | ||
| 59 | 54 | ||||
| 60 | - | func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string { | 55 | + | func formatRecentRouteName(entry recentRoutePresentation) string { |
| 61 | name := entry.name | 56 | name := entry.name | ||
| 62 | if entry.ephemeral { | 57 | if entry.ephemeral { | ||
| 63 | name += "*" | 58 | name += "*" | ||
| 64 | } | 59 | } | ||
| 65 | - | if entry.kind != recentRouteRemote && !ambiguous { | 60 | + | if entry.kind != recentRouteRemote { |
| 66 | return name | 61 | return name | ||
| 67 | } | 62 | } | ||
| 68 | 63 | ||||
| 69 | host := entry.hostLabel | 64 | host := entry.hostLabel | ||
| 70 | if host == "" { | 65 | if host == "" { | ||
| 71 | - | if entry.kind == recentRouteRemote { | 66 | + | host = "remote" |
| 72 | - | host = "remote" | |||
| 73 | - | } else { | |||
| 74 | - | host = "local" | |||
| 75 | - | } | |||
| 76 | } | 67 | } | ||
| 77 | return domain.RemoteSessionDisplay(name, host) | 68 | return domain.RemoteSessionDisplay(name, host) | ||
| 78 | } | 69 | } | ||
M internal/usecase/daemon/recent_route_presentation_test.go +17 -6
| @@ -34,16 +34,27 @@ func TestFormatRecentRoutePresentations(t *testing.T) { | |||
| 34 | 34 | }}, | |
| 35 | 35 | }, | |
| 36 | 36 | { | |
| 37 | - | name: "duplicate local and remote names qualify every entry", | |
| 37 | + | name: "duplicate local and remote names keep local compact", | |
| 38 | 38 | input: []recentRoutePresentation{ | |
| 39 | 39 | {name: "logs", kind: recentRouteLocal}, | |
| 40 | 40 | {name: "logs", hostLabel: "edge", kind: recentRouteRemote}, | |
| 41 | 41 | }, | |
| 42 | 42 | want: []recentRouteDisplay{ | |
| 43 | - | {name: "logs@local", kind: recentRouteLocal}, | |
| 43 | + | {name: "logs", kind: recentRouteLocal}, | |
| 44 | 44 | {name: "logs@edge", kind: recentRouteRemote}, | |
| 45 | 45 | }, | |
| 46 | 46 | }, | |
| 47 | + | { | |
| 48 | + | name: "duplicate local generations stay compact", | |
| 49 | + | input: []recentRoutePresentation{ | |
| 50 | + | {name: "logs", kind: recentRouteLocal}, | |
| 51 | + | {name: "logs", kind: recentRouteLocal}, | |
| 52 | + | }, | |
| 53 | + | want: []recentRouteDisplay{ | |
| 54 | + | {name: "logs", kind: recentRouteLocal}, | |
| 55 | + | {name: "logs", kind: recentRouteLocal}, | |
| 56 | + | }, | |
| 57 | + | }, | |
| 47 | 58 | { | |
| 48 | 59 | name: "missing host uses kind fallback", | |
| 49 | 60 | input: []recentRoutePresentation{{ | |
| @@ -86,8 +97,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||
| 86 | 97 | ||
| 87 | 98 | hints := recentRouteHints(snapshot, nil) | |
| 88 | 99 | require.Equal(t, []palette.RecentSessionHint{ | |
| 89 | - | {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1}, | |
| 90 | - | {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1}, | |
| 100 | + | {Rank: 1, Name: "logs", SnapshotGeneration: 1, Key: 1, Generation: 1}, | |
| 101 | + | {Rank: 2, Name: "logs", SnapshotGeneration: 1, Key: 2, Generation: 1}, | |
| 91 | 102 | }, hints.Recent) | |
| 92 | 103 | ||
| 93 | 104 | hints = palette.ContextualHints{ | |
| @@ -101,8 +112,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||
| 101 | 112 | ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot) | |
| 102 | 113 | ||
| 103 | 114 | require.Equal(t, []rankedRecent{ | |
| 104 | - | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | |
| 105 | - | {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true}, | |
| 115 | + | {rank: 4, name: "logs", kind: recentRouteLocal, attention: true}, | |
| 116 | + | {rank: 9, name: "logs", kind: recentRouteLocal, selected: true}, | |
| 106 | 117 | }, ranked) | |
| 107 | 118 | } | |
| 108 | 119 | ||
| @@ -34,16 +34,27 @@ func TestFormatRecentRoutePresentations(t *testing.T) { | |||||
| 34 | }}, | 34 | }}, | ||
| 35 | }, | 35 | }, | ||
| 36 | { | 36 | { | ||
| 37 | - | name: "duplicate local and remote names qualify every entry", | 37 | + | name: "duplicate local and remote names keep local compact", |
| 38 | input: []recentRoutePresentation{ | 38 | input: []recentRoutePresentation{ | ||
| 39 | {name: "logs", kind: recentRouteLocal}, | 39 | {name: "logs", kind: recentRouteLocal}, | ||
| 40 | {name: "logs", hostLabel: "edge", kind: recentRouteRemote}, | 40 | {name: "logs", hostLabel: "edge", kind: recentRouteRemote}, | ||
| 41 | }, | 41 | }, | ||
| 42 | want: []recentRouteDisplay{ | 42 | want: []recentRouteDisplay{ | ||
| 43 | - | {name: "logs@local", kind: recentRouteLocal}, | 43 | + | {name: "logs", kind: recentRouteLocal}, |
| 44 | {name: "logs@edge", kind: recentRouteRemote}, | 44 | {name: "logs@edge", kind: recentRouteRemote}, | ||
| 45 | }, | 45 | }, | ||
| 46 | }, | 46 | }, | ||
| 47 | + | { | |||
| 48 | + | name: "duplicate local generations stay compact", | |||
| 49 | + | input: []recentRoutePresentation{ | |||
| 50 | + | {name: "logs", kind: recentRouteLocal}, | |||
| 51 | + | {name: "logs", kind: recentRouteLocal}, | |||
| 52 | + | }, | |||
| 53 | + | want: []recentRouteDisplay{ | |||
| 54 | + | {name: "logs", kind: recentRouteLocal}, | |||
| 55 | + | {name: "logs", kind: recentRouteLocal}, | |||
| 56 | + | }, | |||
| 57 | + | }, | |||
| 47 | { | 58 | { | ||
| 48 | name: "missing host uses kind fallback", | 59 | name: "missing host uses kind fallback", | ||
| 49 | input: []recentRoutePresentation{{ | 60 | input: []recentRoutePresentation{{ | ||
| @@ -86,8 +97,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||||
| 86 | 97 | ||||
| 87 | hints := recentRouteHints(snapshot, nil) | 98 | hints := recentRouteHints(snapshot, nil) | ||
| 88 | require.Equal(t, []palette.RecentSessionHint{ | 99 | require.Equal(t, []palette.RecentSessionHint{ | ||
| 89 | - | {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1}, | 100 | + | {Rank: 1, Name: "logs", SnapshotGeneration: 1, Key: 1, Generation: 1}, |
| 90 | - | {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1}, | 101 | + | {Rank: 2, Name: "logs", SnapshotGeneration: 1, Key: 2, Generation: 1}, |
| 91 | }, hints.Recent) | 102 | }, hints.Recent) | ||
| 92 | 103 | ||||
| 93 | hints = palette.ContextualHints{ | 104 | hints = palette.ContextualHints{ | ||
| @@ -101,8 +112,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||||
| 101 | ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot) | 112 | ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot) | ||
| 102 | 113 | ||||
| 103 | require.Equal(t, []rankedRecent{ | 114 | require.Equal(t, []rankedRecent{ | ||
| 104 | - | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | 115 | + | {rank: 4, name: "logs", kind: recentRouteLocal, attention: true}, |
| 105 | - | {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true}, | 116 | + | {rank: 9, name: "logs", kind: recentRouteLocal, selected: true}, |
| 106 | }, ranked) | 117 | }, ranked) | ||
| 107 | } | 118 | } | ||
| 108 | 119 | ||||
M internal/usecase/daemon/status.go +1 -1
| @@ -203,7 +203,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||
| 203 | 203 | presentation.kind = recentRouteRemote | |
| 204 | 204 | } | |
| 205 | 205 | } | |
| 206 | - | name := formatRecentRouteName(presentation, false) | |
| 206 | + | name := formatRecentRouteName(presentation) | |
| 207 | 207 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 208 | 208 | activeIndex := 0 | |
| 209 | 209 | if ac != nil { | |
| @@ -203,7 +203,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||||
| 203 | presentation.kind = recentRouteRemote | 203 | presentation.kind = recentRouteRemote | ||
| 204 | } | 204 | } | ||
| 205 | } | 205 | } | ||
| 206 | - | name := formatRecentRouteName(presentation, false) | 206 | + | name := formatRecentRouteName(presentation) |
| 207 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | 207 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | ||
| 208 | activeIndex := 0 | 208 | activeIndex := 0 | ||
| 209 | if ac != nil { | 209 | if ac != nil { | ||
M internal/usecase/palette/model.go +105 -19
| @@ -36,20 +36,24 @@ type RenderOptions struct { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | type Model struct { | |
| 39 | - | results []Result | |
| 40 | - | argumentResults []Result | |
| 41 | - | input ui.TextInput | |
| 42 | - | matches []Match | |
| 43 | - | selected int | |
| 44 | - | scroll int | |
| 39 | + | results []Result | |
| 40 | + | argumentResults []Result | |
| 41 | + | createDestinations []Result | |
| 42 | + | input ui.TextInput | |
| 43 | + | matches []Match | |
| 44 | + | selected int | |
| 45 | + | scroll int | |
| 46 | + | destinationMode bool | |
| 47 | + | selectionMade bool | |
| 45 | 48 | } | |
| 46 | 49 | ||
| 47 | 50 | // New accepts typed immutable palette results. | |
| 48 | 51 | func New(results []Result) *Model { | |
| 49 | - | copied := append([]Result(nil), results...) | |
| 52 | + | ordinary, destinations := splitResults(results) | |
| 50 | 53 | m := &Model{ | |
| 51 | - | results: copied, | |
| 52 | - | argumentResults: requiredArgumentResults(copied), | |
| 54 | + | results: ordinary, | |
| 55 | + | argumentResults: requiredArgumentResults(ordinary), | |
| 56 | + | createDestinations: destinations, | |
| 53 | 57 | } | |
| 54 | 58 | m.refresh() | |
| 55 | 59 | return m | |
| @@ -63,20 +67,32 @@ func (m *Model) ReplaceResults(results []Result) { | |||
| 63 | 67 | if m == nil { | |
| 64 | 68 | return | |
| 65 | 69 | } | |
| 70 | + | wasDestinationMode := m.destinationMode | |
| 71 | + | wasSelectionMade := m.selectionMade | |
| 66 | 72 | selected, hadSelection := m.Selected() | |
| 67 | - | m.results = append([]Result(nil), results...) | |
| 73 | + | selectedDestination := hadSelection && selected.Kind() == ResultKindCreateSessionDestination | |
| 74 | + | m.results, m.createDestinations = splitResults(results) | |
| 68 | 75 | m.argumentResults = requiredArgumentResults(m.results) | |
| 69 | 76 | m.refresh() | |
| 70 | 77 | if !hadSelection { | |
| 78 | + | if m.destinationMode || wasDestinationMode && !wasSelectionMade { | |
| 79 | + | m.selectionMade = false | |
| 80 | + | } | |
| 71 | 81 | return | |
| 72 | 82 | } | |
| 73 | 83 | for i, match := range m.matches { | |
| 74 | 84 | if match.Result.sameTarget(selected) { | |
| 75 | 85 | m.selected = i | |
| 86 | + | m.selectionMade = true | |
| 76 | 87 | m.clamp() | |
| 77 | 88 | return | |
| 78 | 89 | } | |
| 79 | 90 | } | |
| 91 | + | if selectedDestination || m.destinationMode { | |
| 92 | + | m.selectionMade = false | |
| 93 | + | m.selected, m.scroll = 0, 0 | |
| 94 | + | m.clamp() | |
| 95 | + | } | |
| 80 | 96 | } | |
| 81 | 97 | ||
| 82 | 98 | func DefaultRenderStyles() RenderStyles { | |
| @@ -93,14 +109,14 @@ func DefaultRenderStyles() RenderStyles { | |||
| 93 | 109 | func (m *Model) Insert(r rune) { | |
| 94 | 110 | if m != nil { | |
| 95 | 111 | m.input.Insert(r) | |
| 96 | - | m.selected, m.scroll = 0, 0 | |
| 112 | + | m.selected, m.scroll, m.selectionMade = 0, 0, false | |
| 97 | 113 | m.refresh() | |
| 98 | 114 | } | |
| 99 | 115 | } | |
| 100 | 116 | func (m *Model) Backspace() { | |
| 101 | 117 | if m != nil && m.input.Value() != "" { | |
| 102 | 118 | m.input.Backspace() | |
| 103 | - | m.selected, m.scroll = 0, 0 | |
| 119 | + | m.selected, m.scroll, m.selectionMade = 0, 0, false | |
| 104 | 120 | m.refresh() | |
| 105 | 121 | } | |
| 106 | 122 | } | |
| @@ -112,13 +128,31 @@ func (m *Model) Query() string { | |||
| 112 | 128 | return m.input.Value() | |
| 113 | 129 | } | |
| 114 | 130 | func (m *Model) Up() { | |
| 115 | - | if m != nil && m.selected > 0 { | |
| 131 | + | if m == nil || len(m.matches) == 0 { | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | if !m.selectionMade { | |
| 135 | + | m.selected = len(m.matches) - 1 | |
| 136 | + | m.selectionMade = true | |
| 137 | + | m.clamp() | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | if m.selected > 0 { | |
| 116 | 141 | m.selected-- | |
| 117 | 142 | m.clamp() | |
| 118 | 143 | } | |
| 119 | 144 | } | |
| 120 | 145 | func (m *Model) Down() { | |
| 121 | - | if m != nil && m.selected+1 < len(m.matches) { | |
| 146 | + | if m == nil || len(m.matches) == 0 { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | if !m.selectionMade { | |
| 150 | + | m.selected = 0 | |
| 151 | + | m.selectionMade = true | |
| 152 | + | m.clamp() | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | if m.selected+1 < len(m.matches) { | |
| 122 | 156 | m.selected++ | |
| 123 | 157 | m.clamp() | |
| 124 | 158 | } | |
| @@ -126,7 +160,7 @@ func (m *Model) Down() { | |||
| 126 | 160 | ||
| 127 | 161 | // Selected returns the typed immutable target, not an executable command. | |
| 128 | 162 | func (m *Model) Selected() (Result, bool) { | |
| 129 | - | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | |
| 163 | + | if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) { | |
| 130 | 164 | return Result{}, false | |
| 131 | 165 | } | |
| 132 | 166 | return m.matches[m.selected].Result, true | |
| @@ -162,7 +196,7 @@ func (m *Model) CompleteSelected() bool { | |||
| 162 | 196 | } | |
| 163 | 197 | ||
| 164 | 198 | m.input.SetValue(completed) | |
| 165 | - | m.selected, m.scroll = 0, 0 | |
| 199 | + | m.selected, m.scroll, m.selectionMade = 0, 0, false | |
| 166 | 200 | m.refresh() | |
| 167 | 201 | return true | |
| 168 | 202 | } | |
| @@ -206,6 +240,18 @@ func (m *Model) Matches() []Match { | |||
| 206 | 240 | ||
| 207 | 241 | func (m *Model) refresh() { | |
| 208 | 242 | query := m.input.Value() | |
| 243 | + | wasDestinationMode := m.destinationMode | |
| 244 | + | if destinations, ok := m.cnsDestinationMatches(query); ok { | |
| 245 | + | m.destinationMode = true | |
| 246 | + | if !wasDestinationMode { | |
| 247 | + | m.selectionMade = false | |
| 248 | + | } | |
| 249 | + | m.matches = destinations | |
| 250 | + | m.clamp() | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | m.destinationMode = false | |
| 254 | + | m.selectionMade = true | |
| 209 | 255 | m.matches = Fuzzy(m.results, query) | |
| 210 | 256 | // Keep an exact command row selected over fuzzy session matches. | |
| 211 | 257 | if result, ok := ExactCommandResult(m.results, query); ok { | |
| @@ -226,6 +272,44 @@ func (m *Model) refresh() { | |||
| 226 | 272 | m.clamp() | |
| 227 | 273 | } | |
| 228 | 274 | ||
| 275 | + | func splitResults(results []Result) (ordinary, destinations []Result) { | |
| 276 | + | ordinary = make([]Result, 0, len(results)) | |
| 277 | + | for _, result := range results { | |
| 278 | + | if result.Kind() == ResultKindCreateSessionDestination { | |
| 279 | + | destinations = append(destinations, result) | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | ordinary = append(ordinary, result) | |
| 283 | + | } | |
| 284 | + | return ordinary, destinations | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) { | |
| 288 | + | if len(m.createDestinations) < 2 { | |
| 289 | + | return nil, false | |
| 290 | + | } | |
| 291 | + | fields := strings.Fields(query) | |
| 292 | + | if len(fields) == 0 || len(fields) > 2 { | |
| 293 | + | return nil, false | |
| 294 | + | } | |
| 295 | + | cmd, ok := ArgumentCommand(m.results, query) | |
| 296 | + | if !ok || cmd.Slug != "new-session" { | |
| 297 | + | return nil, false | |
| 298 | + | } | |
| 299 | + | name := "" | |
| 300 | + | if len(fields) == 2 { | |
| 301 | + | name = fields[1] | |
| 302 | + | if domain.ValidateSessionName(name) != nil { | |
| 303 | + | return nil, false | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | matches := make([]Match, len(m.createDestinations)) | |
| 307 | + | for i, destination := range m.createDestinations { | |
| 308 | + | matches[i] = newMatch(destination.withCreateSessionName(name), 0) | |
| 309 | + | } | |
| 310 | + | return matches, true | |
| 311 | + | } | |
| 312 | + | ||
| 229 | 313 | func requiredArgumentResults(results []Result) []Result { | |
| 230 | 314 | commands := make([]Result, 0, len(results)) | |
| 231 | 315 | for _, result := range results { | |
| @@ -315,15 +399,17 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 315 | 399 | } | |
| 316 | 400 | match := m.matches[idx] | |
| 317 | 401 | style := row | |
| 318 | - | if idx == m.selected { | |
| 402 | + | rowSelected := idx == m.selected && m.selectionMade | |
| 403 | + | if rowSelected { | |
| 319 | 404 | style = selection | |
| 320 | 405 | } | |
| 321 | 406 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 322 | 407 | if cmd, ok := match.Result.Command(); ok { | |
| 323 | - | m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected) | |
| 408 | + | m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected) | |
| 324 | 409 | continue | |
| 325 | 410 | } | |
| 326 | - | m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected) | |
| 411 | + | showFeedback := rowSelected || !m.selectionMade && idx == 0 | |
| 412 | + | m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback) | |
| 327 | 413 | } | |
| 328 | 414 | return frame | |
| 329 | 415 | } | |
| @@ -36,20 +36,24 @@ type RenderOptions struct { | |||||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | type Model struct { | 38 | type Model struct { | ||
| 39 | - | results []Result | 39 | + | results []Result |
| 40 | - | argumentResults []Result | 40 | + | argumentResults []Result |
| 41 | - | input ui.TextInput | 41 | + | createDestinations []Result |
| 42 | - | matches []Match | 42 | + | input ui.TextInput |
| 43 | - | selected int | 43 | + | matches []Match |
| 44 | - | scroll int | 44 | + | selected int |
| 45 | + | scroll int | |||
| 46 | + | destinationMode bool | |||
| 47 | + | selectionMade bool | |||
| 45 | } | 48 | } | ||
| 46 | 49 | ||||
| 47 | // New accepts typed immutable palette results. | 50 | // New accepts typed immutable palette results. | ||
| 48 | func New(results []Result) *Model { | 51 | func New(results []Result) *Model { | ||
| 49 | - | copied := append([]Result(nil), results...) | 52 | + | ordinary, destinations := splitResults(results) |
| 50 | m := &Model{ | 53 | m := &Model{ | ||
| 51 | - | results: copied, | 54 | + | results: ordinary, |
| 52 | - | argumentResults: requiredArgumentResults(copied), | 55 | + | argumentResults: requiredArgumentResults(ordinary), |
| 56 | + | createDestinations: destinations, | |||
| 53 | } | 57 | } | ||
| 54 | m.refresh() | 58 | m.refresh() | ||
| 55 | return m | 59 | return m | ||
| @@ -63,20 +67,32 @@ func (m *Model) ReplaceResults(results []Result) { | |||||
| 63 | if m == nil { | 67 | if m == nil { | ||
| 64 | return | 68 | return | ||
| 65 | } | 69 | } | ||
| 70 | + | wasDestinationMode := m.destinationMode | |||
| 71 | + | wasSelectionMade := m.selectionMade | |||
| 66 | selected, hadSelection := m.Selected() | 72 | selected, hadSelection := m.Selected() | ||
| 67 | - | m.results = append([]Result(nil), results...) | 73 | + | selectedDestination := hadSelection && selected.Kind() == ResultKindCreateSessionDestination |
| 74 | + | m.results, m.createDestinations = splitResults(results) | |||
| 68 | m.argumentResults = requiredArgumentResults(m.results) | 75 | m.argumentResults = requiredArgumentResults(m.results) | ||
| 69 | m.refresh() | 76 | m.refresh() | ||
| 70 | if !hadSelection { | 77 | if !hadSelection { | ||
| 78 | + | if m.destinationMode || wasDestinationMode && !wasSelectionMade { | |||
| 79 | + | m.selectionMade = false | |||
| 80 | + | } | |||
| 71 | return | 81 | return | ||
| 72 | } | 82 | } | ||
| 73 | for i, match := range m.matches { | 83 | for i, match := range m.matches { | ||
| 74 | if match.Result.sameTarget(selected) { | 84 | if match.Result.sameTarget(selected) { | ||
| 75 | m.selected = i | 85 | m.selected = i | ||
| 86 | + | m.selectionMade = true | |||
| 76 | m.clamp() | 87 | m.clamp() | ||
| 77 | return | 88 | return | ||
| 78 | } | 89 | } | ||
| 79 | } | 90 | } | ||
| 91 | + | if selectedDestination || m.destinationMode { | |||
| 92 | + | m.selectionMade = false | |||
| 93 | + | m.selected, m.scroll = 0, 0 | |||
| 94 | + | m.clamp() | |||
| 95 | + | } | |||
| 80 | } | 96 | } | ||
| 81 | 97 | ||||
| 82 | func DefaultRenderStyles() RenderStyles { | 98 | func DefaultRenderStyles() RenderStyles { | ||
| @@ -93,14 +109,14 @@ func DefaultRenderStyles() RenderStyles { | |||||
| 93 | func (m *Model) Insert(r rune) { | 109 | func (m *Model) Insert(r rune) { | ||
| 94 | if m != nil { | 110 | if m != nil { | ||
| 95 | m.input.Insert(r) | 111 | m.input.Insert(r) | ||
| 96 | - | m.selected, m.scroll = 0, 0 | 112 | + | m.selected, m.scroll, m.selectionMade = 0, 0, false |
| 97 | m.refresh() | 113 | m.refresh() | ||
| 98 | } | 114 | } | ||
| 99 | } | 115 | } | ||
| 100 | func (m *Model) Backspace() { | 116 | func (m *Model) Backspace() { | ||
| 101 | if m != nil && m.input.Value() != "" { | 117 | if m != nil && m.input.Value() != "" { | ||
| 102 | m.input.Backspace() | 118 | m.input.Backspace() | ||
| 103 | - | m.selected, m.scroll = 0, 0 | 119 | + | m.selected, m.scroll, m.selectionMade = 0, 0, false |
| 104 | m.refresh() | 120 | m.refresh() | ||
| 105 | } | 121 | } | ||
| 106 | } | 122 | } | ||
| @@ -112,13 +128,31 @@ func (m *Model) Query() string { | |||||
| 112 | return m.input.Value() | 128 | return m.input.Value() | ||
| 113 | } | 129 | } | ||
| 114 | func (m *Model) Up() { | 130 | func (m *Model) Up() { | ||
| 115 | - | if m != nil && m.selected > 0 { | 131 | + | if m == nil || len(m.matches) == 0 { |
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | if !m.selectionMade { | |||
| 135 | + | m.selected = len(m.matches) - 1 | |||
| 136 | + | m.selectionMade = true | |||
| 137 | + | m.clamp() | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | if m.selected > 0 { | |||
| 116 | m.selected-- | 141 | m.selected-- | ||
| 117 | m.clamp() | 142 | m.clamp() | ||
| 118 | } | 143 | } | ||
| 119 | } | 144 | } | ||
| 120 | func (m *Model) Down() { | 145 | func (m *Model) Down() { | ||
| 121 | - | if m != nil && m.selected+1 < len(m.matches) { | 146 | + | if m == nil || len(m.matches) == 0 { |
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | if !m.selectionMade { | |||
| 150 | + | m.selected = 0 | |||
| 151 | + | m.selectionMade = true | |||
| 152 | + | m.clamp() | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | if m.selected+1 < len(m.matches) { | |||
| 122 | m.selected++ | 156 | m.selected++ | ||
| 123 | m.clamp() | 157 | m.clamp() | ||
| 124 | } | 158 | } | ||
| @@ -126,7 +160,7 @@ func (m *Model) Down() { | |||||
| 126 | 160 | ||||
| 127 | // Selected returns the typed immutable target, not an executable command. | 161 | // Selected returns the typed immutable target, not an executable command. | ||
| 128 | func (m *Model) Selected() (Result, bool) { | 162 | func (m *Model) Selected() (Result, bool) { | ||
| 129 | - | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | 163 | + | if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) { |
| 130 | return Result{}, false | 164 | return Result{}, false | ||
| 131 | } | 165 | } | ||
| 132 | return m.matches[m.selected].Result, true | 166 | return m.matches[m.selected].Result, true | ||
| @@ -162,7 +196,7 @@ func (m *Model) CompleteSelected() bool { | |||||
| 162 | } | 196 | } | ||
| 163 | 197 | ||||
| 164 | m.input.SetValue(completed) | 198 | m.input.SetValue(completed) | ||
| 165 | - | m.selected, m.scroll = 0, 0 | 199 | + | m.selected, m.scroll, m.selectionMade = 0, 0, false |
| 166 | m.refresh() | 200 | m.refresh() | ||
| 167 | return true | 201 | return true | ||
| 168 | } | 202 | } | ||
| @@ -206,6 +240,18 @@ func (m *Model) Matches() []Match { | |||||
| 206 | 240 | ||||
| 207 | func (m *Model) refresh() { | 241 | func (m *Model) refresh() { | ||
| 208 | query := m.input.Value() | 242 | query := m.input.Value() | ||
| 243 | + | wasDestinationMode := m.destinationMode | |||
| 244 | + | if destinations, ok := m.cnsDestinationMatches(query); ok { | |||
| 245 | + | m.destinationMode = true | |||
| 246 | + | if !wasDestinationMode { | |||
| 247 | + | m.selectionMade = false | |||
| 248 | + | } | |||
| 249 | + | m.matches = destinations | |||
| 250 | + | m.clamp() | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | m.destinationMode = false | |||
| 254 | + | m.selectionMade = true | |||
| 209 | m.matches = Fuzzy(m.results, query) | 255 | m.matches = Fuzzy(m.results, query) | ||
| 210 | // Keep an exact command row selected over fuzzy session matches. | 256 | // Keep an exact command row selected over fuzzy session matches. | ||
| 211 | if result, ok := ExactCommandResult(m.results, query); ok { | 257 | if result, ok := ExactCommandResult(m.results, query); ok { | ||
| @@ -226,6 +272,44 @@ func (m *Model) refresh() { | |||||
| 226 | m.clamp() | 272 | m.clamp() | ||
| 227 | } | 273 | } | ||
| 228 | 274 | ||||
| 275 | + | func splitResults(results []Result) (ordinary, destinations []Result) { | |||
| 276 | + | ordinary = make([]Result, 0, len(results)) | |||
| 277 | + | for _, result := range results { | |||
| 278 | + | if result.Kind() == ResultKindCreateSessionDestination { | |||
| 279 | + | destinations = append(destinations, result) | |||
| 280 | + | continue | |||
| 281 | + | } | |||
| 282 | + | ordinary = append(ordinary, result) | |||
| 283 | + | } | |||
| 284 | + | return ordinary, destinations | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) { | |||
| 288 | + | if len(m.createDestinations) < 2 { | |||
| 289 | + | return nil, false | |||
| 290 | + | } | |||
| 291 | + | fields := strings.Fields(query) | |||
| 292 | + | if len(fields) == 0 || len(fields) > 2 { | |||
| 293 | + | return nil, false | |||
| 294 | + | } | |||
| 295 | + | cmd, ok := ArgumentCommand(m.results, query) | |||
| 296 | + | if !ok || cmd.Slug != "new-session" { | |||
| 297 | + | return nil, false | |||
| 298 | + | } | |||
| 299 | + | name := "" | |||
| 300 | + | if len(fields) == 2 { | |||
| 301 | + | name = fields[1] | |||
| 302 | + | if domain.ValidateSessionName(name) != nil { | |||
| 303 | + | return nil, false | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | matches := make([]Match, len(m.createDestinations)) | |||
| 307 | + | for i, destination := range m.createDestinations { | |||
| 308 | + | matches[i] = newMatch(destination.withCreateSessionName(name), 0) | |||
| 309 | + | } | |||
| 310 | + | return matches, true | |||
| 311 | + | } | |||
| 312 | + | ||||
| 229 | func requiredArgumentResults(results []Result) []Result { | 313 | func requiredArgumentResults(results []Result) []Result { | ||
| 230 | commands := make([]Result, 0, len(results)) | 314 | commands := make([]Result, 0, len(results)) | ||
| 231 | for _, result := range results { | 315 | for _, result := range results { | ||
| @@ -315,15 +399,17 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||||
| 315 | } | 399 | } | ||
| 316 | match := m.matches[idx] | 400 | match := m.matches[idx] | ||
| 317 | style := row | 401 | style := row | ||
| 318 | - | if idx == m.selected { | 402 | + | rowSelected := idx == m.selected && m.selectionMade |
| 403 | + | if rowSelected { | |||
| 319 | style = selection | 404 | style = selection | ||
| 320 | } | 405 | } | ||
| 321 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | 406 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | ||
| 322 | if cmd, ok := match.Result.Command(); ok { | 407 | if cmd, ok := match.Result.Command(); ok { | ||
| 323 | - | m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected) | 408 | + | m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected) |
| 324 | continue | 409 | continue | ||
| 325 | } | 410 | } | ||
| 326 | - | m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected) | 411 | + | showFeedback := rowSelected || !m.selectionMade && idx == 0 |
| 412 | + | m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback) | |||
| 327 | } | 413 | } | ||
| 328 | return frame | 414 | return frame | ||
| 329 | } | 415 | } | ||
M internal/usecase/palette/model_test.go +128 -0
| @@ -7,11 +7,139 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/command" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 16 | + | func cnsCommand(t *testing.T) command.Command { | |
| 17 | + | t.Helper() | |
| 18 | + | for _, candidate := range command.PaletteRegistry() { | |
| 19 | + | if candidate.Slug == "new-session" { | |
| 20 | + | return candidate | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | require.FailNow(t, "new-session command missing from palette registry") | |
| 24 | + | return command.Command{} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestCNSDestinationModeRequiresExplicitSelection(t *testing.T) { | |
| 28 | + | cns := cnsCommand(t) | |
| 29 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |
| 30 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |
| 31 | + | ||
| 32 | + | tests := []struct { | |
| 33 | + | name string | |
| 34 | + | query string | |
| 35 | + | navigate func(*Model) | |
| 36 | + | wantText string | |
| 37 | + | wantSelect bool | |
| 38 | + | wantFirst bool | |
| 39 | + | }{ | |
| 40 | + | {name: "plain requires selection", query: "CNS", wantText: "Create session locally…", wantFirst: true}, | |
| 41 | + | {name: "named requires selection", query: "CNS example", wantText: "Create session “example”", wantFirst: true}, | |
| 42 | + | {name: "down chooses first", query: "CNS example", navigate: (*Model).Down, wantText: "Create session “example”", wantSelect: true, wantFirst: true}, | |
| 43 | + | {name: "up chooses last", query: "CNS example", navigate: (*Model).Up, wantText: "Create session “example” on host-a", wantSelect: true}, | |
| 44 | + | } | |
| 45 | + | for _, tt := range tests { | |
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |
| 47 | + | m := New([]Result{NewCommandResult(cns), local, remote}) | |
| 48 | + | for _, r := range tt.query { | |
| 49 | + | m.Insert(r) | |
| 50 | + | } | |
| 51 | + | require.Len(t, m.Matches(), 2) | |
| 52 | + | if tt.wantFirst { | |
| 53 | + | require.Equal(t, tt.wantText, m.Matches()[0].Result.DisplayText()) | |
| 54 | + | } | |
| 55 | + | if tt.navigate != nil { | |
| 56 | + | tt.navigate(m) | |
| 57 | + | } | |
| 58 | + | selected, ok := m.Selected() | |
| 59 | + | require.Equal(t, tt.wantSelect, ok) | |
| 60 | + | if ok { | |
| 61 | + | require.Equal(t, tt.wantText, selected.DisplayText()) | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestCNSDestinationRefreshPreservesOnlyExplicitSelection(t *testing.T) { | |
| 68 | + | cns := cnsCommand(t) | |
| 69 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |
| 70 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |
| 71 | + | m := New([]Result{NewCommandResult(cns), local, remote}) | |
| 72 | + | for _, r := range "CNS example" { | |
| 73 | + | m.Insert(r) | |
| 74 | + | } | |
| 75 | + | m.ReplaceResults([]Result{NewCommandResult(cns), local, remote}) | |
| 76 | + | _, ok := m.Selected() | |
| 77 | + | require.False(t, ok) | |
| 78 | + | ||
| 79 | + | m.Down() | |
| 80 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote, local}) | |
| 81 | + | selected, ok := m.Selected() | |
| 82 | + | require.True(t, ok) | |
| 83 | + | _, kind, _, _, _, ok := selected.CreateSessionDestination() | |
| 84 | + | require.True(t, ok) | |
| 85 | + | require.Equal(t, CreateSessionOnLocalRoute, kind) | |
| 86 | + | ||
| 87 | + | remoteB := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-b", "host-b", protocol.RouteRef{}, 0) | |
| 88 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB}) | |
| 89 | + | _, ok = m.Selected() | |
| 90 | + | require.False(t, ok, "removing the explicit destination must not select its replacement") | |
| 91 | + | ||
| 92 | + | m.Down() | |
| 93 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote}) | |
| 94 | + | _, ok = m.Selected() | |
| 95 | + | require.False(t, ok, "two-to-one refresh must not promote the generic command") | |
| 96 | + | ||
| 97 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB}) | |
| 98 | + | m.Down() | |
| 99 | + | m.ReplaceResults([]Result{NewCommandResult(cns)}) | |
| 100 | + | _, ok = m.Selected() | |
| 101 | + | require.False(t, ok, "two-to-zero refresh must not promote the generic command") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestCNSUnselectedDestinationCollapseDoesNotPromoteCommand(t *testing.T) { | |
| 105 | + | cns := cnsCommand(t) | |
| 106 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |
| 107 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |
| 108 | + | newModel := func() *Model { | |
| 109 | + | m := New([]Result{NewCommandResult(cns), local, remote}) | |
| 110 | + | for _, r := range "CNS example" { | |
| 111 | + | m.Insert(r) | |
| 112 | + | } | |
| 113 | + | return m | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | m := newModel() | |
| 117 | + | m.ReplaceResults([]Result{NewCommandResult(cns), local}) | |
| 118 | + | _, ok := m.Selected() | |
| 119 | + | require.False(t, ok, "unselected two-to-one refresh must not promote CNS") | |
| 120 | + | ||
| 121 | + | m = newModel() | |
| 122 | + | m.ReplaceResults([]Result{NewCommandResult(cns)}) | |
| 123 | + | _, ok = m.Selected() | |
| 124 | + | require.False(t, ok, "unselected two-to-zero refresh must not promote CNS") | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestCNSDestinationRefreshDoesNotPromoteCommandSelection(t *testing.T) { | |
| 128 | + | cns := cnsCommand(t) | |
| 129 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |
| 130 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |
| 131 | + | m := New([]Result{NewCommandResult(cns), local}) | |
| 132 | + | for _, r := range "CNS" { | |
| 133 | + | m.Insert(r) | |
| 134 | + | } | |
| 135 | + | _, ok := m.Selected() | |
| 136 | + | require.True(t, ok, "single-daemon CNS keeps ordinary command selection") | |
| 137 | + | ||
| 138 | + | m.ReplaceResults([]Result{NewCommandResult(cns), local, remote}) | |
| 139 | + | _, ok = m.Selected() | |
| 140 | + | require.False(t, ok, "entering multi-destination mode requires fresh navigation") | |
| 141 | + | } | |
| 142 | + | ||
| 15 | 143 | func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) { | |
| 16 | 144 | result := func(endpoint string, lifecycle byte) Result { | |
| 17 | 145 | key := domain.RemoteSessionKey{ | |
| @@ -7,11 +7,139 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | "github.com/bnema/vev/internal/usecase/command" | 11 | "github.com/bnema/vev/internal/usecase/command" | ||
| 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 16 | + | func cnsCommand(t *testing.T) command.Command { | |||
| 17 | + | t.Helper() | |||
| 18 | + | for _, candidate := range command.PaletteRegistry() { | |||
| 19 | + | if candidate.Slug == "new-session" { | |||
| 20 | + | return candidate | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | require.FailNow(t, "new-session command missing from palette registry") | |||
| 24 | + | return command.Command{} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestCNSDestinationModeRequiresExplicitSelection(t *testing.T) { | |||
| 28 | + | cns := cnsCommand(t) | |||
| 29 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |||
| 30 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |||
| 31 | + | ||||
| 32 | + | tests := []struct { | |||
| 33 | + | name string | |||
| 34 | + | query string | |||
| 35 | + | navigate func(*Model) | |||
| 36 | + | wantText string | |||
| 37 | + | wantSelect bool | |||
| 38 | + | wantFirst bool | |||
| 39 | + | }{ | |||
| 40 | + | {name: "plain requires selection", query: "CNS", wantText: "Create session locally…", wantFirst: true}, | |||
| 41 | + | {name: "named requires selection", query: "CNS example", wantText: "Create session “example”", wantFirst: true}, | |||
| 42 | + | {name: "down chooses first", query: "CNS example", navigate: (*Model).Down, wantText: "Create session “example”", wantSelect: true, wantFirst: true}, | |||
| 43 | + | {name: "up chooses last", query: "CNS example", navigate: (*Model).Up, wantText: "Create session “example” on host-a", wantSelect: true}, | |||
| 44 | + | } | |||
| 45 | + | for _, tt := range tests { | |||
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 47 | + | m := New([]Result{NewCommandResult(cns), local, remote}) | |||
| 48 | + | for _, r := range tt.query { | |||
| 49 | + | m.Insert(r) | |||
| 50 | + | } | |||
| 51 | + | require.Len(t, m.Matches(), 2) | |||
| 52 | + | if tt.wantFirst { | |||
| 53 | + | require.Equal(t, tt.wantText, m.Matches()[0].Result.DisplayText()) | |||
| 54 | + | } | |||
| 55 | + | if tt.navigate != nil { | |||
| 56 | + | tt.navigate(m) | |||
| 57 | + | } | |||
| 58 | + | selected, ok := m.Selected() | |||
| 59 | + | require.Equal(t, tt.wantSelect, ok) | |||
| 60 | + | if ok { | |||
| 61 | + | require.Equal(t, tt.wantText, selected.DisplayText()) | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestCNSDestinationRefreshPreservesOnlyExplicitSelection(t *testing.T) { | |||
| 68 | + | cns := cnsCommand(t) | |||
| 69 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |||
| 70 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |||
| 71 | + | m := New([]Result{NewCommandResult(cns), local, remote}) | |||
| 72 | + | for _, r := range "CNS example" { | |||
| 73 | + | m.Insert(r) | |||
| 74 | + | } | |||
| 75 | + | m.ReplaceResults([]Result{NewCommandResult(cns), local, remote}) | |||
| 76 | + | _, ok := m.Selected() | |||
| 77 | + | require.False(t, ok) | |||
| 78 | + | ||||
| 79 | + | m.Down() | |||
| 80 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote, local}) | |||
| 81 | + | selected, ok := m.Selected() | |||
| 82 | + | require.True(t, ok) | |||
| 83 | + | _, kind, _, _, _, ok := selected.CreateSessionDestination() | |||
| 84 | + | require.True(t, ok) | |||
| 85 | + | require.Equal(t, CreateSessionOnLocalRoute, kind) | |||
| 86 | + | ||||
| 87 | + | remoteB := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-b", "host-b", protocol.RouteRef{}, 0) | |||
| 88 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB}) | |||
| 89 | + | _, ok = m.Selected() | |||
| 90 | + | require.False(t, ok, "removing the explicit destination must not select its replacement") | |||
| 91 | + | ||||
| 92 | + | m.Down() | |||
| 93 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote}) | |||
| 94 | + | _, ok = m.Selected() | |||
| 95 | + | require.False(t, ok, "two-to-one refresh must not promote the generic command") | |||
| 96 | + | ||||
| 97 | + | m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB}) | |||
| 98 | + | m.Down() | |||
| 99 | + | m.ReplaceResults([]Result{NewCommandResult(cns)}) | |||
| 100 | + | _, ok = m.Selected() | |||
| 101 | + | require.False(t, ok, "two-to-zero refresh must not promote the generic command") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestCNSUnselectedDestinationCollapseDoesNotPromoteCommand(t *testing.T) { | |||
| 105 | + | cns := cnsCommand(t) | |||
| 106 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |||
| 107 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |||
| 108 | + | newModel := func() *Model { | |||
| 109 | + | m := New([]Result{NewCommandResult(cns), local, remote}) | |||
| 110 | + | for _, r := range "CNS example" { | |||
| 111 | + | m.Insert(r) | |||
| 112 | + | } | |||
| 113 | + | return m | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | m := newModel() | |||
| 117 | + | m.ReplaceResults([]Result{NewCommandResult(cns), local}) | |||
| 118 | + | _, ok := m.Selected() | |||
| 119 | + | require.False(t, ok, "unselected two-to-one refresh must not promote CNS") | |||
| 120 | + | ||||
| 121 | + | m = newModel() | |||
| 122 | + | m.ReplaceResults([]Result{NewCommandResult(cns)}) | |||
| 123 | + | _, ok = m.Selected() | |||
| 124 | + | require.False(t, ok, "unselected two-to-zero refresh must not promote CNS") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestCNSDestinationRefreshDoesNotPromoteCommandSelection(t *testing.T) { | |||
| 128 | + | cns := cnsCommand(t) | |||
| 129 | + | local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1) | |||
| 130 | + | remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0) | |||
| 131 | + | m := New([]Result{NewCommandResult(cns), local}) | |||
| 132 | + | for _, r := range "CNS" { | |||
| 133 | + | m.Insert(r) | |||
| 134 | + | } | |||
| 135 | + | _, ok := m.Selected() | |||
| 136 | + | require.True(t, ok, "single-daemon CNS keeps ordinary command selection") | |||
| 137 | + | ||||
| 138 | + | m.ReplaceResults([]Result{NewCommandResult(cns), local, remote}) | |||
| 139 | + | _, ok = m.Selected() | |||
| 140 | + | require.False(t, ok, "entering multi-destination mode requires fresh navigation") | |||
| 141 | + | } | |||
| 142 | + | ||||
| 15 | func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) { | 143 | func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) { | ||
| 16 | result := func(endpoint string, lifecycle byte) Result { | 144 | result := func(endpoint string, lifecycle byte) Result { | ||
| 17 | key := domain.RemoteSessionKey{ | 145 | key := domain.RemoteSessionKey{ | ||
M internal/usecase/palette/result.go +74 -5
| @@ -17,16 +17,27 @@ const ( | |||
| 17 | 17 | ResultKindStoppedSession | |
| 18 | 18 | ResultKindRemoteSession | |
| 19 | 19 | ResultKindRecentRoute | |
| 20 | + | ResultKindCreateSessionDestination | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // CreateSessionDestinationKind identifies where a CNS result creates a session. | |
| 24 | + | type CreateSessionDestinationKind uint8 | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | CreateSessionOnServingDaemon CreateSessionDestinationKind = iota + 1 | |
| 28 | + | CreateSessionOnLocalRoute | |
| 29 | + | CreateSessionOnRemoteHost | |
| 20 | 30 | ) | |
| 21 | 31 | ||
| 22 | 32 | // Result is an immutable palette target. Its kind is the sole discriminator | |
| 23 | 33 | // for its private command, local-session, remote-session, or route payload. | |
| 24 | 34 | type Result struct { | |
| 25 | - | kind ResultKind | |
| 26 | - | command command.Command | |
| 27 | - | session sessionPayload | |
| 28 | - | remoteSession remoteSessionPayload | |
| 29 | - | route routePayload | |
| 35 | + | kind ResultKind | |
| 36 | + | command command.Command | |
| 37 | + | session sessionPayload | |
| 38 | + | remoteSession remoteSessionPayload | |
| 39 | + | route routePayload | |
| 40 | + | createDestination createSessionDestinationPayload | |
| 30 | 41 | } | |
| 31 | 42 | ||
| 32 | 43 | type sessionPayload struct { | |
| @@ -46,6 +57,15 @@ type routePayload struct { | |||
| 46 | 57 | action protocol.RouteNavigationAction | |
| 47 | 58 | } | |
| 48 | 59 | ||
| 60 | + | type createSessionDestinationPayload struct { | |
| 61 | + | name string | |
| 62 | + | kind CreateSessionDestinationKind | |
| 63 | + | displayOrigin string | |
| 64 | + | endpoint string | |
| 65 | + | route protocol.RouteRef | |
| 66 | + | snapshotGen uint64 | |
| 67 | + | } | |
| 68 | + | ||
| 49 | 69 | // NewCommandResult creates a static command palette target. | |
| 50 | 70 | func NewCommandResult(cmd command.Command) Result { | |
| 51 | 71 | return Result{kind: ResultKindCommand, command: cmd} | |
| @@ -102,6 +122,19 @@ func NewRecentRouteResult(label string, action protocol.RouteNavigationAction) R | |||
| 102 | 122 | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | |
| 103 | 123 | } | |
| 104 | 124 | ||
| 125 | + | // NewCreateSessionDestination creates an immutable destination template. The | |
| 126 | + | // palette binds the current validated CNS name without parsing rendered text. | |
| 127 | + | func NewCreateSessionDestination(kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, snapshotGeneration uint64) Result { | |
| 128 | + | return Result{kind: ResultKindCreateSessionDestination, createDestination: createSessionDestinationPayload{ | |
| 129 | + | kind: kind, displayOrigin: displayOrigin, endpoint: endpoint, route: route, snapshotGen: snapshotGeneration, | |
| 130 | + | }} | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (r Result) withCreateSessionName(name string) Result { | |
| 134 | + | r.createDestination.name = name | |
| 135 | + | return r | |
| 136 | + | } | |
| 137 | + | ||
| 105 | 138 | func (r Result) Kind() ResultKind { return r.kind } | |
| 106 | 139 | ||
| 107 | 140 | func (r Result) sameTarget(other Result) bool { | |
| @@ -117,6 +150,11 @@ func (r Result) sameTarget(other Result) bool { | |||
| 117 | 150 | return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target | |
| 118 | 151 | case ResultKindRecentRoute: | |
| 119 | 152 | return r.route.action == other.route.action | |
| 153 | + | case ResultKindCreateSessionDestination: | |
| 154 | + | return r.createDestination.kind == other.createDestination.kind && | |
| 155 | + | r.createDestination.endpoint == other.createDestination.endpoint && | |
| 156 | + | r.createDestination.route == other.createDestination.route && | |
| 157 | + | r.createDestination.snapshotGen == other.createDestination.snapshotGen | |
| 120 | 158 | default: | |
| 121 | 159 | return false | |
| 122 | 160 | } | |
| @@ -132,9 +170,27 @@ func (r Result) DisplayText() string { | |||
| 132 | 170 | if r.kind == ResultKindRecentRoute { | |
| 133 | 171 | return activeSessionDisplayPrefix + r.route.label | |
| 134 | 172 | } | |
| 173 | + | if r.kind == ResultKindCreateSessionDestination { | |
| 174 | + | return r.createSessionDestinationDisplay() | |
| 175 | + | } | |
| 135 | 176 | return r.sessionDisplayPrefix() + r.session.display | |
| 136 | 177 | } | |
| 137 | 178 | ||
| 179 | + | func (r Result) createSessionDestinationDisplay() string { | |
| 180 | + | destination := r.createDestination | |
| 181 | + | if destination.name == "" { | |
| 182 | + | if destination.kind == CreateSessionOnLocalRoute { | |
| 183 | + | return "Create session locally…" | |
| 184 | + | } | |
| 185 | + | return "Create session on " + destination.displayOrigin + "…" | |
| 186 | + | } | |
| 187 | + | text := "Create session “" + destination.name + "”" | |
| 188 | + | if destination.kind != CreateSessionOnLocalRoute { | |
| 189 | + | text += " on " + destination.displayOrigin | |
| 190 | + | } | |
| 191 | + | return text | |
| 192 | + | } | |
| 193 | + | ||
| 138 | 194 | func (r Result) sessionDisplayPrefix() string { | |
| 139 | 195 | if r.kind == ResultKindStoppedSession { | |
| 140 | 196 | return stoppedSessionDisplayPrefix | |
| @@ -191,6 +247,19 @@ func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) { | |||
| 191 | 247 | return r.route.action, r.kind == ResultKindRecentRoute | |
| 192 | 248 | } | |
| 193 | 249 | ||
| 250 | + | // CreateSessionDestination returns structured creation authority only for CNS | |
| 251 | + | // destination rows. Labels are never parsed back into these values. | |
| 252 | + | func (r Result) CreateSessionDestination() (name string, kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, ok bool) { | |
| 253 | + | d := r.createDestination | |
| 254 | + | return d.name, d.kind, d.displayOrigin, d.endpoint, d.route, r.kind == ResultKindCreateSessionDestination | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // CreateSessionSnapshotGeneration returns the route snapshot generation bound | |
| 258 | + | // to a local destination. Other destination kinds carry zero. | |
| 259 | + | func (r Result) CreateSessionSnapshotGeneration() (uint64, bool) { | |
| 260 | + | return r.createDestination.snapshotGen, r.kind == ResultKindCreateSessionDestination | |
| 261 | + | } | |
| 262 | + | ||
| 194 | 263 | const ( | |
| 195 | 264 | activeSessionDisplayPrefix = "Switch to session " | |
| 196 | 265 | stoppedSessionDisplayPrefix = "Resume session " | |
| @@ -17,16 +17,27 @@ const ( | |||||
| 17 | ResultKindStoppedSession | 17 | ResultKindStoppedSession | ||
| 18 | ResultKindRemoteSession | 18 | ResultKindRemoteSession | ||
| 19 | ResultKindRecentRoute | 19 | ResultKindRecentRoute | ||
| 20 | + | ResultKindCreateSessionDestination | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // CreateSessionDestinationKind identifies where a CNS result creates a session. | |||
| 24 | + | type CreateSessionDestinationKind uint8 | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | CreateSessionOnServingDaemon CreateSessionDestinationKind = iota + 1 | |||
| 28 | + | CreateSessionOnLocalRoute | |||
| 29 | + | CreateSessionOnRemoteHost | |||
| 20 | ) | 30 | ) | ||
| 21 | 31 | ||||
| 22 | // Result is an immutable palette target. Its kind is the sole discriminator | 32 | // Result is an immutable palette target. Its kind is the sole discriminator | ||
| 23 | // for its private command, local-session, remote-session, or route payload. | 33 | // for its private command, local-session, remote-session, or route payload. | ||
| 24 | type Result struct { | 34 | type Result struct { | ||
| 25 | - | kind ResultKind | 35 | + | kind ResultKind |
| 26 | - | command command.Command | 36 | + | command command.Command |
| 27 | - | session sessionPayload | 37 | + | session sessionPayload |
| 28 | - | remoteSession remoteSessionPayload | 38 | + | remoteSession remoteSessionPayload |
| 29 | - | route routePayload | 39 | + | route routePayload |
| 40 | + | createDestination createSessionDestinationPayload | |||
| 30 | } | 41 | } | ||
| 31 | 42 | ||||
| 32 | type sessionPayload struct { | 43 | type sessionPayload struct { | ||
| @@ -46,6 +57,15 @@ type routePayload struct { | |||||
| 46 | action protocol.RouteNavigationAction | 57 | action protocol.RouteNavigationAction | ||
| 47 | } | 58 | } | ||
| 48 | 59 | ||||
| 60 | + | type createSessionDestinationPayload struct { | |||
| 61 | + | name string | |||
| 62 | + | kind CreateSessionDestinationKind | |||
| 63 | + | displayOrigin string | |||
| 64 | + | endpoint string | |||
| 65 | + | route protocol.RouteRef | |||
| 66 | + | snapshotGen uint64 | |||
| 67 | + | } | |||
| 68 | + | ||||
| 49 | // NewCommandResult creates a static command palette target. | 69 | // NewCommandResult creates a static command palette target. | ||
| 50 | func NewCommandResult(cmd command.Command) Result { | 70 | func NewCommandResult(cmd command.Command) Result { | ||
| 51 | return Result{kind: ResultKindCommand, command: cmd} | 71 | return Result{kind: ResultKindCommand, command: cmd} | ||
| @@ -102,6 +122,19 @@ func NewRecentRouteResult(label string, action protocol.RouteNavigationAction) R | |||||
| 102 | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | 122 | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | ||
| 103 | } | 123 | } | ||
| 104 | 124 | ||||
| 125 | + | // NewCreateSessionDestination creates an immutable destination template. The | |||
| 126 | + | // palette binds the current validated CNS name without parsing rendered text. | |||
| 127 | + | func NewCreateSessionDestination(kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, snapshotGeneration uint64) Result { | |||
| 128 | + | return Result{kind: ResultKindCreateSessionDestination, createDestination: createSessionDestinationPayload{ | |||
| 129 | + | kind: kind, displayOrigin: displayOrigin, endpoint: endpoint, route: route, snapshotGen: snapshotGeneration, | |||
| 130 | + | }} | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (r Result) withCreateSessionName(name string) Result { | |||
| 134 | + | r.createDestination.name = name | |||
| 135 | + | return r | |||
| 136 | + | } | |||
| 137 | + | ||||
| 105 | func (r Result) Kind() ResultKind { return r.kind } | 138 | func (r Result) Kind() ResultKind { return r.kind } | ||
| 106 | 139 | ||||
| 107 | func (r Result) sameTarget(other Result) bool { | 140 | func (r Result) sameTarget(other Result) bool { | ||
| @@ -117,6 +150,11 @@ func (r Result) sameTarget(other Result) bool { | |||||
| 117 | return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target | 150 | return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target | ||
| 118 | case ResultKindRecentRoute: | 151 | case ResultKindRecentRoute: | ||
| 119 | return r.route.action == other.route.action | 152 | return r.route.action == other.route.action | ||
| 153 | + | case ResultKindCreateSessionDestination: | |||
| 154 | + | return r.createDestination.kind == other.createDestination.kind && | |||
| 155 | + | r.createDestination.endpoint == other.createDestination.endpoint && | |||
| 156 | + | r.createDestination.route == other.createDestination.route && | |||
| 157 | + | r.createDestination.snapshotGen == other.createDestination.snapshotGen | |||
| 120 | default: | 158 | default: | ||
| 121 | return false | 159 | return false | ||
| 122 | } | 160 | } | ||
| @@ -132,9 +170,27 @@ func (r Result) DisplayText() string { | |||||
| 132 | if r.kind == ResultKindRecentRoute { | 170 | if r.kind == ResultKindRecentRoute { | ||
| 133 | return activeSessionDisplayPrefix + r.route.label | 171 | return activeSessionDisplayPrefix + r.route.label | ||
| 134 | } | 172 | } | ||
| 173 | + | if r.kind == ResultKindCreateSessionDestination { | |||
| 174 | + | return r.createSessionDestinationDisplay() | |||
| 175 | + | } | |||
| 135 | return r.sessionDisplayPrefix() + r.session.display | 176 | return r.sessionDisplayPrefix() + r.session.display | ||
| 136 | } | 177 | } | ||
| 137 | 178 | ||||
| 179 | + | func (r Result) createSessionDestinationDisplay() string { | |||
| 180 | + | destination := r.createDestination | |||
| 181 | + | if destination.name == "" { | |||
| 182 | + | if destination.kind == CreateSessionOnLocalRoute { | |||
| 183 | + | return "Create session locally…" | |||
| 184 | + | } | |||
| 185 | + | return "Create session on " + destination.displayOrigin + "…" | |||
| 186 | + | } | |||
| 187 | + | text := "Create session “" + destination.name + "”" | |||
| 188 | + | if destination.kind != CreateSessionOnLocalRoute { | |||
| 189 | + | text += " on " + destination.displayOrigin | |||
| 190 | + | } | |||
| 191 | + | return text | |||
| 192 | + | } | |||
| 193 | + | ||||
| 138 | func (r Result) sessionDisplayPrefix() string { | 194 | func (r Result) sessionDisplayPrefix() string { | ||
| 139 | if r.kind == ResultKindStoppedSession { | 195 | if r.kind == ResultKindStoppedSession { | ||
| 140 | return stoppedSessionDisplayPrefix | 196 | return stoppedSessionDisplayPrefix | ||
| @@ -191,6 +247,19 @@ func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) { | |||||
| 191 | return r.route.action, r.kind == ResultKindRecentRoute | 247 | return r.route.action, r.kind == ResultKindRecentRoute | ||
| 192 | } | 248 | } | ||
| 193 | 249 | ||||
| 250 | + | // CreateSessionDestination returns structured creation authority only for CNS | |||
| 251 | + | // destination rows. Labels are never parsed back into these values. | |||
| 252 | + | func (r Result) CreateSessionDestination() (name string, kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, ok bool) { | |||
| 253 | + | d := r.createDestination | |||
| 254 | + | return d.name, d.kind, d.displayOrigin, d.endpoint, d.route, r.kind == ResultKindCreateSessionDestination | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // CreateSessionSnapshotGeneration returns the route snapshot generation bound | |||
| 258 | + | // to a local destination. Other destination kinds carry zero. | |||
| 259 | + | func (r Result) CreateSessionSnapshotGeneration() (uint64, bool) { | |||
| 260 | + | return r.createDestination.snapshotGen, r.kind == ResultKindCreateSessionDestination | |||
| 261 | + | } | |||
| 262 | + | ||||
| 194 | const ( | 263 | const ( | ||
| 195 | activeSessionDisplayPrefix = "Switch to session " | 264 | activeSessionDisplayPrefix = "Switch to session " | ||
| 196 | stoppedSessionDisplayPrefix = "Resume session " | 265 | stoppedSessionDisplayPrefix = "Resume session " | ||