Merge pull request #196 from bnema/refactor/unified-route-handoff
refactor(routes): unify session handoffs
17 files · +269 -46
M internal/app/run.go +3 -3
| @@ -857,7 +857,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 857 | 857 | factory := deps.remoteDialerFactory | |
| 858 | 858 | var remoteSelection *domain.RemoteSessionTarget | |
| 859 | 859 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | |
| 860 | - | remoteDisplayOrigin := remoteTarget | |
| 860 | + | remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget) | |
| 861 | 861 | routeOrigin := ports.RouteOriginLocal | |
| 862 | 862 | routeOriginKey := "local" | |
| 863 | 863 | if remoteTarget != "" { | |
| @@ -897,7 +897,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 897 | 897 | return nil, client.AttachRequest{}, err | |
| 898 | 898 | } | |
| 899 | 899 | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | |
| 900 | - | displayOrigin := target.Endpoint | |
| 900 | + | displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint) | |
| 901 | 901 | if selection != nil { | |
| 902 | 902 | displayOrigin = selection.DisplayOrigin | |
| 903 | 903 | } | |
| @@ -978,7 +978,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 978 | 978 | } | |
| 979 | 979 | handoffAttempts++ | |
| 980 | 980 | remoteTarget = handoffErr.Target.Endpoint | |
| 981 | - | remoteDisplayOrigin = remoteTarget | |
| 981 | + | remoteDisplayOrigin = domain.RemoteDisplayOrigin(remoteTarget) | |
| 982 | 982 | routeOrigin = ports.RouteOriginDiscovery | |
| 983 | 983 | routeOriginKey = remoteTarget | |
| 984 | 984 | name = handoffErr.Target.Session | |
| @@ -857,7 +857,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 857 | factory := deps.remoteDialerFactory | 857 | factory := deps.remoteDialerFactory | ||
| 858 | var remoteSelection *domain.RemoteSessionTarget | 858 | var remoteSelection *domain.RemoteSessionTarget | ||
| 859 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | 859 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | ||
| 860 | - | remoteDisplayOrigin := remoteTarget | 860 | + | remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget) |
| 861 | routeOrigin := ports.RouteOriginLocal | 861 | routeOrigin := ports.RouteOriginLocal | ||
| 862 | routeOriginKey := "local" | 862 | routeOriginKey := "local" | ||
| 863 | if remoteTarget != "" { | 863 | if remoteTarget != "" { | ||
| @@ -897,7 +897,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 897 | return nil, client.AttachRequest{}, err | 897 | return nil, client.AttachRequest{}, err | ||
| 898 | } | 898 | } | ||
| 899 | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | 899 | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | ||
| 900 | - | displayOrigin := target.Endpoint | 900 | + | displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint) |
| 901 | if selection != nil { | 901 | if selection != nil { | ||
| 902 | displayOrigin = selection.DisplayOrigin | 902 | displayOrigin = selection.DisplayOrigin | ||
| 903 | } | 903 | } | ||
| @@ -978,7 +978,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 978 | } | 978 | } | ||
| 979 | handoffAttempts++ | 979 | handoffAttempts++ | ||
| 980 | remoteTarget = handoffErr.Target.Endpoint | 980 | remoteTarget = handoffErr.Target.Endpoint | ||
| 981 | - | remoteDisplayOrigin = remoteTarget | 981 | + | remoteDisplayOrigin = domain.RemoteDisplayOrigin(remoteTarget) |
| 982 | routeOrigin = ports.RouteOriginDiscovery | 982 | routeOrigin = ports.RouteOriginDiscovery | ||
| 983 | routeOriginKey = remoteTarget | 983 | routeOriginKey = remoteTarget | ||
| 984 | name = handoffErr.Target.Session | 984 | name = handoffErr.Target.Session | ||
M internal/domain/remote_session.go +12 -4
| @@ -37,14 +37,22 @@ func (k RemoteSessionKey) ID() SessionID { | |||
| 37 | 37 | return SessionID("remote:" + host + "." + name) | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | // RemoteDisplayOrigin returns the host-facing portion of a remote endpoint. | |
| 41 | + | // Routing retains the full user@host target; presentation consistently omits | |
| 42 | + | // the login prefix regardless of whether the route came from the CLI or a | |
| 43 | + | // discovered picker entry. | |
| 44 | + | func RemoteDisplayOrigin(endpoint string) string { | |
| 45 | + | if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 { | |
| 46 | + | return endpoint[i+1:] | |
| 47 | + | } | |
| 48 | + | return endpoint | |
| 49 | + | } | |
| 50 | + | ||
| 40 | 51 | // Display returns the presentation label for this remote session. | |
| 41 | 52 | func (k RemoteSessionKey) Display() string { | |
| 42 | 53 | origin := k.DisplayOrigin | |
| 43 | 54 | if origin == "" { | |
| 44 | - | origin = k.Host | |
| 45 | - | if i := strings.LastIndexByte(origin, '@'); i > 0 && i < len(origin)-1 { | |
| 46 | - | origin = origin[i+1:] | |
| 47 | - | } | |
| 55 | + | origin = RemoteDisplayOrigin(k.Host) | |
| 48 | 56 | } | |
| 49 | 57 | return k.Name + "@" + origin | |
| 50 | 58 | } | |
| @@ -37,14 +37,22 @@ func (k RemoteSessionKey) ID() SessionID { | |||||
| 37 | return SessionID("remote:" + host + "." + name) | 37 | return SessionID("remote:" + host + "." + name) | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | + | // RemoteDisplayOrigin returns the host-facing portion of a remote endpoint. | |||
| 41 | + | // Routing retains the full user@host target; presentation consistently omits | |||
| 42 | + | // the login prefix regardless of whether the route came from the CLI or a | |||
| 43 | + | // discovered picker entry. | |||
| 44 | + | func RemoteDisplayOrigin(endpoint string) string { | |||
| 45 | + | if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 { | |||
| 46 | + | return endpoint[i+1:] | |||
| 47 | + | } | |||
| 48 | + | return endpoint | |||
| 49 | + | } | |||
| 50 | + | ||||
| 40 | // Display returns the presentation label for this remote session. | 51 | // Display returns the presentation label for this remote session. | ||
| 41 | func (k RemoteSessionKey) Display() string { | 52 | func (k RemoteSessionKey) Display() string { | ||
| 42 | origin := k.DisplayOrigin | 53 | origin := k.DisplayOrigin | ||
| 43 | if origin == "" { | 54 | if origin == "" { | ||
| 44 | - | origin = k.Host | 55 | + | origin = RemoteDisplayOrigin(k.Host) |
| 45 | - | if i := strings.LastIndexByte(origin, '@'); i > 0 && i < len(origin)-1 { | |||
| 46 | - | origin = origin[i+1:] | |||
| 47 | - | } | |||
| 48 | } | 56 | } | ||
| 49 | return k.Name + "@" + origin | 57 | return k.Name + "@" + origin | ||
| 50 | } | 58 | } | ||
M internal/ports/frame.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 30 | |
| 15 | + | const ProtocolVersion uint16 = 31 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | const HandshakeTimeout = 15 * time.Second | 12 | const HandshakeTimeout = 15 * time.Second | ||
| 13 | 13 | ||||
| 14 | // ProtocolVersion is the current vev IPC wire protocol version. | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 15 | - | const ProtocolVersion uint16 = 30 | 15 | + | const ProtocolVersion uint16 = 31 |
| 16 | 16 | ||||
| 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | ||
| 18 | // and excluding the four-byte length prefix. | 18 | // and excluding the four-byte length prefix. | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 30 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 31 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 30 { | 6 | + | if ProtocolVersion != 31 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/navigation_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 62 | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | |
| 63 | 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 64 | 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 65 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, | |
| 65 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, | |
| 66 | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | |
| 67 | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 68 | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | |
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | ||
| 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 65 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, | 65 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, |
| 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | ||
| 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
| 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | ||
M internal/ports/remote_preview_hardening_test.go +1 -0
| @@ -200,6 +200,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 200 | 200 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |
| 201 | 201 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | |
| 202 | 202 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | |
| 203 | + | 0x00, | |
| 203 | 204 | } | |
| 204 | 205 | if !bytes.Equal(got, want) { | |
| 205 | 206 | t.Fatalf("rich target bytes = %x, want %x", got, want) | |
| @@ -200,6 +200,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||||
| 200 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | 200 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | ||
| 201 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | 201 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | ||
| 202 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | 202 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | ||
| 203 | + | 0x00, | |||
| 203 | } | 204 | } | ||
| 204 | if !bytes.Equal(got, want) { | 205 | if !bytes.Equal(got, want) { | ||
| 205 | t.Fatalf("rich target bytes = %x, want %x", got, want) | 206 | t.Fatalf("rich target bytes = %x, want %x", got, want) | ||
M internal/ports/remote_target_wire_test.go +2 -2
| @@ -139,7 +139,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||
| 139 | 139 | }) | |
| 140 | 140 | }, | |
| 141 | 141 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | |
| 142 | - | policyOffset: 1, | |
| 142 | + | policyOffset: 2, | |
| 143 | 143 | wantErr: ErrInvalidAttachTarget, | |
| 144 | 144 | }, | |
| 145 | 145 | } | |
| @@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | 197 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | |
| 198 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0} | |
| 198 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | |
| 199 | 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | |
| 200 | 200 | if !bytes.Equal(got, want) { | |
| 201 | 201 | t.Fatalf("target bytes = %x, want %x", got, want) | |
| @@ -139,7 +139,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||||
| 139 | }) | 139 | }) | ||
| 140 | }, | 140 | }, | ||
| 141 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | 141 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | ||
| 142 | - | policyOffset: 1, | 142 | + | policyOffset: 2, |
| 143 | wantErr: ErrInvalidAttachTarget, | 143 | wantErr: ErrInvalidAttachTarget, | ||
| 144 | }, | 144 | }, | ||
| 145 | } | 145 | } | ||
| @@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||||
| 195 | } | 195 | } | ||
| 196 | 196 | ||||
| 197 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | 197 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | ||
| 198 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0} | 198 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} |
| 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | ||
| 200 | if !bytes.Equal(got, want) { | 200 | if !bytes.Equal(got, want) { | ||
| 201 | t.Fatalf("target bytes = %x, want %x", got, want) | 201 | t.Fatalf("target bytes = %x, want %x", got, want) | ||
M internal/ports/wire.go +19 -4
| @@ -293,6 +293,7 @@ type AttachTarget struct { | |||
| 293 | 293 | Session string | |
| 294 | 294 | Intent uint8 | |
| 295 | 295 | RemoteTarget *domain.RemoteSessionTarget | |
| 296 | + | ExactTarget *ExactSessionTarget | |
| 296 | 297 | EnvironmentPolicy EnvironmentPolicy | |
| 297 | 298 | } | |
| 298 | 299 | ||
| @@ -806,10 +807,14 @@ func ValidateAck(m Ack) error { | |||
| 806 | 807 | return nil | |
| 807 | 808 | } | |
| 808 | 809 | ||
| 809 | - | // ValidateAttachTarget validates route fields and, when present, the exact | |
| 810 | - | // picker identity carried by the handoff. | |
| 810 | + | // ValidateAttachTarget validates a client-owned route handoff. An empty | |
| 811 | + | // Endpoint selects another session on the currently connected daemon; a | |
| 812 | + | // non-empty Endpoint selects a discovered remote daemon. | |
| 811 | 813 | func ValidateAttachTarget(m AttachTarget) error { | |
| 812 | - | if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | |
| 814 | + | if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | |
| 815 | + | return ErrInvalidAttachTarget | |
| 816 | + | } | |
| 817 | + | if err := domain.ValidateSessionName(m.Session); err != nil { | |
| 813 | 818 | return ErrInvalidAttachTarget | |
| 814 | 819 | } | |
| 815 | 820 | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | |
| @@ -821,6 +826,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||
| 821 | 826 | if !validEnvironmentPolicy(m.EnvironmentPolicy) { | |
| 822 | 827 | return ErrInvalidAttachTarget | |
| 823 | 828 | } | |
| 829 | + | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | |
| 830 | + | return ErrInvalidAttachTarget | |
| 831 | + | } | |
| 824 | 832 | if m.RemoteTarget == nil { | |
| 825 | 833 | return nil | |
| 826 | 834 | } | |
| @@ -1498,6 +1506,7 @@ func MarshalAttachTarget(m AttachTarget) []byte { | |||
| 1498 | 1506 | w.putString(m.Session) | |
| 1499 | 1507 | w.putUint8(m.Intent) | |
| 1500 | 1508 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | |
| 1509 | + | marshalExactTargetSection(&w, m.ExactTarget) | |
| 1501 | 1510 | return w.b | |
| 1502 | 1511 | } | |
| 1503 | 1512 | ||
| @@ -1506,7 +1515,7 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1506 | 1515 | // Preflight lengths and intent before getString can allocate either value. | |
| 1507 | 1516 | probe := payloadReader{b: b} | |
| 1508 | 1517 | endpointLen, err := probe.getUint16() | |
| 1509 | - | if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) { | |
| 1518 | + | if err != nil || int(endpointLen) > len(probe.b) { | |
| 1510 | 1519 | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1511 | 1520 | } | |
| 1512 | 1521 | probe.b = probe.b[endpointLen:] | |
| @@ -1522,6 +1531,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1522 | 1531 | if err := skipRemoteTargetSection(&probe); err != nil { | |
| 1523 | 1532 | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1524 | 1533 | } | |
| 1534 | + | if err := skipExactTargetSection(&probe); err != nil { | |
| 1535 | + | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1536 | + | } | |
| 1525 | 1537 | if err := probe.done(); err != nil { | |
| 1526 | 1538 | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1527 | 1539 | } | |
| @@ -1540,6 +1552,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1540 | 1552 | if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil { | |
| 1541 | 1553 | return AttachTarget{}, err | |
| 1542 | 1554 | } | |
| 1555 | + | if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | |
| 1556 | + | return AttachTarget{}, err | |
| 1557 | + | } | |
| 1543 | 1558 | if err := r.done(); err != nil { | |
| 1544 | 1559 | return AttachTarget{}, err | |
| 1545 | 1560 | } | |
| @@ -293,6 +293,7 @@ type AttachTarget struct { | |||||
| 293 | Session string | 293 | Session string | ||
| 294 | Intent uint8 | 294 | Intent uint8 | ||
| 295 | RemoteTarget *domain.RemoteSessionTarget | 295 | RemoteTarget *domain.RemoteSessionTarget | ||
| 296 | + | ExactTarget *ExactSessionTarget | |||
| 296 | EnvironmentPolicy EnvironmentPolicy | 297 | EnvironmentPolicy EnvironmentPolicy | ||
| 297 | } | 298 | } | ||
| 298 | 299 | ||||
| @@ -806,10 +807,14 @@ func ValidateAck(m Ack) error { | |||||
| 806 | return nil | 807 | return nil | ||
| 807 | } | 808 | } | ||
| 808 | 809 | ||||
| 809 | - | // ValidateAttachTarget validates route fields and, when present, the exact | 810 | + | // ValidateAttachTarget validates a client-owned route handoff. An empty |
| 810 | - | // picker identity carried by the handoff. | 811 | + | // Endpoint selects another session on the currently connected daemon; a |
| 812 | + | // non-empty Endpoint selects a discovered remote daemon. | |||
| 811 | func ValidateAttachTarget(m AttachTarget) error { | 813 | func ValidateAttachTarget(m AttachTarget) error { | ||
| 812 | - | if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | 814 | + | if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { |
| 815 | + | return ErrInvalidAttachTarget | |||
| 816 | + | } | |||
| 817 | + | if err := domain.ValidateSessionName(m.Session); err != nil { | |||
| 813 | return ErrInvalidAttachTarget | 818 | return ErrInvalidAttachTarget | ||
| 814 | } | 819 | } | ||
| 815 | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | 820 | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | ||
| @@ -821,6 +826,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||||
| 821 | if !validEnvironmentPolicy(m.EnvironmentPolicy) { | 826 | if !validEnvironmentPolicy(m.EnvironmentPolicy) { | ||
| 822 | return ErrInvalidAttachTarget | 827 | return ErrInvalidAttachTarget | ||
| 823 | } | 828 | } | ||
| 829 | + | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | |||
| 830 | + | return ErrInvalidAttachTarget | |||
| 831 | + | } | |||
| 824 | if m.RemoteTarget == nil { | 832 | if m.RemoteTarget == nil { | ||
| 825 | return nil | 833 | return nil | ||
| 826 | } | 834 | } | ||
| @@ -1498,6 +1506,7 @@ func MarshalAttachTarget(m AttachTarget) []byte { | |||||
| 1498 | w.putString(m.Session) | 1506 | w.putString(m.Session) | ||
| 1499 | w.putUint8(m.Intent) | 1507 | w.putUint8(m.Intent) | ||
| 1500 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | 1508 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | ||
| 1509 | + | marshalExactTargetSection(&w, m.ExactTarget) | |||
| 1501 | return w.b | 1510 | return w.b | ||
| 1502 | } | 1511 | } | ||
| 1503 | 1512 | ||||
| @@ -1506,7 +1515,7 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1506 | // Preflight lengths and intent before getString can allocate either value. | 1515 | // Preflight lengths and intent before getString can allocate either value. | ||
| 1507 | probe := payloadReader{b: b} | 1516 | probe := payloadReader{b: b} | ||
| 1508 | endpointLen, err := probe.getUint16() | 1517 | endpointLen, err := probe.getUint16() | ||
| 1509 | - | if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) { | 1518 | + | if err != nil || int(endpointLen) > len(probe.b) { |
| 1510 | return AttachTarget{}, ErrInvalidAttachTarget | 1519 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1511 | } | 1520 | } | ||
| 1512 | probe.b = probe.b[endpointLen:] | 1521 | probe.b = probe.b[endpointLen:] | ||
| @@ -1522,6 +1531,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1522 | if err := skipRemoteTargetSection(&probe); err != nil { | 1531 | if err := skipRemoteTargetSection(&probe); err != nil { | ||
| 1523 | return AttachTarget{}, ErrInvalidAttachTarget | 1532 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1524 | } | 1533 | } | ||
| 1534 | + | if err := skipExactTargetSection(&probe); err != nil { | |||
| 1535 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1536 | + | } | |||
| 1525 | if err := probe.done(); err != nil { | 1537 | if err := probe.done(); err != nil { | ||
| 1526 | return AttachTarget{}, ErrInvalidAttachTarget | 1538 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1527 | } | 1539 | } | ||
| @@ -1540,6 +1552,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1540 | if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil { | 1552 | if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil { | ||
| 1541 | return AttachTarget{}, err | 1553 | return AttachTarget{}, err | ||
| 1542 | } | 1554 | } | ||
| 1555 | + | if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | |||
| 1556 | + | return AttachTarget{}, err | |||
| 1557 | + | } | |||
| 1543 | if err := r.done(); err != nil { | 1558 | if err := r.done(); err != nil { | ||
| 1544 | return AttachTarget{}, err | 1559 | return AttachTarget{}, err | ||
| 1545 | } | 1560 | } | ||
M internal/ports/wire_final_test.go +43 -5
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 30 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 31 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 29 | - | want := append([]byte{0, 30, 2}, make([]byte, 16+8)...) | |
| 29 | + | want := append([]byte{0, 31, 2}, make([]byte, 16+8)...) | |
| 30 | 30 | 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) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | if !bytes.Equal(got, want) { | |
| @@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 178 | 178 | ||
| 179 | 179 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |
| 180 | 180 | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | |
| 181 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0} | |
| 181 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0} | |
| 182 | 182 | got := MarshalAttachTarget(msg) | |
| 183 | 183 | if !bytes.Equal(got, want) { | |
| 184 | 184 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | |
| @@ -189,13 +189,51 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 189 | 189 | } | |
| 190 | 190 | assertAllPrefixesFail(t, got, UnmarshalAttachTarget) | |
| 191 | 191 | assertTrailingGarbageFails(t, got, UnmarshalAttachTarget) | |
| 192 | - | for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | |
| 192 | + | local := AttachTarget{Session: "work", Intent: IntentAttach} | |
| 193 | + | localPayload := MarshalAttachTarget(local) | |
| 194 | + | if localPayload == nil { | |
| 195 | + | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | |
| 196 | + | } | |
| 197 | + | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | |
| 198 | + | if !bytes.Equal(localPayload, wantLocal) { | |
| 199 | + | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | |
| 200 | + | } | |
| 201 | + | decodedLocal, err := UnmarshalAttachTarget(localPayload) | |
| 202 | + | if err != nil || decodedLocal != local { | |
| 203 | + | t.Fatalf("same-peer target = %+v, error %v, want %+v", decodedLocal, err, local) | |
| 204 | + | } | |
| 205 | + | assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget) | |
| 206 | + | assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget) | |
| 207 | + | for _, bad := range []AttachTarget{{Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | |
| 193 | 208 | if got := MarshalAttachTarget(bad); got != nil { | |
| 194 | 209 | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | |
| 195 | 210 | } | |
| 196 | 211 | } | |
| 197 | 212 | } | |
| 198 | 213 | ||
| 214 | + | func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |
| 215 | + | var lifecycle domain.SessionLifecycleID | |
| 216 | + | lifecycle[0] = 1 | |
| 217 | + | target := AttachTarget{Session: "work", Intent: IntentAttach, ExactTarget: &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}} | |
| 218 | + | payload := MarshalAttachTarget(target) | |
| 219 | + | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | |
| 220 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k') | |
| 221 | + | if !bytes.Equal(payload, want) { | |
| 222 | + | t.Fatalf("exact target bytes = %x, want %x", payload, want) | |
| 223 | + | } | |
| 224 | + | back, err := UnmarshalAttachTarget(payload) | |
| 225 | + | if err != nil || !reflect.DeepEqual(back, target) { | |
| 226 | + | t.Fatalf("exact target = %+v, error %v, want %+v", back, err, target) | |
| 227 | + | } | |
| 228 | + | assertAllPrefixesFail(t, payload, UnmarshalAttachTarget) | |
| 229 | + | assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget) | |
| 230 | + | mismatched := target | |
| 231 | + | mismatched.ExactTarget = &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "other"} | |
| 232 | + | if got := MarshalAttachTarget(mismatched); got != nil { | |
| 233 | + | t.Fatalf("MarshalAttachTarget accepted mismatched target: %x", got) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 199 | 237 | func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |
| 200 | 238 | tests := []struct { | |
| 201 | 239 | name string | |
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 14 | - | if ProtocolVersion != 30 { | 14 | + | if ProtocolVersion != 31 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) |
| 16 | } | 16 | } | ||
| 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 18 | if len(payload) < 2 { | 18 | if len(payload) < 2 { | ||
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 29 | - | want := append([]byte{0, 30, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 31, 2}, make([]byte, 16+8)...) |
| 30 | 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) | 30 | 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) | ||
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
| @@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||||
| 178 | 178 | ||||
| 179 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | 179 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | ||
| 180 | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | 180 | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | ||
| 181 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0} | 181 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0} |
| 182 | got := MarshalAttachTarget(msg) | 182 | got := MarshalAttachTarget(msg) | ||
| 183 | if !bytes.Equal(got, want) { | 183 | if !bytes.Equal(got, want) { | ||
| 184 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | 184 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | ||
| @@ -189,13 +189,51 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||||
| 189 | } | 189 | } | ||
| 190 | assertAllPrefixesFail(t, got, UnmarshalAttachTarget) | 190 | assertAllPrefixesFail(t, got, UnmarshalAttachTarget) | ||
| 191 | assertTrailingGarbageFails(t, got, UnmarshalAttachTarget) | 191 | assertTrailingGarbageFails(t, got, UnmarshalAttachTarget) | ||
| 192 | - | for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | 192 | + | local := AttachTarget{Session: "work", Intent: IntentAttach} |
| 193 | + | localPayload := MarshalAttachTarget(local) | |||
| 194 | + | if localPayload == nil { | |||
| 195 | + | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | |||
| 196 | + | } | |||
| 197 | + | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | |||
| 198 | + | if !bytes.Equal(localPayload, wantLocal) { | |||
| 199 | + | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | |||
| 200 | + | } | |||
| 201 | + | decodedLocal, err := UnmarshalAttachTarget(localPayload) | |||
| 202 | + | if err != nil || decodedLocal != local { | |||
| 203 | + | t.Fatalf("same-peer target = %+v, error %v, want %+v", decodedLocal, err, local) | |||
| 204 | + | } | |||
| 205 | + | assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget) | |||
| 206 | + | assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget) | |||
| 207 | + | for _, bad := range []AttachTarget{{Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | |||
| 193 | if got := MarshalAttachTarget(bad); got != nil { | 208 | if got := MarshalAttachTarget(bad); got != nil { | ||
| 194 | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | 209 | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | ||
| 195 | } | 210 | } | ||
| 196 | } | 211 | } | ||
| 197 | } | 212 | } | ||
| 198 | 213 | ||||
| 214 | + | func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |||
| 215 | + | var lifecycle domain.SessionLifecycleID | |||
| 216 | + | lifecycle[0] = 1 | |||
| 217 | + | target := AttachTarget{Session: "work", Intent: IntentAttach, ExactTarget: &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}} | |||
| 218 | + | payload := MarshalAttachTarget(target) | |||
| 219 | + | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | |||
| 220 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k') | |||
| 221 | + | if !bytes.Equal(payload, want) { | |||
| 222 | + | t.Fatalf("exact target bytes = %x, want %x", payload, want) | |||
| 223 | + | } | |||
| 224 | + | back, err := UnmarshalAttachTarget(payload) | |||
| 225 | + | if err != nil || !reflect.DeepEqual(back, target) { | |||
| 226 | + | t.Fatalf("exact target = %+v, error %v, want %+v", back, err, target) | |||
| 227 | + | } | |||
| 228 | + | assertAllPrefixesFail(t, payload, UnmarshalAttachTarget) | |||
| 229 | + | assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget) | |||
| 230 | + | mismatched := target | |||
| 231 | + | mismatched.ExactTarget = &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "other"} | |||
| 232 | + | if got := MarshalAttachTarget(mismatched); got != nil { | |||
| 233 | + | t.Fatalf("MarshalAttachTarget accepted mismatched target: %x", got) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 199 | func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | 237 | func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | ||
| 200 | tests := []struct { | 238 | tests := []struct { | ||
| 201 | name string | 239 | name string | ||
M internal/ports/wire_test.go +6 -6
| @@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 131 | 131 | } | |
| 132 | 132 | got := MarshalHello(msg) | |
| 133 | 133 | want := []byte{ | |
| 134 | - | 0x00, 0x1e, 0x00, // version, intent, first client ID byte | |
| 134 | + | 0x00, 0x1f, 0x00, // version, intent, first client ID byte | |
| 135 | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 136 | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 137 | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 361 | 361 | } | |
| 362 | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 363 | 363 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 364 | - | require.Equal(t, uint16(30), ProtocolVersion) | |
| 364 | + | require.Equal(t, uint16(31), ProtocolVersion) | |
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 522 | 522 | name: "minimal", | |
| 523 | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 524 | 524 | want: []byte{ | |
| 525 | - | 0x00, 0x1e, | |
| 525 | + | 0x00, 0x1f, | |
| 526 | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 527 | 527 | 0x00, // attached | |
| 528 | 528 | 0x00, // self | |
| @@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 545 | 545 | JSON: true, | |
| 546 | 546 | }, | |
| 547 | 547 | want: []byte{ | |
| 548 | - | 0x00, 0x1e, | |
| 548 | + | 0x00, 0x1f, | |
| 549 | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 550 | 550 | 0x00, // attached | |
| 551 | 551 | 0x01, // self | |
| @@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 974 | 974 | name: "attached", | |
| 975 | 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 976 | 976 | want: []byte{ | |
| 977 | - | 0x00, 0x1e, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 977 | + | 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 978 | 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 979 | 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 980 | 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 984 | 984 | name: "control", | |
| 985 | 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 986 | 986 | want: []byte{ | |
| 987 | - | 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 987 | + | 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 988 | 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 989 | 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 990 | 990 | }, | |
| @@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 131 | } | 131 | } | ||
| 132 | got := MarshalHello(msg) | 132 | got := MarshalHello(msg) | ||
| 133 | want := []byte{ | 133 | want := []byte{ | ||
| 134 | - | 0x00, 0x1e, 0x00, // version, intent, first client ID byte | 134 | + | 0x00, 0x1f, 0x00, // version, intent, first client ID byte |
| 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 361 | } | 361 | } | ||
| 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 363 | require.Equal(t, want, MarshalTheme(cleared)) | 363 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 364 | - | require.Equal(t, uint16(30), ProtocolVersion) | 364 | + | require.Equal(t, uint16(31), ProtocolVersion) |
| 365 | } | 365 | } | ||
| 366 | 366 | ||||
| 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 522 | name: "minimal", | 522 | name: "minimal", | ||
| 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 524 | want: []byte{ | 524 | want: []byte{ | ||
| 525 | - | 0x00, 0x1e, | 525 | + | 0x00, 0x1f, |
| 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 527 | 0x00, // attached | 527 | 0x00, // attached | ||
| 528 | 0x00, // self | 528 | 0x00, // self | ||
| @@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 545 | JSON: true, | 545 | JSON: true, | ||
| 546 | }, | 546 | }, | ||
| 547 | want: []byte{ | 547 | want: []byte{ | ||
| 548 | - | 0x00, 0x1e, | 548 | + | 0x00, 0x1f, |
| 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 550 | 0x00, // attached | 550 | 0x00, // attached | ||
| 551 | 0x01, // self | 551 | 0x01, // self | ||
| @@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 974 | name: "attached", | 974 | name: "attached", | ||
| 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 976 | want: []byte{ | 976 | want: []byte{ | ||
| 977 | - | 0x00, 0x1e, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 977 | + | 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 984 | name: "control", | 984 | name: "control", | ||
| 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 986 | want: []byte{ | 986 | want: []byte{ | ||
| 987 | - | 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 987 | + | 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 990 | }, | 990 | }, | ||
M internal/usecase/client/client.go +24 -8
| @@ -634,9 +634,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 634 | 634 | } | |
| 635 | 635 | } | |
| 636 | 636 | if result.target != nil { | |
| 637 | - | if r.attachHandoff == nil { | |
| 638 | - | return &AttachTargetError{Target: *result.target} | |
| 639 | - | } | |
| 640 | 637 | if result.target.RemoteTarget != nil || result.target.Endpoint != "" { | |
| 641 | 638 | if homeRoute == nil && attemptRequest.RemoteTarget == nil { | |
| 642 | 639 | routeRequest := attemptRequest | |
| @@ -649,9 +646,28 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 649 | 646 | homeRoute = &attachRoute{dialer: dialer, request: routeRequest} | |
| 650 | 647 | } | |
| 651 | 648 | } | |
| 652 | - | nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target) | |
| 653 | - | if handoffErr != nil { | |
| 654 | - | return handoffErr | |
| 649 | + | var ( | |
| 650 | + | nextDialer ports.Dialer | |
| 651 | + | nextRequest AttachRequest | |
| 652 | + | handoffErr error | |
| 653 | + | ) | |
| 654 | + | if result.target.Endpoint == "" { | |
| 655 | + | if r.ledger == nil { | |
| 656 | + | return errors.New("vev: route ledger unavailable") | |
| 657 | + | } | |
| 658 | + | nextDialer = dialer | |
| 659 | + | nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target) | |
| 660 | + | } else { | |
| 661 | + | if r.attachHandoff == nil { | |
| 662 | + | return &AttachTargetError{Target: *result.target} | |
| 663 | + | } | |
| 664 | + | nextDialer, nextRequest, handoffErr = r.attachHandoff(*result.target) | |
| 665 | + | if handoffErr != nil { | |
| 666 | + | return handoffErr | |
| 667 | + | } | |
| 668 | + | } | |
| 669 | + | if result.target.ExactTarget != nil { | |
| 670 | + | nextRequest.ExactTarget = result.target.ExactTarget | |
| 655 | 671 | } | |
| 656 | 672 | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) { | |
| 657 | 673 | nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker | |
| @@ -662,10 +678,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 662 | 678 | } | |
| 663 | 679 | nextRequest = cloneAttachRequest(nextRequest) | |
| 664 | 680 | if err := validateAttachRequest(nextRequest); err != nil { | |
| 665 | - | return fmt.Errorf("vev: invalid remote attach handoff request: %w", err) | |
| 681 | + | return fmt.Errorf("vev: invalid route handoff request: %w", err) | |
| 666 | 682 | } | |
| 667 | 683 | if nextDialer == nil { | |
| 668 | - | return errors.New("vev: remote attach handoff returned nil dialer") | |
| 684 | + | return errors.New("vev: route handoff returned nil dialer") | |
| 669 | 685 | } | |
| 670 | 686 | dialer = nextDialer | |
| 671 | 687 | attemptRequest = nextRequest | |
| @@ -634,9 +634,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 634 | } | 634 | } | ||
| 635 | } | 635 | } | ||
| 636 | if result.target != nil { | 636 | if result.target != nil { | ||
| 637 | - | if r.attachHandoff == nil { | |||
| 638 | - | return &AttachTargetError{Target: *result.target} | |||
| 639 | - | } | |||
| 640 | if result.target.RemoteTarget != nil || result.target.Endpoint != "" { | 637 | if result.target.RemoteTarget != nil || result.target.Endpoint != "" { | ||
| 641 | if homeRoute == nil && attemptRequest.RemoteTarget == nil { | 638 | if homeRoute == nil && attemptRequest.RemoteTarget == nil { | ||
| 642 | routeRequest := attemptRequest | 639 | routeRequest := attemptRequest | ||
| @@ -649,9 +646,28 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 649 | homeRoute = &attachRoute{dialer: dialer, request: routeRequest} | 646 | homeRoute = &attachRoute{dialer: dialer, request: routeRequest} | ||
| 650 | } | 647 | } | ||
| 651 | } | 648 | } | ||
| 652 | - | nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target) | 649 | + | var ( |
| 653 | - | if handoffErr != nil { | 650 | + | nextDialer ports.Dialer |
| 654 | - | return handoffErr | 651 | + | nextRequest AttachRequest |
| 652 | + | handoffErr error | |||
| 653 | + | ) | |||
| 654 | + | if result.target.Endpoint == "" { | |||
| 655 | + | if r.ledger == nil { | |||
| 656 | + | return errors.New("vev: route ledger unavailable") | |||
| 657 | + | } | |||
| 658 | + | nextDialer = dialer | |||
| 659 | + | nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target) | |||
| 660 | + | } else { | |||
| 661 | + | if r.attachHandoff == nil { | |||
| 662 | + | return &AttachTargetError{Target: *result.target} | |||
| 663 | + | } | |||
| 664 | + | nextDialer, nextRequest, handoffErr = r.attachHandoff(*result.target) | |||
| 665 | + | if handoffErr != nil { | |||
| 666 | + | return handoffErr | |||
| 667 | + | } | |||
| 668 | + | } | |||
| 669 | + | if result.target.ExactTarget != nil { | |||
| 670 | + | nextRequest.ExactTarget = result.target.ExactTarget | |||
| 655 | } | 671 | } | ||
| 656 | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) { | 672 | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) { | ||
| 657 | nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker | 673 | nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker | ||
| @@ -662,10 +678,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 662 | } | 678 | } | ||
| 663 | nextRequest = cloneAttachRequest(nextRequest) | 679 | nextRequest = cloneAttachRequest(nextRequest) | ||
| 664 | if err := validateAttachRequest(nextRequest); err != nil { | 680 | if err := validateAttachRequest(nextRequest); err != nil { | ||
| 665 | - | return fmt.Errorf("vev: invalid remote attach handoff request: %w", err) | 681 | + | return fmt.Errorf("vev: invalid route handoff request: %w", err) |
| 666 | } | 682 | } | ||
| 667 | if nextDialer == nil { | 683 | if nextDialer == nil { | ||
| 668 | - | return errors.New("vev: remote attach handoff returned nil dialer") | 684 | + | return errors.New("vev: route handoff returned nil dialer") |
| 669 | } | 685 | } | ||
| 670 | dialer = nextDialer | 686 | dialer = nextDialer | ||
| 671 | attemptRequest = nextRequest | 687 | attemptRequest = nextRequest | ||
M internal/usecase/client/routes.go +36 -0
| @@ -531,6 +531,42 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |||
| 531 | 531 | return snapshot | |
| 532 | 532 | } | |
| 533 | 533 | ||
| 534 | + | // samePeerHandoff restores a previously committed route on the daemon already | |
| 535 | + | // connected to by the client. The daemon deliberately sends no tab state: the | |
| 536 | + | // ledger is the sole owner of per-route view memory. | |
| 537 | + | func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest { | |
| 538 | + | origin := normalizeRouteOrigin(active.Origin, active.Remote) | |
| 539 | + | originKey := normalizeRouteOriginKey(active.OriginKey, origin) | |
| 540 | + | ||
| 541 | + | l.mu.RLock() | |
| 542 | + | defer l.mu.RUnlock() | |
| 543 | + | for _, entry := range l.entries { | |
| 544 | + | if entry.origin != origin || entry.originKey != originKey || entry.target.SessionName != target.Session { | |
| 545 | + | continue | |
| 546 | + | } | |
| 547 | + | if target.ExactTarget != nil && entry.target != *target.ExactTarget { | |
| 548 | + | continue | |
| 549 | + | } | |
| 550 | + | request := cloneAttachRequest(entry.request) | |
| 551 | + | request.Intent = target.Intent | |
| 552 | + | request.EnvironmentPolicy = target.EnvironmentPolicy | |
| 553 | + | request.ExactTarget = target.ExactTarget | |
| 554 | + | if target.ExactTarget == nil { | |
| 555 | + | request.PreferredTabID = "" | |
| 556 | + | } | |
| 557 | + | return request | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | request := cloneAttachRequest(active) | |
| 561 | + | request.Intent = target.Intent | |
| 562 | + | request.SessionName = target.Session | |
| 563 | + | request.ExactTarget = target.ExactTarget | |
| 564 | + | request.PreferredTabID = "" | |
| 565 | + | request.RemoteTarget = nil | |
| 566 | + | request.EnvironmentPolicy = target.EnvironmentPolicy | |
| 567 | + | return request | |
| 568 | + | } | |
| 569 | + | ||
| 534 | 570 | func (l *routeLedger) activeRef() ports.RouteRef { | |
| 535 | 571 | l.mu.RLock() | |
| 536 | 572 | defer l.mu.RUnlock() | |
| @@ -531,6 +531,42 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |||||
| 531 | return snapshot | 531 | return snapshot | ||
| 532 | } | 532 | } | ||
| 533 | 533 | ||||
| 534 | + | // samePeerHandoff restores a previously committed route on the daemon already | |||
| 535 | + | // connected to by the client. The daemon deliberately sends no tab state: the | |||
| 536 | + | // ledger is the sole owner of per-route view memory. | |||
| 537 | + | func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest { | |||
| 538 | + | origin := normalizeRouteOrigin(active.Origin, active.Remote) | |||
| 539 | + | originKey := normalizeRouteOriginKey(active.OriginKey, origin) | |||
| 540 | + | ||||
| 541 | + | l.mu.RLock() | |||
| 542 | + | defer l.mu.RUnlock() | |||
| 543 | + | for _, entry := range l.entries { | |||
| 544 | + | if entry.origin != origin || entry.originKey != originKey || entry.target.SessionName != target.Session { | |||
| 545 | + | continue | |||
| 546 | + | } | |||
| 547 | + | if target.ExactTarget != nil && entry.target != *target.ExactTarget { | |||
| 548 | + | continue | |||
| 549 | + | } | |||
| 550 | + | request := cloneAttachRequest(entry.request) | |||
| 551 | + | request.Intent = target.Intent | |||
| 552 | + | request.EnvironmentPolicy = target.EnvironmentPolicy | |||
| 553 | + | request.ExactTarget = target.ExactTarget | |||
| 554 | + | if target.ExactTarget == nil { | |||
| 555 | + | request.PreferredTabID = "" | |||
| 556 | + | } | |||
| 557 | + | return request | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | request := cloneAttachRequest(active) | |||
| 561 | + | request.Intent = target.Intent | |||
| 562 | + | request.SessionName = target.Session | |||
| 563 | + | request.ExactTarget = target.ExactTarget | |||
| 564 | + | request.PreferredTabID = "" | |||
| 565 | + | request.RemoteTarget = nil | |||
| 566 | + | request.EnvironmentPolicy = target.EnvironmentPolicy | |||
| 567 | + | return request | |||
| 568 | + | } | |||
| 569 | + | ||||
| 534 | func (l *routeLedger) activeRef() ports.RouteRef { | 570 | func (l *routeLedger) activeRef() ports.RouteRef { | ||
| 535 | l.mu.RLock() | 571 | l.mu.RLock() | ||
| 536 | defer l.mu.RUnlock() | 572 | defer l.mu.RUnlock() | ||
M internal/usecase/client/routes_test.go +39 -0
| @@ -109,6 +109,45 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) { | |||
| 109 | 109 | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | + | func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |
| 113 | + | ledger := newRouteLedger() | |
| 114 | + | first := routeTestCandidate(1, ports.RouteOriginLocal) | |
| 115 | + | first.originKey = "local" | |
| 116 | + | _, err := ledger.commit(first) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"})) | |
| 119 | + | ||
| 120 | + | second := routeTestCandidate(2, ports.RouteOriginLocal) | |
| 121 | + | second.originKey = "local" | |
| 122 | + | _, err = ledger.commit(second) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | ||
| 125 | + | recreated := first.target | |
| 126 | + | recreated.LifecycleID[0]++ | |
| 127 | + | for _, tt := range []struct { | |
| 128 | + | name string | |
| 129 | + | target ports.ExactSessionTarget | |
| 130 | + | wantExactTarget *ports.ExactSessionTarget | |
| 131 | + | wantTab domain.TabStableID | |
| 132 | + | }{ | |
| 133 | + | {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"}, | |
| 134 | + | {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated}, | |
| 135 | + | } { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | request := ledger.samePeerHandoff(second.request, ports.AttachTarget{ | |
| 138 | + | Session: first.target.SessionName, | |
| 139 | + | Intent: ports.IntentAttach, | |
| 140 | + | ExactTarget: &tt.target, | |
| 141 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 142 | + | }) | |
| 143 | + | require.Equal(t, first.target.SessionName, request.SessionName) | |
| 144 | + | require.Equal(t, tt.wantExactTarget, request.ExactTarget) | |
| 145 | + | require.Equal(t, tt.wantTab, request.PreferredTabID) | |
| 146 | + | require.Equal(t, ports.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy) | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 112 | 151 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | |
| 113 | 152 | ledger := newRouteLedger() | |
| 114 | 153 | first := routeTestCandidate(0, ports.RouteOriginRemote) | |
| @@ -109,6 +109,45 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) { | |||||
| 109 | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | 109 | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | ||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | + | func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |||
| 113 | + | ledger := newRouteLedger() | |||
| 114 | + | first := routeTestCandidate(1, ports.RouteOriginLocal) | |||
| 115 | + | first.originKey = "local" | |||
| 116 | + | _, err := ledger.commit(first) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"})) | |||
| 119 | + | ||||
| 120 | + | second := routeTestCandidate(2, ports.RouteOriginLocal) | |||
| 121 | + | second.originKey = "local" | |||
| 122 | + | _, err = ledger.commit(second) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | ||||
| 125 | + | recreated := first.target | |||
| 126 | + | recreated.LifecycleID[0]++ | |||
| 127 | + | for _, tt := range []struct { | |||
| 128 | + | name string | |||
| 129 | + | target ports.ExactSessionTarget | |||
| 130 | + | wantExactTarget *ports.ExactSessionTarget | |||
| 131 | + | wantTab domain.TabStableID | |||
| 132 | + | }{ | |||
| 133 | + | {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"}, | |||
| 134 | + | {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated}, | |||
| 135 | + | } { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | request := ledger.samePeerHandoff(second.request, ports.AttachTarget{ | |||
| 138 | + | Session: first.target.SessionName, | |||
| 139 | + | Intent: ports.IntentAttach, | |||
| 140 | + | ExactTarget: &tt.target, | |||
| 141 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 142 | + | }) | |||
| 143 | + | require.Equal(t, first.target.SessionName, request.SessionName) | |||
| 144 | + | require.Equal(t, tt.wantExactTarget, request.ExactTarget) | |||
| 145 | + | require.Equal(t, tt.wantTab, request.PreferredTabID) | |||
| 146 | + | require.Equal(t, ports.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy) | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 112 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | 151 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | ||
| 113 | ledger := newRouteLedger() | 152 | ledger := newRouteLedger() | ||
| 114 | first := routeTestCandidate(0, ports.RouteOriginRemote) | 153 | first := routeTestCandidate(0, ports.RouteOriginRemote) | ||
M internal/usecase/daemon/client_frame_routing.go +1 -0
| @@ -135,6 +135,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 135 | 135 | if !token.ac.setRouteSnapshot(snapshot) { | |
| 136 | 136 | break | |
| 137 | 137 | } | |
| 138 | + | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |
| 138 | 139 | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | |
| 139 | 140 | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 140 | 141 | } | |
| @@ -135,6 +135,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 135 | if !token.ac.setRouteSnapshot(snapshot) { | 135 | if !token.ac.setRouteSnapshot(snapshot) { | ||
| 136 | break | 136 | break | ||
| 137 | } | 137 | } | ||
| 138 | + | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |||
| 138 | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | 139 | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | ||
| 139 | d.detachOnAttachmentSendError(token, token.transport.transport) | 140 | d.detachOnAttachmentSendError(token, token.transport.transport) | ||
| 140 | } | 141 | } | ||
M internal/usecase/daemon/picker.go +57 -1
| @@ -901,7 +901,63 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 901 | 901 | if !token.attachmentCurrent() { | |
| 902 | 902 | return errAttachmentTransition | |
| 903 | 903 | } | |
| 904 | - | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | |
| 904 | + | return d.sendLocalAttachTargetForAttachment(token, target, guard, action) | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | // sendLocalAttachTargetForAttachment makes local picker navigation follow the | |
| 908 | + | // same client-owned handoff path as remote navigation. The client reconnects | |
| 909 | + | // to this daemon and restores its per-route tab from the route ledger. | |
| 910 | + | func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 911 | + | d.mu.Lock() | |
| 912 | + | var targetSess *session | |
| 913 | + | var sessionName string | |
| 914 | + | var exactTarget *ports.ExactSessionTarget | |
| 915 | + | var matches bool | |
| 916 | + | if target.Name != "" { | |
| 917 | + | var stopped stoppedSession | |
| 918 | + | var stoppedTarget bool | |
| 919 | + | targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target) | |
| 920 | + | if stoppedTarget && matches { | |
| 921 | + | sessionName = stopped.name | |
| 922 | + | exactTarget = &ports.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName} | |
| 923 | + | } | |
| 924 | + | } else { | |
| 925 | + | targetSess = d.sessions[target.Session] | |
| 926 | + | matches = targetSess != nil | |
| 927 | + | } | |
| 928 | + | if matches && targetSess != nil { | |
| 929 | + | targetSess.mu.Lock() | |
| 930 | + | sessionName = targetSess.name | |
| 931 | + | exactTarget = &ports.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName} | |
| 932 | + | targetSess.mu.Unlock() | |
| 933 | + | } | |
| 934 | + | d.mu.Unlock() | |
| 935 | + | if !matches || !token.attachmentCurrent() { | |
| 936 | + | return errAttachmentTransition | |
| 937 | + | } | |
| 938 | + | if target.TabID != "" { | |
| 939 | + | // An explicit tab row is a direct user choice, not route memory; retain | |
| 940 | + | // the daemon-side transition so it opens exactly that tab. | |
| 941 | + | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | payload := ports.MarshalAttachTarget(ports.AttachTarget{ | |
| 945 | + | Session: sessionName, | |
| 946 | + | Intent: ports.IntentAttach, | |
| 947 | + | ExactTarget: exactTarget, | |
| 948 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 949 | + | }) | |
| 950 | + | if payload == nil { | |
| 951 | + | return errAttachmentTransition | |
| 952 | + | } | |
| 953 | + | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | |
| 954 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to local session", err) | |
| 955 | + | } | |
| 956 | + | if guard.closePicker { | |
| 957 | + | d.closePicker(token.ac) | |
| 958 | + | } | |
| 959 | + | d.clientGoneForAttachment(token, false) | |
| 960 | + | return nil | |
| 905 | 961 | } | |
| 906 | 962 | ||
| 907 | 963 | // sendRemoteAttachTargetForAttachment validates the catalog row and hands the | |
| @@ -901,7 +901,63 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||||
| 901 | if !token.attachmentCurrent() { | 901 | if !token.attachmentCurrent() { | ||
| 902 | return errAttachmentTransition | 902 | return errAttachmentTransition | ||
| 903 | } | 903 | } | ||
| 904 | - | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | 904 | + | return d.sendLocalAttachTargetForAttachment(token, target, guard, action) |
| 905 | + | } | |||
| 906 | + | ||||
| 907 | + | // sendLocalAttachTargetForAttachment makes local picker navigation follow the | |||
| 908 | + | // same client-owned handoff path as remote navigation. The client reconnects | |||
| 909 | + | // to this daemon and restores its per-route tab from the route ledger. | |||
| 910 | + | func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |||
| 911 | + | d.mu.Lock() | |||
| 912 | + | var targetSess *session | |||
| 913 | + | var sessionName string | |||
| 914 | + | var exactTarget *ports.ExactSessionTarget | |||
| 915 | + | var matches bool | |||
| 916 | + | if target.Name != "" { | |||
| 917 | + | var stopped stoppedSession | |||
| 918 | + | var stoppedTarget bool | |||
| 919 | + | targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target) | |||
| 920 | + | if stoppedTarget && matches { | |||
| 921 | + | sessionName = stopped.name | |||
| 922 | + | exactTarget = &ports.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName} | |||
| 923 | + | } | |||
| 924 | + | } else { | |||
| 925 | + | targetSess = d.sessions[target.Session] | |||
| 926 | + | matches = targetSess != nil | |||
| 927 | + | } | |||
| 928 | + | if matches && targetSess != nil { | |||
| 929 | + | targetSess.mu.Lock() | |||
| 930 | + | sessionName = targetSess.name | |||
| 931 | + | exactTarget = &ports.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName} | |||
| 932 | + | targetSess.mu.Unlock() | |||
| 933 | + | } | |||
| 934 | + | d.mu.Unlock() | |||
| 935 | + | if !matches || !token.attachmentCurrent() { | |||
| 936 | + | return errAttachmentTransition | |||
| 937 | + | } | |||
| 938 | + | if target.TabID != "" { | |||
| 939 | + | // An explicit tab row is a direct user choice, not route memory; retain | |||
| 940 | + | // the daemon-side transition so it opens exactly that tab. | |||
| 941 | + | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | |||
| 942 | + | } | |||
| 943 | + | ||||
| 944 | + | payload := ports.MarshalAttachTarget(ports.AttachTarget{ | |||
| 945 | + | Session: sessionName, | |||
| 946 | + | Intent: ports.IntentAttach, | |||
| 947 | + | ExactTarget: exactTarget, | |||
| 948 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 949 | + | }) | |||
| 950 | + | if payload == nil { | |||
| 951 | + | return errAttachmentTransition | |||
| 952 | + | } | |||
| 953 | + | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | |||
| 954 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to local session", err) | |||
| 955 | + | } | |||
| 956 | + | if guard.closePicker { | |||
| 957 | + | d.closePicker(token.ac) | |||
| 958 | + | } | |||
| 959 | + | d.clientGoneForAttachment(token, false) | |||
| 960 | + | return nil | |||
| 905 | } | 961 | } | ||
| 906 | 962 | ||||
| 907 | // sendRemoteAttachTargetForAttachment validates the catalog row and hands the | 963 | // sendRemoteAttachTargetForAttachment validates the catalog row and hands the | ||
M internal/usecase/daemon/remote_attach_test.go +21 -0
| @@ -17,6 +17,27 @@ func remoteLifecycleForTest() domain.SessionLifecycleID { | |||
| 17 | 17 | return id | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | func TestLocalPickerHandoffCarriesExactLifecycle(t *testing.T) { | |
| 21 | + | d := newRemotePickerDaemon(nil) | |
| 22 | + | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |
| 23 | + | target, _, _ := addRemoteRefreshPickerOwner(t, d, "target") | |
| 24 | + | target.incarnation = remoteLifecycleForTest() | |
| 25 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 26 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 27 | + | require.True(t, admitted) | |
| 28 | + | token.effect = effect | |
| 29 | + | defer effect.End() | |
| 30 | + | ||
| 31 | + | require.NoError(t, d.sendLocalAttachTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | |
| 32 | + | frame := receiveRemotePicker(t, sends, "local attach target") | |
| 33 | + | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.Empty(t, got.Endpoint) | |
| 36 | + | require.Equal(t, ports.IntentAttach, got.Intent) | |
| 37 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, got.ExactTarget) | |
| 38 | + | require.Nil(t, ac.currentAttachmentSession()) | |
| 39 | + | } | |
| 40 | + | ||
| 20 | 41 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |
| 21 | 42 | d := newRemotePickerDaemon(nil) | |
| 22 | 43 | lifecycle := remoteLifecycleForTest() | |
| @@ -17,6 +17,27 @@ func remoteLifecycleForTest() domain.SessionLifecycleID { | |||||
| 17 | return id | 17 | return id | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | + | func TestLocalPickerHandoffCarriesExactLifecycle(t *testing.T) { | |||
| 21 | + | d := newRemotePickerDaemon(nil) | |||
| 22 | + | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |||
| 23 | + | target, _, _ := addRemoteRefreshPickerOwner(t, d, "target") | |||
| 24 | + | target.incarnation = remoteLifecycleForTest() | |||
| 25 | + | token := source.attachmentToken(ac, ac.transport()) | |||
| 26 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 27 | + | require.True(t, admitted) | |||
| 28 | + | token.effect = effect | |||
| 29 | + | defer effect.End() | |||
| 30 | + | ||||
| 31 | + | require.NoError(t, d.sendLocalAttachTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | |||
| 32 | + | frame := receiveRemotePicker(t, sends, "local attach target") | |||
| 33 | + | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.Empty(t, got.Endpoint) | |||
| 36 | + | require.Equal(t, ports.IntentAttach, got.Intent) | |||
| 37 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, got.ExactTarget) | |||
| 38 | + | require.Nil(t, ac.currentAttachmentSession()) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 20 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | 41 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | ||
| 21 | d := newRemotePickerDaemon(nil) | 42 | d := newRemotePickerDaemon(nil) | ||
| 22 | lifecycle := remoteLifecycleForTest() | 43 | lifecycle := remoteLifecycleForTest() | ||
M internal/usecase/daemon/remote_picker.go +1 -9
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sort" | |
| 9 | - | "strings" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| 12 | 11 | "github.com/bnema/vev/internal/domain" | |
| @@ -122,20 +121,13 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri | |||
| 122 | 121 | } | |
| 123 | 122 | } | |
| 124 | 123 | ||
| 125 | - | func remoteSessionOrigin(host string) string { | |
| 126 | - | if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) { | |
| 127 | - | return host[i+1:] | |
| 128 | - | } | |
| 129 | - | return host | |
| 130 | - | } | |
| 131 | - | ||
| 132 | 124 | func remoteSessionStateStopped(state string) bool { | |
| 133 | 125 | return state == "down" || state == "stopped" | |
| 134 | 126 | } | |
| 135 | 127 | ||
| 136 | 128 | func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView { | |
| 137 | 129 | key.LifecycleID = session.LifecycleID | |
| 138 | - | key.DisplayOrigin = remoteSessionOrigin(key.Host) | |
| 130 | + | key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host) | |
| 139 | 131 | availability := remotePickerAvailability(status) | |
| 140 | 132 | stopped := remoteSessionStateStopped(session.State) | |
| 141 | 133 | broken := session.State == "broken" | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sort" | 8 | "sort" | ||
| 9 | - | "strings" | |||
| 10 | "time" | 9 | "time" | ||
| 11 | 10 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| @@ -122,20 +121,13 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri | |||||
| 122 | } | 121 | } | ||
| 123 | } | 122 | } | ||
| 124 | 123 | ||||
| 125 | - | func remoteSessionOrigin(host string) string { | |||
| 126 | - | if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) { | |||
| 127 | - | return host[i+1:] | |||
| 128 | - | } | |||
| 129 | - | return host | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | func remoteSessionStateStopped(state string) bool { | 124 | func remoteSessionStateStopped(state string) bool { | ||
| 133 | return state == "down" || state == "stopped" | 125 | return state == "down" || state == "stopped" | ||
| 134 | } | 126 | } | ||
| 135 | 127 | ||||
| 136 | func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView { | 128 | func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView { | ||
| 137 | key.LifecycleID = session.LifecycleID | 129 | key.LifecycleID = session.LifecycleID | ||
| 138 | - | key.DisplayOrigin = remoteSessionOrigin(key.Host) | 130 | + | key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host) |
| 139 | availability := remotePickerAvailability(status) | 131 | availability := remotePickerAvailability(status) | ||
| 140 | stopped := remoteSessionStateStopped(session.State) | 132 | stopped := remoteSessionStateStopped(session.State) | ||
| 141 | broken := session.State == "broken" | 133 | broken := session.State == "broken" | ||