From a94ceb04969e4ef2758774bdc5c081d36527d2d2 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 13:59:01 -0700 Subject: [PATCH 01/14] test: cover Tailscale and Iroh upgrade compatibility --- .../IrohReconnectRouteSelectionTests.swift | 251 ++++++++++++++++++ cmuxTests/MobileHostIrohAdmissionTests.swift | 61 +++++ .../MobileHostServiceSettingsTests.swift | 12 + 3 files changed, 324 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index 0d5dd500b3bf..95b62e6e737e 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -1,4 +1,8 @@ import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation import Testing @testable import CmuxMobileShell @@ -63,6 +67,191 @@ extension ReconnectRouteSelectionTests { #expect(factory.attemptedKinds().isEmpty) } + @Test func reconnectUsesSingleRegistrySnapshotToRescueNonActiveMacWithNoLocalRoutes() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + await router.setHostIdentity(deviceID: "mac-b", instanceTag: "stable", displayName: "Mac B") + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let good = try registryIroh( + id: "iroh-b", + endpointID: String(repeating: "b", count: 64) + ) + let wrong = try registryIroh( + id: "iroh-wrong", + endpointID: String(repeating: "c", count: 64) + ) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([ + RegistryDevice( + deviceId: "mac-b", + platform: "mac", + displayName: "Mac B", + lastSeenAt: clock.now, + instances: [ + RegistryAppInstance(tag: "other", routes: [wrong], lastSeenAt: clock.now), + RegistryAppInstance( + tag: "stable", + routes: [good, try tailscale(51_002)], + lastSeenAt: clock.now + ), + ] + ), + ])) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "mac-a", + displayName: "Mac A", + routes: [try tailscale(51_001)], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + try await pairedStore.upsert( + macDeviceID: "mac-b", + displayName: "Mac B", + routes: [], + instanceTag: "stable", + markActive: false, + stackUserID: "user-1", + teamID: nil, + now: clock.now.addingTimeInterval(1) + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(store.foregroundMacDeviceID == "mac-b") + #expect(store.activeRoute?.id == "iroh-b") + #expect(factory.attemptedKinds() == [.iroh]) + #expect(await registry.counts() == .init(list: 1, fresh: 0)) + let rows = try await pairedStore.loadAll(stackUserID: "user-1", teamID: nil) + #expect(rows.count == 2) + let upgraded = try #require(rows.first { $0.macDeviceID == "mac-b" }) + #expect(upgraded.instanceTag == "stable") + #expect(upgraded.routes.contains { $0.id == "iroh-b" }) + } + + @Test func switchToLegacySavedMacUpgradesFromRegistryWithoutRescan() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + await router.setHostIdentity(deviceID: "test-mac", instanceTag: "stable") + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let iroh = try registryIroh( + id: "iroh-stable", + endpointID: String(repeating: "d", count: 64) + ) + let legacy = try tailscale(51_003) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([ + RegistryDevice( + deviceId: "test-mac", + platform: "mac", + displayName: "Test Mac", + lastSeenAt: clock.now, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: [iroh, legacy], + lastSeenAt: clock.now + ), + ] + ), + ])) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [legacy], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + let before = try #require(await pairedStore.activeMac(stackUserID: "user-1", teamID: nil)) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(await store.switchToMac(macDeviceID: "test-mac")) + #expect(await registry.counts() == .init(list: 1, fresh: 0)) + #expect(store.activeRoute?.id == iroh.id) + #expect(!store.connectionRequiresReauth) + let after = try #require(await pairedStore.activeMac(stackUserID: "user-1", teamID: nil)) + #expect(after.createdAt == before.createdAt) + #expect(after.isActive) + #expect(after.routes.contains { $0.id == iroh.id }) + } + + @Test func legacySavedMacWithoutPublishedIrohIsRetainedAndRequestsMacUpdate() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([])) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + let legacy = try tailscale(51_004) + try await pairedStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [legacy], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + let before = try #require(await pairedStore.activeMac(stackUserID: "user-1", teamID: nil)) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(!(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))) + #expect(factory.attemptedKinds().isEmpty) + #expect(!store.connectionRequiresReauth) + #expect(store.hasKnownPairedMac) + #expect(await registry.counts() == .init(list: 1, fresh: 0)) + let copy = [store.connectionError, store.connectionErrorGuidance] + .compactMap { $0 } + .joined(separator: " ") + .lowercased() + #expect(copy.contains("update cmux")) + #expect(copy.contains("mac")) + #expect(copy.contains("automatically")) + let after = try #require(await pairedStore.activeMac(stackUserID: "user-1", teamID: nil)) + #expect(after.macDeviceID == before.macDeviceID) + #expect(after.routes == before.routes) + #expect(after.createdAt == before.createdAt) + #expect(after.isActive) + } + @Test func switchingToIrohCapableMacUsesPinnedIrohRoute() async throws { let clock = TestClock() let router = LivenessHostRouter() @@ -131,4 +320,66 @@ extension ReconnectRouteSelectionTests { #expect(hints[0].source == .tailscale) #expect(hints[0].use == .fallbackOnly) } + + private func registryIroh(id: String, endpointID: String) throws -> CmxAttachRoute { + try CmxAttachRoute( + id: id, + kind: .iroh, + endpoint: .peer( + identity: CmxIrohPeerIdentity(endpointID: endpointID), + pathHints: [] + ), + priority: -10_000 + ) + } + + private func makeMigrationShell( + pairedStore: MobilePairedMacStore, + registry: any DeviceRegistryRefreshing, + runtime: any MobileSyncRuntime + ) async -> MobileShellComposite { + let store = MobileShellComposite( + runtime: runtime, + isSignedIn: true, + pairedMacStore: pairedStore, + deviceRegistry: registry, + identityProvider: StaticIdentityProvider(userID: "user-1"), + reachability: AlwaysOnlineReachability(), + pairingHintDefaults: UserDefaults(suiteName: "iroh-migration-\(UUID().uuidString)")! + ) + await store.loadPairedMacs() + return store + } +} + +private actor SnapshotCountingDeviceRegistry: DeviceRegistryRefreshing { + struct Counts: Equatable, Sendable { + let list: Int + let fresh: Int + } + + private let outcome: DeviceRegistryListOutcome + private var listCalls = 0 + private var freshCalls = 0 + + init(outcome: DeviceRegistryListOutcome) { + self.outcome = outcome + } + + func freshRoutes( + forMacDeviceID _: String, + instanceTag _: String? + ) async -> [CmxAttachRoute]? { + freshCalls += 1 + return nil + } + + func listDevices() async -> DeviceRegistryListOutcome { + listCalls += 1 + return outcome + } + + func counts() -> Counts { + Counts(list: listCalls, fresh: freshCalls) + } } diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index 0866ad46b767..cdec49a93ec2 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -80,6 +80,67 @@ extension MobileHostAuthorizationTests { #expect(decoded.routes.first?.endpoint == .hostPort(host: "100.64.0.7", port: 58465)) } + @Test func testLegacyPairingPayloadDropsIrohFromMixedHostRoutes() throws { + let store = MobileAttachTicketStore() + let iroh = try CmxAttachRoute( + id: "iroh", + kind: .iroh, + endpoint: .peer( + identity: CmxIrohPeerIdentity(endpointID: String(repeating: "a", count: 64)), + pathHints: [] + ), + priority: 0 + ) + let tailscale = try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.7", port: 58465), + priority: 10 + ) + let ticket = try store.createTicket( + workspaceID: "", + terminalID: nil, + routes: [iroh, tailscale], + ttl: 3600 + ) + + let payload = try store.payload( + for: ticket, + routeDisclosureMode: .legacyPrivateNetworkCompatibility + ) + let attachURL = try #require(payload["attach_url"] as? String) + let decoded = try CmxAttachTicketInput.decode(attachURL) + + #expect(!CmxPairingQRCode().isPairingCodeURLString(attachURL)) + #expect(decoded.routes == [tailscale]) + #expect(decoded.authToken == nil) + #expect(!attachURL.contains(String(repeating: "a", count: 64))) + + let components = try #require(URLComponents(string: attachURL)) + let encoded = try #require( + components.queryItems?.first(where: { $0.name == "payload" })?.value + ) + let legacyData = try #require(Self.decodeBase64URL(encoded)) + let legacyObject = try #require( + JSONSerialization.jsonObject(with: legacyData) as? [String: Any] + ) + #expect(legacyObject["version"] as? Int == CmxAttachTicket.currentVersion) + #expect(legacyObject["expiresAt"] != nil) + #expect(legacyObject["auth_token"] == nil) + #expect((legacyObject["routes"] as? [[String: Any]])?.count == 1) + } + + private static func decodeBase64URL(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = base64.count % 4 + if padding > 0 { + base64.append(String(repeating: "=", count: 4 - padding)) + } + return Data(base64Encoded: base64) + } + @Test func testBindingPublicationDoesNotWaitForPersistence() async { let queue = MobileHostIrohPersistenceQueue() let gate = MobileHostIrohPersistenceGate() diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index b327ec524696..1b73d2eb9ca3 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -25,6 +25,18 @@ struct MobileHostServiceSettingsTests { #expect(!MobileHostService.isListeningEnabled(defaults: defaults)) } + @Test func mobileHostListenerPreservesHistoricalExplicitOptIn() throws { + let suiteName = "MobileHostServiceSettingsTests.Legacy.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: "cmuxMobilePairingHostEnabled") + #expect(MobileHostService.isListeningEnabled(defaults: defaults)) + + defaults.set(false, forKey: MobileHostService.listeningEnabledDefaultsKey) + #expect(!MobileHostService.isListeningEnabled(defaults: defaults)) + } + @Test func configuredPortDefaultsToCatalogDefaultWhenUnset() throws { let suiteName = "MobileHostServiceSettingsTests.Port.Default.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) From 3ee987a1f5a0eafb6625634666806e5100b4e3ea Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 14:25:35 -0700 Subject: [PATCH 02/14] test: cover live Mac migration switching --- .../IrohReconnectRouteSelectionTests.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index 95b62e6e737e..022aaca5022d 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -202,6 +202,78 @@ extension ReconnectRouteSelectionTests { #expect(after.routes.contains { $0.id == iroh.id }) } + @Test func switchUpgradesLegacyTargetWhileAnotherMacStaysConnected() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let macAIroh = try registryIroh( + id: "iroh-a", + endpointID: String(repeating: "a", count: 64) + ) + let macBIroh = try registryIroh( + id: "iroh-b", + endpointID: String(repeating: "b", count: 64) + ) + let macBLegacy = try tailscale(51_005) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([ + RegistryDevice( + deviceId: "mac-b", + platform: "mac", + displayName: "Mac B", + lastSeenAt: clock.now, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: [macBIroh, macBLegacy], + lastSeenAt: clock.now + ), + ] + ), + ])) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "mac-a", + displayName: "Mac A", + routes: [macAIroh], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + try await pairedStore.upsert( + macDeviceID: "mac-b", + displayName: "Mac B", + routes: [macBLegacy], + instanceTag: "stable", + markActive: false, + stackUserID: "user-1", + teamID: nil, + now: clock.now.addingTimeInterval(1) + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + await router.setHostIdentity(deviceID: "mac-a", instanceTag: "stable") + #expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(store.foregroundMacDeviceID == "mac-a") + + await router.setHostIdentity(deviceID: "mac-b", instanceTag: "stable") + #expect(await store.switchToMac(macDeviceID: "mac-b")) + #expect(store.foregroundMacDeviceID == "mac-b") + #expect(store.activeRoute?.id == macBIroh.id) + #expect(await registry.counts() == .init(list: 1, fresh: 0)) + } + @Test func legacySavedMacWithoutPublishedIrohIsRetainedAndRequestsMacUpdate() async throws { let clock = TestClock() let router = LivenessHostRouter() From 416d02da96b124bce0f878425cbf37fae0b2f007 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 14:31:14 -0700 Subject: [PATCH 03/14] test: cover migration failure precedence and legacy expiry --- .../IrohReconnectRouteSelectionTests.swift | 54 +++++++++++++++++++ cmuxTests/MobileHostIrohAdmissionTests.swift | 9 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index 022aaca5022d..d240decef56a 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -324,6 +324,54 @@ extension ReconnectRouteSelectionTests { #expect(after.isActive) } + @Test func activeIrohAuthorizationFailureOutranksSecondaryLegacyMigrationCopy() async throws { + let clock = TestClock() + let runtime = LivenessTestRuntime( + transportFactory: AuthorizationRejectingTransportFactory(), + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([])) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "mac-a", + displayName: "Mac A", + routes: [try registryIroh( + id: "iroh-a", + endpointID: String(repeating: "a", count: 64) + )], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + try await pairedStore.upsert( + macDeviceID: "mac-b", + displayName: "Mac B", + routes: [try tailscale(51_006)], + instanceTag: "stable", + markActive: false, + stackUserID: "user-1", + teamID: nil, + now: clock.now.addingTimeInterval(1) + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(!(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))) + #expect(store.connectionRequiresReauth) + let copy = [store.connectionError, store.connectionErrorGuidance] + .compactMap { $0 } + .joined(separator: " ") + .lowercased() + #expect(!copy.contains("update cmux")) + } + @Test func switchingToIrohCapableMacUsesPinnedIrohRoute() async throws { let clock = TestClock() let router = LivenessHostRouter() @@ -455,3 +503,9 @@ private actor SnapshotCountingDeviceRegistry: DeviceRegistryRefreshing { Counts(list: listCalls, fresh: freshCalls) } } + +private struct AuthorizationRejectingTransportFactory: CmxByteTransportFactory { + func makeTransport(for _: CmxAttachRoute) throws -> any CmxByteTransport { + throw MobileShellConnectionError.authorizationFailed("authorization rejected") + } +} diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index cdec49a93ec2..dcb1c549a64b 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -33,7 +33,9 @@ extension MobileHostAuthorizationTests { workspaceID: "", terminalID: nil, routes: [iroh, tailscale], - ttl: 3600 + ttl: 3600, + macUserEmail: "private@example.com", + macUserID: "opaque-user-id" ) let payload = try store.payload( @@ -114,6 +116,9 @@ extension MobileHostAuthorizationTests { #expect(!CmxPairingQRCode().isPairingCodeURLString(attachURL)) #expect(decoded.routes == [tailscale]) #expect(decoded.authToken == nil) + let sourceExpiry = try #require(ticket.expiresAt) + let legacyExpiry = try #require(decoded.expiresAt) + #expect(legacyExpiry > sourceExpiry.addingTimeInterval(365 * 24 * 60 * 60)) #expect(!attachURL.contains(String(repeating: "a", count: 64))) let components = try #require(URLComponents(string: attachURL)) @@ -127,6 +132,8 @@ extension MobileHostAuthorizationTests { #expect(legacyObject["version"] as? Int == CmxAttachTicket.currentVersion) #expect(legacyObject["expiresAt"] != nil) #expect(legacyObject["auth_token"] == nil) + #expect(legacyObject["macUserEmail"] == nil) + #expect(legacyObject["macUserID"] as? String == "opaque-user-id") #expect((legacyObject["routes"] as? [[String: Any]])?.count == 1) } From 64f37b57a530a1d496c8dc3a79ff291a60e3be4d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 14:35:29 -0700 Subject: [PATCH 04/14] test: strengthen legacy pairing privacy coverage --- cmuxTests/MobileHostIrohAdmissionTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index dcb1c549a64b..82684218296a 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -103,7 +103,9 @@ extension MobileHostAuthorizationTests { workspaceID: "", terminalID: nil, routes: [iroh, tailscale], - ttl: 3600 + ttl: 3600, + macUserEmail: "private@example.com", + macUserID: "opaque-user-id" ) let payload = try store.payload( From 6c17c35c96a07dda29b3b0acb0ca3bd09628685f Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 14:39:34 -0700 Subject: [PATCH 05/14] test: cover concurrent route migration --- .../IrohReconnectRouteSelectionTests.swift | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index d240decef56a..d7b3e80474c9 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -142,6 +142,73 @@ extension ReconnectRouteSelectionTests { #expect(upgraded.routes.contains { $0.id == "iroh-b" }) } + @Test func concurrentPresenceUpgradeIsUsedWhenRegistryReturnsTheSameIrohRoutes() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + await router.setHostIdentity(deviceID: "test-mac", instanceTag: "stable") + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let iroh = try registryIroh( + id: "iroh-stable", + endpointID: String(repeating: "e", count: 64) + ) + let legacy = try tailscale(51_007) + let publishedRoutes = [iroh, legacy] + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [legacy], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + let registry = PresenceRacingDeviceRegistry( + pairedStore: pairedStore, + routes: publishedRoutes, + outcome: .ok([ + RegistryDevice( + deviceId: "test-mac", + platform: "mac", + displayName: "Test Mac", + lastSeenAt: clock.now, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: publishedRoutes, + lastSeenAt: clock.now + ), + ] + ), + ]), + now: clock.now.addingTimeInterval(1) + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(store.activeRoute?.id == iroh.id) + #expect(factory.attemptedKinds() == [.iroh]) + #expect(store.connectionError?.localizedCaseInsensitiveContains("update cmux") != true) + #expect(await registry.state() == .init(listCalls: 1, wrotePresenceRoutes: true)) + let persisted = try #require(await pairedStore.activeMac( + stackUserID: "user-1", + teamID: nil + )) + #expect(persisted.routes.contains { $0.id == iroh.id }) + } + @Test func switchToLegacySavedMacUpgradesFromRegistryWithoutRescan() async throws { let clock = TestClock() let router = LivenessHostRouter() @@ -504,6 +571,62 @@ private actor SnapshotCountingDeviceRegistry: DeviceRegistryRefreshing { } } +private actor PresenceRacingDeviceRegistry: DeviceRegistryRefreshing { + struct State: Equatable, Sendable { + let listCalls: Int + let wrotePresenceRoutes: Bool + } + + private let pairedStore: MobilePairedMacStore + private let routes: [CmxAttachRoute] + private let outcome: DeviceRegistryListOutcome + private let now: Date + private var listCalls = 0 + private var wrotePresenceRoutes = false + + init( + pairedStore: MobilePairedMacStore, + routes: [CmxAttachRoute], + outcome: DeviceRegistryListOutcome, + now: Date + ) { + self.pairedStore = pairedStore + self.routes = routes + self.outcome = outcome + self.now = now + } + + func freshRoutes( + forMacDeviceID _: String, + instanceTag _: String? + ) async -> [CmxAttachRoute]? { + nil + } + + func listDevices() async -> DeviceRegistryListOutcome { + listCalls += 1 + do { + wrotePresenceRoutes = try await pairedStore.upsertRoutesIfAuthorized( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: routes, + condition: .matchingInstanceTag("stable"), + markActive: nil, + stackUserID: "user-1", + teamID: nil, + now: now + ) + return outcome + } catch { + return .transientFailure + } + } + + func state() -> State { + State(listCalls: listCalls, wrotePresenceRoutes: wrotePresenceRoutes) + } +} + private struct AuthorizationRejectingTransportFactory: CmxByteTransportFactory { func makeTransport(for _: CmxAttachRoute) throws -> any CmxByteTransport { throw MobileShellConnectionError.authorizationFailed("authorization rejected") From 913b91cbc5b87026c134dbd998c9691f4c2e40af Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 14:44:15 -0700 Subject: [PATCH 06/14] fix: preserve Iroh and Tailscale upgrade compatibility --- .../DeviceRegistryService.swift | 80 +++--- .../MobilePairingFailure.swift | 15 ++ ...MobileShellComposite+ReconnectRoutes.swift | 70 +++--- .../MobileShellComposite.swift | 236 +++++++++++------- .../MobilePairingFailureTests.swift | 12 + .../DisconnectedWorkspaceShellView.swift | 20 +- .../CmuxMobileShellUI/WorkspaceListView.swift | 26 +- Sources/Mobile/MobileAttachTicketStore.swift | 55 +++- Sources/Mobile/MobileHostService.swift | 8 + ios/cmux/Resources/Localizable.xcstrings | 34 +++ 10 files changed, 377 insertions(+), 179 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index 8073885f67cf..f24865c8a959 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -279,68 +279,48 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return .distantPast } - /// Decode the `/api/devices` list response and return authoritative routes - /// for the matching device. A scoped client selects its resolved Mac - /// app-instance tag; unscoped builds require one sole route-advertising - /// instance. Returns `nil` when that ownership cannot be proven. - /// - /// Each route is decoded *failably* and individually: a malformed or - /// unknown-kind route from any instance (even another Mac's) is skipped - /// rather than failing the whole response. This keeps one bad sibling row - /// from disabling registry refresh for every Mac, and makes old clients - /// forward-compatible when a newer build advertises a route kind they cannot - /// decode. + /// Return authoritative routes for a matching device from one decoded + /// registry snapshot. A scoped client selects its exact Mac app-instance + /// tag; an unscoped client accepts routes only when exactly one instance on + /// that physical device advertises any. Returns `nil` when ownership cannot + /// be proven. static func routes( forMacDeviceID macDeviceID: String, pairedMacInstanceTag: String? = nil, - in data: Data + in devices: [RegistryDevice] ) -> [CmxAttachRoute]? { - // Decode each route element through an optional wrapper so a single bad - // element decodes to `nil` and is dropped, never throwing for the array. - struct FailableRoute: Decodable { - let value: CmxAttachRoute? - init(from decoder: Decoder) throws { - value = try? CmxAttachRoute(from: decoder) - } - } - struct Instance: Decodable { - let tag: String? - let routes: [FailableRoute] - } - struct Device: Decodable { - let deviceId: String - let instances: [Instance] - } - struct ListResponse: Decodable { - let devices: [Device] - } - guard let decoded = try? JSONDecoder().decode(ListResponse.self, from: data) else { - return nil - } let target = macDeviceID.lowercased() - guard let device = decoded.devices.first(where: { $0.deviceId.lowercased() == target }) else { + guard let device = devices.first(where: { $0.deviceId.lowercased() == target }) else { return nil } - let candidates: [Instance] - if let pairedMacInstanceTag { - // Route authority is an exact Mac-instance identity resolved at app - // composition. Another tag becoming the device's sole live instance - // must not redirect this build's persisted reconnect route. - candidates = device.instances.filter { - $0.tag?.trimmingCharacters(in: .whitespacesAndNewlines) == pairedMacInstanceTag - } + let candidates = if let pairedMacInstanceTag { + device.instances.filter { $0.tag == pairedMacInstanceTag } } else { - // Stable/unscoped storage has no tag ownership to prove. Keep the - // existing safe fallback: accept routes only when one instance on - // the physical Mac advertises any. - candidates = device.instances + device.instances } - let nonEmpty = candidates - .map { $0.routes.compactMap(\.value) } - .filter { !$0.isEmpty } + let nonEmpty = candidates.map(\.routes).filter { !$0.isEmpty } return nonEmpty.count == 1 ? nonEmpty[0] : nil } + /// Decode the `/api/devices` list response and return authoritative routes + /// for the matching device. Each route is decoded *failably* and + /// individually by ``parseDeviceList(in:)``: a malformed or unknown-kind + /// route from any instance is skipped rather than failing the whole response. + /// This keeps one bad sibling row from disabling registry refresh for every + /// Mac and makes old clients forward-compatible with new route kinds. + static func routes( + forMacDeviceID macDeviceID: String, + pairedMacInstanceTag: String? = nil, + in data: Data + ) -> [CmxAttachRoute]? { + guard let devices = parseDeviceList(in: data) else { return nil } + return routes( + forMacDeviceID: macDeviceID, + pairedMacInstanceTag: pairedMacInstanceTag, + in: devices + ) + } + // MARK: - Request building private func makeRequest(method: String, path: String, body: [String: Any]?) async -> URLRequest? { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index 1033713d6ba8..69c4f703ec2d 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -76,6 +76,10 @@ public enum MobilePairingFailureCategory: Equatable, Sendable { /// The scanned/pasted code only points back at the Mac itself (loopback), /// which the phone can never dial. case loopbackRejected + /// A saved legacy route is still valid, but the Mac must publish an Iroh + /// route before this iOS version can reconnect securely. This is version + /// skew, not an account failure, so the saved pairing stays intact. + case macUpdateRequired /// The pairing code carried only an untrusted manual route that cannot carry /// the account credential. case unsupportedRoute @@ -109,6 +113,7 @@ extension MobilePairingFailureCategory { case .invalidCode: return "invalid_code" case .unrecognizedVersion: return "unrecognized_version" case .loopbackRejected: return "loopback_rejected" + case .macUpdateRequired: return "mac_update_required" case .unsupportedRoute: return "unsupported_route" case .noSupportedRoute: return "no_supported_route" case .cancelled: return "cancelled" @@ -247,6 +252,11 @@ extension MobilePairingFailureCategory { "mobile.pairing.loopbackRejected", defaultValue: "This code points at the Mac itself (localhost), so your iPhone can't use it. Update cmux on the Mac and scan its Iroh code." ) + case .macUpdateRequired: + return L10n.string( + "mobile.pairing.macUpdateRequired", + defaultValue: "Update cmux on this Mac to connect securely." + ) case .unsupportedRoute: return L10n.string( "mobile.pairing.secureRouteRequired", @@ -321,6 +331,11 @@ extension MobilePairingFailureCategory { "mobile.pairing.guidance.updateApp", defaultValue: "Update cmux from the App Store (or TestFlight), then scan again." ) + case .macUpdateRequired: + return L10n.string( + "mobile.pairing.guidance.macUpdateRequired", + defaultValue: "Your saved computer will reconnect automatically after the Mac publishes its Iroh route. You do not need to sign out or pair again." + ) case .invalidCode, .loopbackRejected, .cancelled, .unknown: return nil } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index 1993f31bbb27..001a679960dd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -1,5 +1,6 @@ import CMUXMobileCore import CmuxMobilePairedMac +import CmuxMobileShellModel import Foundation import os @@ -166,57 +167,61 @@ extension MobileShellComposite { func freshReconnectRoutesAfterLocalFailure( for mac: MobilePairedMac, scope: MobileShellScopeSnapshot, - triedRoutes: [(host: String, port: Int, routeID: String)] - ) async -> RefreshedReconnectRoutes? { + registryDevices: [RegistryDevice]? + ) async -> [CmxAttachRoute]? { let supportedKinds = runtime?.supportedRouteKinds ?? [] - let localRoutes = Self.storedReconnectRoutes( - mac.routes, - supportedKinds: supportedKinds, - preferNonLoopback: Self.prefersNonLoopbackRoutes - ) - let requiresIroh = localRoutes.contains { $0.kind == .iroh } - guard let deviceRegistry, + guard let registryDevices, await isScopeCurrent(scope), await !isForgottenMacDeviceID(mac.macDeviceID, scope: scope), - let registryRoutes = await deviceRegistry.freshRoutes( + let registryRoutes = DeviceRegistryService.routes( forMacDeviceID: mac.macDeviceID, - instanceTag: mac.instanceTag + pairedMacInstanceTag: mac.instanceTag, + in: registryDevices ), - connectionState != .connected, let pairedMacStore, let currentMac = try? await pairedMacStore.loadAll( stackUserID: scope.userID, teamID: scope.teamID ).first(where: { $0.macDeviceID == mac.macDeviceID }), currentMac.instanceTag == mac.instanceTag, - let updatedRoutes = DeviceRegistryService.selectReconnectRoutes( - local: mac.routes, - registry: registryRoutes - ) else { + await isScopeCurrent(scope), + await !isForgottenMacDeviceID(mac.macDeviceID, scope: scope) else { return nil } - let reconnectRoutes = Self.storedReconnectRoutes( - updatedRoutes, + let localRoutes = Self.storedReconnectRoutes( + currentMac.routes, supportedKinds: supportedKinds, preferNonLoopback: Self.prefersNonLoopbackRoutes ) - if reconnectRoutes.contains(where: { $0.kind == .iroh }) { - return .ticket(reconnectRoutes) + let requiresIroh = localRoutes.contains { $0.kind == .iroh } + || mac.routes.contains { $0.kind == .iroh } + // Presence may authorize and persist the same registry routes while the + // list request is in flight. That current row is newer than the captured + // candidate and is already scoped to this account/device/instance, so use + // it directly instead of mistaking registry equality for "no route." + let updatedRoutes: [CmxAttachRoute] + if currentMac.routes != mac.routes { + updatedRoutes = currentMac.routes + } else if let registryUpdate = DeviceRegistryService.selectReconnectRoutes( + local: currentMac.routes, + registry: registryRoutes + ) { + updatedRoutes = registryUpdate + } else { + return nil } - // Once this pairing has used Iroh, a cloud refresh that omits Iroh is - // stale or downgraded input. Keep the local Iroh capability pin instead - // of converting a grant failure into raw private-network RPC. - guard !requiresIroh else { return nil } - let refreshed = Self.reconnectHostPortRoutes( + let reconnectRoutes = Self.storedReconnectRoutes( updatedRoutes, supportedKinds: supportedKinds, preferNonLoopback: Self.prefersNonLoopbackRoutes ) - guard !refreshed.isEmpty else { return nil } - let tried = Set(triedRoutes.map { "\($0.host)\u{1F}\($0.port)" }) - let fresh = Set(refreshed.map { "\($0.host)\u{1F}\($0.port)" }) - guard fresh != tried else { return nil } - return .hostPorts(refreshed) + // Once this pairing has used Iroh, a cloud refresh that omits Iroh is + // stale or downgraded input. Keep the local Iroh capability pin instead + // of converting a grant failure into raw private-network RPC. + guard !requiresIroh || reconnectRoutes.contains(where: { $0.kind == .iroh }) else { + return nil + } + return reconnectRoutes.isEmpty ? nil : reconnectRoutes } func shouldResyncTerminalOutputOnForeground() -> Bool { @@ -402,8 +407,3 @@ extension MobileShellComposite { return merged.sorted(by: Self.routeSortsBefore) } } - -enum RefreshedReconnectRoutes { - case ticket([CmxAttachRoute]) - case hostPorts([(host: String, port: Int, routeID: String)]) -} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 6d23ecdf185e..1d4be853e92f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1643,13 +1643,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } guard await isScopeCurrent(scope) else { finishStoredMacReconnectAttempt(generation: generation); return false } let supportedKinds = runtime?.supportedRouteKinds ?? [] - func reachableRoutes(_ mac: MobilePairedMac) -> [(host: String, port: Int, routeID: String)] { - Self.reconnectHostPortRoutes( - mac.routes, - supportedKinds: supportedKinds, - preferNonLoopback: Self.prefersNonLoopbackRoutes - ) - } func storedReconnectRoutes(_ mac: MobilePairedMac) -> [CmxAttachRoute] { Self.storedReconnectRoutes( mac.routes, @@ -1657,9 +1650,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { preferNonLoopback: Self.prefersNonLoopbackRoutes ) } - func hasReachableRoute(_ mac: MobilePairedMac) -> Bool { - !storedReconnectRoutes(mac).isEmpty - } let loadedActiveMac: MobilePairedMac? let loadedMacs: [MobilePairedMac] do { @@ -1681,29 +1671,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } let activeMac = loadedActiveMac.flatMap { forgottenIDs.contains($0.macDeviceID) ? nil : $0 } let allMacs = loadedMacs.filter { !forgottenIDs.contains($0.macDeviceID) } - // Auto-connect target: the explicitly active Mac when it is reachable, - // otherwise the FIRST saved Mac with a usable route. Picking the first - // reachable Mac instead of bailing when nothing is marked active is what - // lets the home come up connected without the user choosing a Mac; the - // other Macs are then aggregated read-only into one integrated list. - // Candidate Macs in priority order: the active Mac first (when it has a - // usable route), then every OTHER saved Mac with a usable route. A - // down/unreachable Mac has a route but fails the connect, so we fall - // through to the next candidate instead of stranding the user on "Mac - // offline" just because their active Mac happens to be off. + // Candidate Macs in priority order: the active Mac first, then every + // other saved Mac. Rows with no locally usable route stay in the list so + // one authenticated registry snapshot can upgrade an older Tailscale + // pairing, or recover a route that was never persisted locally. var candidates: [MobilePairedMac] = [] - if let activeMac, hasReachableRoute(activeMac) { + if let activeMac { candidates.append(activeMac) } candidates.append(contentsOf: allMacs.filter { mac in - mac.macDeviceID != activeMac?.macDeviceID && hasReachableRoute(mac) + mac.macDeviceID != activeMac?.macDeviceID }) guard !candidates.isEmpty else { - // No saved Mac has a usable route right now (none paired, or all - // offline). Clear the hint only when there are truly no saved Macs, so - // the add-device sheet comes up cleanly; otherwise keep it so a Retry - // or network change can reconnect once a Mac comes back. - setHasKnownPairedMac(!allMacs.isEmpty, generation: generation) + setHasKnownPairedMac(false, generation: generation) finishStoredMacReconnectAttempt(generation: generation) return false } @@ -1730,64 +1710,95 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.isReconnectingStoredMac = false self.didFinishStoredMacReconnectAttempt = true } + // The registry endpoint returns every Mac for this account. Load it at + // most once, and only after a local route failed or was intentionally + // skipped. Reusing this immutable snapshot avoids one request per saved + // Mac and prevents one reconnect pass from mixing registry generations. + var didLoadRegistrySnapshot = false + var didSuccessfullyLoadRegistrySnapshot = false + var registrySnapshot: [RegistryDevice]? + func loadRegistrySnapshotIfNeeded() async -> [RegistryDevice]? { + if didLoadRegistrySnapshot { return registrySnapshot } + didLoadRegistrySnapshot = true + guard let deviceRegistry else { return nil } + switch await deviceRegistry.listDevices() { + case .ok(let devices): + didSuccessfullyLoadRegistrySnapshot = true + registrySnapshot = devices + case .authRejected, .transientFailure: + registrySnapshot = nil + } + return registrySnapshot + } + + var firstCandidateNeedsMacUpdate = false // Try each candidate until one connects, so a single offline Mac never // blocks the others. - for mac in candidates { + for (candidateIndex, mac) in candidates.enumerated() { guard generation == storedMacReconnectGeneration, await isScopeCurrent(scope) else { break } let latestForgottenIDs = await forgottenMacDeviceIDs(scope: scope) guard generation == storedMacReconnectGeneration, await isScopeCurrent(scope), !latestForgottenIDs.contains(mac.macDeviceID) else { break } - // Best-effort registry refresh for this Mac in the background. - refreshRoutesFromRegistry(for: mac, scope: scope) - let localRoutes = reachableRoutes(mac) - _ = await connectStoredMac( - name: mac.displayName ?? mac.macDeviceID, - routes: mac.routes, - pairedMacDeviceID: mac.macDeviceID, - instanceTag: mac.instanceTag, - ifStillCurrent: { [weak self] in - self?.storedMacReconnectGeneration == generation - } - ) + let localRoutes = storedReconnectRoutes(mac) + let localHasIroh = localRoutes.contains { $0.kind == .iroh } + let localCanConnectSecurely = localHasIroh + || localRoutes.contains { $0.kind == .debugLoopback } + let isLegacyPrivateNetworkPairing = !mac.routes.contains { $0.kind == .iroh } + && mac.routes.contains { $0.kind == .tailscale } + var foundPublishedIroh = localHasIroh + + // New clients never send a Stack bearer directly over a raw + // Tailscale/TCP route. Such a saved route is a migration hint only; + // consult the authenticated registry for the Mac's Iroh identity. + if localCanConnectSecurely { + _ = await connectStoredMac( + name: mac.displayName ?? mac.macDeviceID, + routes: localRoutes, + pairedMacDeviceID: mac.macDeviceID, + instanceTag: mac.instanceTag, + ifStillCurrent: { [weak self] in + self?.storedMacReconnectGeneration == generation + } + ) + } if connectionState != .connected, - mac.macDeviceID == activeMac?.macDeviceID, let refreshedRoutes = await freshReconnectRoutesAfterLocalFailure( - for: mac, - scope: scope, - triedRoutes: localRoutes + for: mac, + scope: scope, + registryDevices: await loadRegistrySnapshotIfNeeded() ) { - switch refreshedRoutes { - case let .ticket(routes): + let refreshedHasIroh = refreshedRoutes.contains { $0.kind == .iroh } + foundPublishedIroh = foundPublishedIroh || refreshedHasIroh + if refreshedHasIroh || refreshedRoutes.contains(where: { $0.kind == .debugLoopback }) { _ = await connectStoredMac( name: mac.displayName ?? mac.macDeviceID, - routes: routes, + routes: refreshedRoutes, pairedMacDeviceID: mac.macDeviceID, instanceTag: mac.instanceTag, ifStillCurrent: { [weak self] in self?.storedMacReconnectGeneration == generation } ) - case let .hostPorts(routes): - for route in routes { - guard generation == storedMacReconnectGeneration, - await isScopeCurrent(scope) else { break } - await connectStoredMacHost( - name: mac.displayName ?? route.host, - host: route.host, - port: route.port, - pairedMacDeviceID: mac.macDeviceID, - instanceTag: mac.instanceTag) - if connectionState == .connected { break } - } } } if connectionState == .connected { break } + if isLegacyPrivateNetworkPairing, + candidateIndex == candidates.startIndex, + didSuccessfullyLoadRegistrySnapshot, + !foundPublishedIroh { + firstCandidateNeedsMacUpdate = true + } } restoringDeadline.cancel() // A newer attempt may have started during the connect; it now owns the flags. guard generation == storedMacReconnectGeneration else { return false } isReconnectingStoredMac = false didFinishStoredMacReconnectAttempt = true + if connectionState != .connected, + !connectionRequiresReauth, + firstCandidateNeedsMacUpdate { + applyStoredMacUpdateRequiredFailure(disconnect: true) + } return connectionState == .connected } @@ -2184,39 +2195,77 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { supportedKinds: supportedKinds, preferNonLoopback: Self.prefersNonLoopbackRoutes ) - guard !candidateRoutes.isEmpty else { - mobileShellLog.error("switchToMac: no reconnectable route mac=\(macDeviceID, privacy: .private)") - if !hasActiveMacConnection, - await restorePreviousMacIfNeeded( - macSwitchRestoreBaseline ?? previousForegroundMac, - switchAttemptID: switchAttemptID - ) { - macSwitchRestoreBaseline = nil - } - return false + let localHasIroh = candidateRoutes.contains { $0.kind == .iroh } + let localCanConnectSecurely = localHasIroh + || candidateRoutes.contains { $0.kind == .debugLoopback } + let isLegacyPrivateNetworkPairing = !refreshedTarget.routes.contains { $0.kind == .iroh } + && refreshedTarget.routes.contains { $0.kind == .tailscale } + var foundPublishedIroh = localHasIroh + var registryConfirmedMissingIroh = false + + if localCanConnectSecurely { + _ = await connectStoredMac( + name: refreshedTarget.displayName ?? macDeviceID, + routes: candidateRoutes, + pairedMacDeviceID: macDeviceID, + instanceTag: refreshedTarget.instanceTag, + recordsPairingAttempt: true, + ifStillCurrent: { [weak self] in + self?.isCurrentMacSwitchAttempt(switchAttemptID) == true + } + ) } - _ = await connectStoredMac( - name: refreshedTarget.displayName ?? macDeviceID, - routes: candidateRoutes, - pairedMacDeviceID: macDeviceID, - instanceTag: refreshedTarget.instanceTag, - recordsPairingAttempt: true, - ifStillCurrent: { [weak self] in - self?.isCurrentMacSwitchAttempt(switchAttemptID) == true - } - ) guard isCurrentMacSwitchAttempt(switchAttemptID) else { await restoreMacSwitchBaselineIfCancelled(switchAttemptID, fallback: previousForegroundMac) return false } + + var switched = connectionState == .connected + && remoteClient != nil + && foregroundMacDeviceID == macDeviceID + if !switched, let scope, let deviceRegistry { + let registryDevices: [RegistryDevice]? + switch await deviceRegistry.listDevices() { + case .ok(let devices): + registryDevices = devices + registryConfirmedMissingIroh = isLegacyPrivateNetworkPairing + case .authRejected, .transientFailure: + registryDevices = nil + } + if let refreshedRoutes = await freshReconnectRoutesAfterLocalFailure( + for: refreshedTarget, + scope: scope, + registryDevices: registryDevices + ) { + let refreshedHasIroh = refreshedRoutes.contains { $0.kind == .iroh } + foundPublishedIroh = foundPublishedIroh || refreshedHasIroh + registryConfirmedMissingIroh = registryConfirmedMissingIroh && !refreshedHasIroh + if refreshedHasIroh || refreshedRoutes.contains(where: { $0.kind == .debugLoopback }) { + _ = await connectStoredMac( + name: refreshedTarget.displayName ?? macDeviceID, + routes: refreshedRoutes, + pairedMacDeviceID: macDeviceID, + instanceTag: refreshedTarget.instanceTag, + recordsPairingAttempt: true, + ifStillCurrent: { [weak self] in + self?.isCurrentMacSwitchAttempt(switchAttemptID) == true + } + ) + } + } + guard isCurrentMacSwitchAttempt(switchAttemptID) else { + await restoreMacSwitchBaselineIfCancelled(switchAttemptID, fallback: previousForegroundMac) + return false + } + switched = connectionState == .connected + && remoteClient != nil + && foregroundMacDeviceID == macDeviceID + } // The switch succeeded only if the live foreground identity is THIS Mac. // `connect(..., pairedMacDeviceID:)` stamps the foreground state with the // target id after a successful connection, while a superseding switch leaves // a different foreground id. Trust that identity instead of exact host/port // text equality, which can differ across normalized routes. - let switched = connectionState == .connected - && remoteClient != nil - && foregroundMacDeviceID == macDeviceID if switched { macSwitchRestoreBaseline = nil finishMacSwitchAttempt(switchAttemptID) @@ -2242,6 +2291,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macSwitchRestoreBaseline = nil } } + if isLegacyPrivateNetworkPairing, + registryConfirmedMissingIroh, + !foundPublishedIroh { + applyStoredMacUpdateRequiredFailure(disconnect: !hasActiveMacConnection) + } await loadPairedMacs() return false } @@ -5291,6 +5345,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { recordPairingFailed(reason: category.analyticsReason, phase: phase) } + /// Preserve an existing saved pairing while explaining the one migration + /// step the Mac still needs. This is deliberately distinct from an auth + /// failure: signing out or deleting the pairing cannot make an older Mac + /// publish an Iroh identity, and the same saved row becomes usable as soon + /// as the Mac updates and republishes through the authenticated registry. + private func applyStoredMacUpdateRequiredFailure(disconnect: Bool) { + applyPairingFailure(.macUpdateRequired, phase: "migration") + connectionRequiresReauth = false + guard disconnect else { return } + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + } + private func applyPairingValidationFailure(_ category: MobilePairingFailureCategory) { if pairingAttemptMethod == nil { _ = beginPairingValidationAttempt(method: "qr") @@ -7399,7 +7467,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return false } switch connectionError { - case .attachTicketExpired, .authorizationFailed, .accountMismatch, .insecureManualRoute: + case .attachTicketExpired, .authorizationFailed, .accountMismatch: return true case let .rpcError(code, message): let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -7413,7 +7481,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { || normalizedMessage.contains("invalid token") || normalizedMessage.contains("expired token") || normalizedMessage.contains("token expired") - case .invalidResponse, .connectionClosed, .requestTimedOut: + case .invalidResponse, .connectionClosed, .requestTimedOut, .insecureManualRoute: return false } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift index e6f160c30e96..63f90ca861df 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift @@ -238,6 +238,7 @@ import Testing .ticketExpired, .invalidCode, .unrecognizedVersion, + .macUpdateRequired, .unsupportedRoute, .noSupportedRoute, .unknown(host: "h", port: 1), @@ -248,6 +249,17 @@ import Testing _ = route } + @Test func macUpdateRequiredPreservesTheSavedPairing() { + let category = MobilePairingFailureCategory.macUpdateRequired + + #expect(category.analyticsReason == "mac_update_required") + #expect(!category.isAuthorizationFailure) + #expect(category.message.localizedCaseInsensitiveContains("Update cmux")) + #expect(category.guidance?.localizedCaseInsensitiveContains("reconnect automatically") == true) + #expect(category.guidance?.localizedCaseInsensitiveContains("do not need to sign out") == true) + #expect(category.guidance?.localizedCaseInsensitiveContains("pair again") == true) + } + @Test func invalidCodeNoLongerMentionsAPairingCode() { // There is no pairing-code secret anymore (the v2 QR carries bare routes // and the host authorizes by Stack account). The copy must not imply a diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift index 73fd834a9896..ae5051846875 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift @@ -121,10 +121,7 @@ struct DisconnectedWorkspaceShellView: View { ) { Button(L10n.string("mobile.common.ok", defaultValue: "OK"), role: .cancel) {} } message: { - Text(L10n.string( - "mobile.disconnected.connectFailedMessage", - defaultValue: "Make sure the computer is awake and online, then try again." - )) + Text(connectFailedMessage) } #endif } @@ -262,6 +259,21 @@ struct DisconnectedWorkspaceShellView: View { ) } + /// The reconnect attempt owns the store's latest classified failure. Show + /// it with its guidance, falling back only when no specific reason exists. + private var connectFailedMessage: String { + if let failure = MobileDisconnectedFailureCopy.combine( + error: store?.connectionError, + guidance: store?.connectionErrorGuidance + ) { + return failure + } + return L10n.string( + "mobile.disconnected.connectFailedMessage", + defaultValue: "Make sure the computer is awake and online, then try again." + ) + } + private func removalConfirmationBinding(for deviceID: String) -> Binding { Binding( get: { computerPendingRemovalID == deviceID }, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 7b0b3284054d..a914514763cf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -261,7 +261,7 @@ struct WorkspaceListView: View { "mobile.loading.timeout.message", defaultValue: "cmux could not finish restoring this session. Check that the selected cmux build is running, then retry or add this computer again." ) - : nil, + : disconnectedConnectionFailureDescription, retry: initialConnectionTimedOut ? retryInitialConnection : nil, addDevice: initialConnectionTimedOut ? showAddDevice : nil, reconnect: reconnect @@ -493,6 +493,17 @@ struct WorkspaceListView: View { ) } + /// Prefer the classified migration/reconnect failure over the generic + /// unavailable description. Guidance stays attached to its headline so a + /// saved legacy pairing never looks like an account or QR failure. + var disconnectedConnectionFailureDescription: String? { + guard connectionStatus == .unavailable else { return nil } + return MobileDisconnectedFailureCopy.combine( + error: store?.connectionError, + guidance: store?.connectionErrorGuidance + ) + } + private func updateMachineSnapshots(_ snapshots: WorkspaceMachineSnapshots) { if machineSnapshots != snapshots { machineSnapshots = snapshots @@ -652,3 +663,16 @@ struct WorkspaceListView: View { } } + +/// Keeps the classified headline and its recovery guidance together anywhere +/// the disconnected shell presents a failure. +enum MobileDisconnectedFailureCopy { + static func combine(error: String?, guidance: String?) -> String? { + let parts = [error, guidance].compactMap { value -> String? in + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + return parts.isEmpty ? nil : parts.joined(separator: "\n\n") + } +} diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index 8a30ecd658c1..ad10c19d9734 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -156,11 +156,16 @@ final class MobileAttachTicketStore { for ticket: CmxAttachTicket, routeDisclosureMode: CmxPairingRouteDisclosureMode ) throws -> URL { - // Released iOS clients understand the compact v2 Tailscale grammar. - // Keep that representation only for an explicitly requested legacy - // compatibility code. The default pairing window requests - // `.irohIdentityOnly`, which falls through to the compact v1 envelope - // below and contains the Mac's EndpointID without any path hints. + // Frozen iOS builds predate either the compact short-key v1 payload or + // the bare-route v2 grammar. Give those clients the original full-key + // v1 ticket, restricted to Tailscale and stripped of its attach token. + // New/default pairing requests `.irohIdentityOnly`, so this branch + // never changes the EndpointID-only Iroh representation. + if routeDisclosureMode == .legacyPrivateNetworkCompatibility, + ticket.routes.contains(where: { $0.kind == .tailscale }) { + return try legacyPrivateNetworkAttachURL(for: ticket) + } + if let pairingURL = CmxPairingQRCode().encode( ticket, routeDisclosureMode: routeDisclosureMode @@ -186,6 +191,46 @@ final class MobileAttachTicketStore { return url } + private func legacyPrivateNetworkAttachURL(for ticket: CmxAttachTicket) throws -> URL { + let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale } + guard !tailscaleRoutes.isEmpty else { + throw MobileAttachTicketStoreError.invalidAttachURL + } + + // Historical clients require an expiry field and the oldest supported + // decoder rejects the whole QR after that date. Give this tokenless + // compatibility payload a synthetic far-future expiry so the displayed + // QR follows the current never-expiring pairing contract. Authorization + // still comes exclusively from the old client's Stack bearer. + let legacyTicket = try CmxAttachTicket( + version: ticket.version, + workspaceID: ticket.workspaceID, + terminalID: ticket.terminalID, + macDeviceID: ticket.macDeviceID, + macDisplayName: ticket.macDisplayName, + // A public QR does not need a human-readable account identifier. + // Newer legacy clients can preflight with the opaque user id, while + // every supported version is authorized by the host after connect. + macUserEmail: nil, + macUserID: ticket.macUserID, + macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion, + macAppVersion: ticket.macAppVersion, + macAppBuild: ticket.macAppBuild, + routes: tailscaleRoutes, + expiresAt: Date(timeIntervalSince1970: 4_102_444_800), + authToken: nil + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let payload = Self.base64URLEncode(try encoder.encode(legacyTicket)) + guard let url = URL( + string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)" + ) else { + throw MobileAttachTicketStoreError.invalidAttachURL + } + return url + } + private func attachURL( for ticket: CmxAttachTicket, target: MobileAttachTarget, diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index efdd83662e69..58b8a4e6ce29 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -416,6 +416,10 @@ final class MobileHostService { /// User-default key for the opt-in Mac-side iOS pairing listener. nonisolated static let listeningEnabledDefaultsKey = SettingCatalog().mobile.iOSPairingHost.userDefaultsKey + /// Key written by released builds before the setting moved into the + /// canonical settings catalog. Read only as a migration fallback. + nonisolated private static let legacyListeningEnabledDefaultsKey = "cmuxMobilePairingHostEnabled" + /// Whether the mobile pairing host should bind a network listener at all. /// /// Defaults off in every build so macOS does not ask for Local Network @@ -440,6 +444,9 @@ final class MobileHostService { if let override = defaults.object(forKey: listeningEnabledDefaultsKey) as? Bool { return override } + if let legacyOverride = defaults.object(forKey: legacyListeningEnabledDefaultsKey) as? Bool { + return legacyOverride + } return SettingCatalog().mobile.iOSPairingHost.defaultValue } @@ -694,6 +701,7 @@ final class MobileHostService { nonisolated private static func canPublishRoutesWithoutListenerForXCTest(defaults: UserDefaults) -> Bool { guard isRunningUnderXCTest else { return false } return defaults.object(forKey: listeningEnabledDefaultsKey) == nil + && defaults.object(forKey: legacyListeningEnabledDefaultsKey) == nil } private func publishRoutesWithoutListenerForXCTest() { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 65c940eeae51..0e198284f3e5 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3452,6 +3452,23 @@ } } }, + "mobile.pairing.guidance.macUpdateRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your saved computer will reconnect automatically after the Mac publishes its Iroh route. You do not need to sign out or pair again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "MacがIrohルートを公開すると、保存済みのコンピュータに自動的に再接続します。サインアウトや再ペアリングは不要です。" + } + } + } + }, "mobile.pairing.guidance.openMacApp": { "extractionState": "manual", "localizations": { @@ -3588,6 +3605,23 @@ } } }, + "mobile.pairing.macUpdateRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update cmux on this Mac to connect securely." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全に接続するには、このMacのcmuxをアップデートしてください。" + } + } + } + }, "mobile.pairing.loopbackRejected": { "extractionState": "manual", "localizations": { From 2aad5d794a2f864573b0bbf80a97cc29af2c5d2b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 15:03:33 -0700 Subject: [PATCH 07/14] fix: restore Bonsplit fork availability API --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index f647ae39b343..50a03f96582d 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f647ae39b34383808648b68dd43090143c6cd7cb +Subproject commit 50a03f96582d2b10a747b8c7aac62017e6edd7df From 1ecd0d42b46bf0833d3b192b772248dee5a42561 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 15:33:48 -0700 Subject: [PATCH 08/14] build(ios): lock Iroh in workspace resolution --- .../xcshareddata/swiftpm/Package.resolved | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index d61053e7e034..505700b66295 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "6104d2cb8b5660da4a5b51b4e654037e3bc623e8e169544d7f9c329f6afe6789", + "originHash" : "d9134cf84862c709f8ed0ba2bf6030fe66ea77a67fec9d41b789ed6c8eaf10a0", "pins" : [ + { + "identity" : "iroh-ffi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/manaflow-ai/iroh-ffi.git", + "state" : { + "revision" : "31ec35c931028937db1910b6d623a3e078eb91da", + "version" : "1.0.2-cmux.2" + } + }, { "identity" : "sentry-cocoa", "kind" : "remoteSourceControl", From c171bc36fde205fd46f3765b2e30b3dcc686216e Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 16:16:45 -0700 Subject: [PATCH 09/14] test: cover reconnect migration review gaps --- .../DeviceRegistryListParsingTests.swift | 13 +++ .../IrohReconnectRouteSelectionTests.swift | 108 ++++++++++++++++++ .../MobilePairingFailureTests.swift | 1 + 3 files changed, 122 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift index 89c043cae266..323883b8fc6f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift @@ -105,6 +105,19 @@ import Testing #expect(devices.first?.instances.first?.hasRoutes == false) } + @Test func instanceTagIsNormalizedBeforeAuthorityMatching() throws { + let json = """ + { "teamId": "t", "devices": [ + { "deviceId": "CCCC3333-3333-4333-8333-333333333333", "platform": "mac", + "instances": [ { "tag": " stable ", "routes": [] } ] } + ] } + """.data(using: .utf8)! + + let devices = try #require(DeviceRegistryService.parseDeviceList(in: json)) + + #expect(devices.first?.instances.first?.tag == "stable") + } + @Test func malformedEnvelopeReturnsNil() { #expect(DeviceRegistryService.parseDeviceList(in: Data("not json".utf8)) == nil) #expect(DeviceRegistryService.parseDeviceList(in: Data("{}".utf8)) == nil) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index d7b3e80474c9..b0653a40f831 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -209,6 +209,114 @@ extension ReconnectRouteSelectionTests { #expect(persisted.routes.contains { $0.id == iroh.id }) } + @Test func concurrentPresenceUpgradeWinsWhenRegistrySnapshotHasNoMatchingDevice() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + await router.setHostIdentity(deviceID: "test-mac", instanceTag: "stable") + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let iroh = try registryIroh( + id: "iroh-presence", + endpointID: String(repeating: "f", count: 64) + ) + let legacy = try tailscale(51_008) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [legacy], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + let registry = PresenceRacingDeviceRegistry( + pairedStore: pairedStore, + routes: [iroh, legacy], + outcome: .ok([]), + now: clock.now.addingTimeInterval(1) + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(store.activeRoute?.id == iroh.id) + #expect(factory.attemptedKinds() == [.iroh]) + #expect(store.connectionError?.localizedCaseInsensitiveContains("update cmux") != true) + } + + @Test func ambiguousRegistryAuthorityDoesNotClaimTheMacNeedsAnUpdate() async throws { + let clock = TestClock() + let runtime = LivenessTestRuntime( + transportFactory: KindRecordingTransportFactory( + router: LivenessHostRouter(), + box: TransportBox() + ), + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let legacy = try tailscale(51_009) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([ + RegistryDevice( + deviceId: "test-mac", + platform: "mac", + displayName: "Test Mac", + lastSeenAt: clock.now, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: [try registryIroh( + id: "iroh-a", + endpointID: String(repeating: "a", count: 64) + )], + lastSeenAt: clock.now + ), + RegistryAppInstance( + tag: "stable", + routes: [try registryIroh( + id: "iroh-b", + endpointID: String(repeating: "b", count: 64) + )], + lastSeenAt: clock.now + ), + ] + ), + ])) + let (pairedStore, directory) = try makePairedMacStore() + defer { try? FileManager.default.removeItem(at: directory) } + try await pairedStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [legacy], + instanceTag: "stable", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + + #expect(!(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))) + let copy = [store.connectionError, store.connectionErrorGuidance] + .compactMap { $0 } + .joined(separator: " ") + #expect(!copy.localizedCaseInsensitiveContains("update cmux")) + } + @Test func switchToLegacySavedMacUpgradesFromRegistryWithoutRescan() async throws { let clock = TestClock() let router = LivenessHostRouter() diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift index 63f90ca861df..50984d9e0d28 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift @@ -258,6 +258,7 @@ import Testing #expect(category.guidance?.localizedCaseInsensitiveContains("reconnect automatically") == true) #expect(category.guidance?.localizedCaseInsensitiveContains("do not need to sign out") == true) #expect(category.guidance?.localizedCaseInsensitiveContains("pair again") == true) + #expect(category.guidance?.localizedCaseInsensitiveContains("Iroh") != true) } @Test func invalidCodeNoLongerMentionsAPairingCode() { From f8914fce60896268c02f563d6cdb96a7813b052d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 16:20:37 -0700 Subject: [PATCH 10/14] test: cover reconnect snapshot ownership --- .../IrohReconnectRouteSelectionTests.swift | 153 +++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index b0653a40f831..8302a4a3777d 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -142,6 +142,71 @@ extension ReconnectRouteSelectionTests { #expect(upgraded.routes.contains { $0.id == "iroh-b" }) } + @Test func reconnectManyLegacyMacsReadsOnePostRegistryStoreSnapshot() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory( + router: router, + box: box, + failingKinds: [.iroh] + ) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let macCount = 32 + var records: [MobilePairedMac] = [] + var devices: [RegistryDevice] = [] + for index in 0.. MobileShellComposite { From f16568cb1e488f04d6663c66a3f4ef569ea66e74 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 16:33:24 -0700 Subject: [PATCH 11/14] fix: make reconnect migration authoritative and linear --- .../DeviceRegistryService.swift | 66 +++++++-- .../MobilePairingFailure.swift | 2 +- ...MobileShellComposite+ReconnectRoutes.swift | 133 ++++++++++++++---- .../MobileShellComposite.swift | 114 +++++++-------- .../DisconnectedWorkspaceShellView.swift | 4 +- .../CmuxMobileShellUI/WorkspaceListView.swift | 11 +- ios/cmux/Resources/Localizable.xcstrings | 4 +- 7 files changed, 225 insertions(+), 109 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index f24865c8a959..0f9c03be1690 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -243,9 +243,9 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { let deviceId = device.deviceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !deviceId.isEmpty else { return nil } let instances = (device.instances ?? []).map { instance in - RegistryAppInstance( - tag: instance.tag?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - ? instance.tag! : "default", + let tag = instance.tag?.trimmingCharacters(in: .whitespacesAndNewlines) + return RegistryAppInstance( + tag: tag?.isEmpty == false ? tag! : "default", routes: (instance.routes ?? []).compactMap(\.value), lastSeenAt: Self.parseTimestamp(instance.lastSeenAt) ) @@ -289,17 +289,11 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { pairedMacInstanceTag: String? = nil, in devices: [RegistryDevice] ) -> [CmxAttachRoute]? { - let target = macDeviceID.lowercased() - guard let device = devices.first(where: { $0.deviceId.lowercased() == target }) else { - return nil - } - let candidates = if let pairedMacInstanceTag { - device.instances.filter { $0.tag == pairedMacInstanceTag } - } else { - device.instances - } - let nonEmpty = candidates.map(\.routes).filter { !$0.isEmpty } - return nonEmpty.count == 1 ? nonEmpty[0] : nil + guard case .unique(let routes) = DeviceRegistryRouteIndex(devices: devices).resolve( + macDeviceID: macDeviceID, + instanceTag: pairedMacInstanceTag + ) else { return nil } + return routes } /// Decode the `/api/devices` list response and return authoritative routes @@ -344,3 +338,47 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return request } } + +/// Exact, immutable authority lookup for one authenticated registry generation. +/// Building it once keeps a reconnect pass linear even with many saved Macs. +struct DeviceRegistryRouteIndex: Sendable { + private let devicesByID: [String: [RegistryDevice]] + + init(devices: [RegistryDevice]) { + devicesByID = Dictionary(grouping: devices) { device in + Self.normalizedDeviceID(device.deviceId) + } + } + + func resolve( + macDeviceID: String, + instanceTag: String? + ) -> DeviceRegistryRouteResolution { + let matches = devicesByID[Self.normalizedDeviceID(macDeviceID)] ?? [] + guard !matches.isEmpty else { return .missing } + guard matches.count == 1, let device = matches.first else { return .ambiguous } + + let instances: [RegistryAppInstance] + if let expectedTag = MobileMacInstanceTagAuthority.normalized(instanceTag) { + instances = device.instances.filter { + MobileMacInstanceTagAuthority.normalized($0.tag) == expectedTag + } + } else { + instances = device.instances + } + let nonEmptyRoutes = instances.map(\.routes).filter { !$0.isEmpty } + guard !nonEmptyRoutes.isEmpty else { return .missing } + guard nonEmptyRoutes.count == 1 else { return .ambiguous } + return .unique(nonEmptyRoutes[0]) + } + + private static func normalizedDeviceID(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +enum DeviceRegistryRouteResolution: Equatable, Sendable { + case unique([CmxAttachRoute]) + case missing + case ambiguous +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index 69c4f703ec2d..fa45d0ed42a1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -334,7 +334,7 @@ extension MobilePairingFailureCategory { case .macUpdateRequired: return L10n.string( "mobile.pairing.guidance.macUpdateRequired", - defaultValue: "Your saved computer will reconnect automatically after the Mac publishes its Iroh route. You do not need to sign out or pair again." + defaultValue: "Your saved computer will reconnect automatically after you update cmux on the Mac. You do not need to sign out or pair again." ) case .invalidCode, .loopbackRejected, .cancelled, .unknown: return nil diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index 001a679960dd..14673561b270 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -9,6 +9,50 @@ private let reconnectRouteLog = Logger( category: "MobileReconnectRoutes" ) +enum ReconnectRouteRefreshOutcome: Sendable { + case refreshedRoutes([CmxAttachRoute]) + case confirmedMissingIroh + case inconclusive +} + +struct ReconnectRefreshSnapshot: Sendable { + private struct Authority: Hashable, Sendable { + let deviceID: String + let instanceTag: String? + + init(deviceID: String, instanceTag: String?) { + self.deviceID = deviceID + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + self.instanceTag = MobileMacInstanceTagAuthority.normalized(instanceTag) + } + } + + private let pairedMacsByAuthority: [Authority: [MobilePairedMac]] + private let registryRoutes: DeviceRegistryRouteIndex? + + init(pairedMacs: [MobilePairedMac], registryDevices: [RegistryDevice]?) { + pairedMacsByAuthority = Dictionary(grouping: pairedMacs) { + Authority(deviceID: $0.macDeviceID, instanceTag: $0.instanceTag) + } + registryRoutes = registryDevices.map(DeviceRegistryRouteIndex.init(devices:)) + } + + func currentMac(for captured: MobilePairedMac) -> MobilePairedMac? { + let matches = pairedMacsByAuthority[ + Authority(deviceID: captured.macDeviceID, instanceTag: captured.instanceTag) + ] ?? [] + return matches.count == 1 ? matches[0] : nil + } + + func registryResolution(for captured: MobilePairedMac) -> DeviceRegistryRouteResolution? { + registryRoutes?.resolve( + macDeviceID: captured.macDeviceID, + instanceTag: captured.instanceTag + ) + } +} + @MainActor extension MobileShellComposite { /// Supported routes for reconnecting an already-paired Mac. @@ -164,29 +208,49 @@ extension MobileShellComposite { lastBackgroundedAt = runtime?.now() ?? Date() } + func loadReconnectRefreshSnapshot( + scope: MobileShellScopeSnapshot + ) async -> ReconnectRefreshSnapshot? { + guard await isScopeCurrent(scope) else { return nil } + let registryDevices: [RegistryDevice]? + if let deviceRegistry { + switch await deviceRegistry.listDevices() { + case .ok(let devices): + registryDevices = devices + case .authRejected, .transientFailure: + registryDevices = nil + } + } else { + registryDevices = nil + } + guard await isScopeCurrent(scope), + let pairedMacStore, + let pairedMacs = try? await pairedMacStore.loadAll( + stackUserID: scope.userID, + teamID: scope.teamID + ), + await isScopeCurrent(scope) else { + return nil + } + return ReconnectRefreshSnapshot( + pairedMacs: pairedMacs, + registryDevices: registryDevices + ) + } + func freshReconnectRoutesAfterLocalFailure( for mac: MobilePairedMac, scope: MobileShellScopeSnapshot, - registryDevices: [RegistryDevice]? - ) async -> [CmxAttachRoute]? { + snapshot: ReconnectRefreshSnapshot? + ) async -> ReconnectRouteRefreshOutcome { let supportedKinds = runtime?.supportedRouteKinds ?? [] - guard let registryDevices, + guard let snapshot, await isScopeCurrent(scope), await !isForgottenMacDeviceID(mac.macDeviceID, scope: scope), - let registryRoutes = DeviceRegistryService.routes( - forMacDeviceID: mac.macDeviceID, - pairedMacInstanceTag: mac.instanceTag, - in: registryDevices - ), - let pairedMacStore, - let currentMac = try? await pairedMacStore.loadAll( - stackUserID: scope.userID, - teamID: scope.teamID - ).first(where: { $0.macDeviceID == mac.macDeviceID }), - currentMac.instanceTag == mac.instanceTag, + let currentMac = snapshot.currentMac(for: mac), await isScopeCurrent(scope), await !isForgottenMacDeviceID(mac.macDeviceID, scope: scope) else { - return nil + return .inconclusive } let localRoutes = Self.storedReconnectRoutes( currentMac.routes, @@ -199,16 +263,31 @@ extension MobileShellComposite { // list request is in flight. That current row is newer than the captured // candidate and is already scoped to this account/device/instance, so use // it directly instead of mistaking registry equality for "no route." - let updatedRoutes: [CmxAttachRoute] if currentMac.routes != mac.routes { - updatedRoutes = currentMac.routes - } else if let registryUpdate = DeviceRegistryService.selectReconnectRoutes( + let reconnectRoutes = Self.storedReconnectRoutes( + currentMac.routes, + supportedKinds: supportedKinds, + preferNonLoopback: Self.prefersNonLoopbackRoutes + ) + if !reconnectRoutes.isEmpty, + !requiresIroh || reconnectRoutes.contains(where: { $0.kind == .iroh }) { + return .refreshedRoutes(reconnectRoutes) + } + } + + guard case .unique(let registryRoutes) = snapshot.registryResolution(for: mac) else { + return .inconclusive + } + let registryHasIroh = registryRoutes.contains { $0.kind == .iroh } + let isLegacyPrivateNetworkPairing = !mac.routes.contains { $0.kind == .iroh } + && mac.routes.contains { $0.kind == .tailscale } + guard let updatedRoutes = DeviceRegistryService.selectReconnectRoutes( local: currentMac.routes, registry: registryRoutes - ) { - updatedRoutes = registryUpdate - } else { - return nil + ) else { + return isLegacyPrivateNetworkPairing && !registryHasIroh + ? .confirmedMissingIroh + : .inconclusive } let reconnectRoutes = Self.storedReconnectRoutes( updatedRoutes, @@ -219,9 +298,15 @@ extension MobileShellComposite { // stale or downgraded input. Keep the local Iroh capability pin instead // of converting a grant failure into raw private-network RPC. guard !requiresIroh || reconnectRoutes.contains(where: { $0.kind == .iroh }) else { - return nil + return .inconclusive + } + if !reconnectRoutes.isEmpty, + reconnectRoutes.contains(where: { $0.kind == .iroh || $0.kind == .debugLoopback }) { + return .refreshedRoutes(reconnectRoutes) } - return reconnectRoutes.isEmpty ? nil : reconnectRoutes + return isLegacyPrivateNetworkPairing && !registryHasIroh + ? .confirmedMissingIroh + : .inconclusive } func shouldResyncTerminalOutputOnForeground() -> Bool { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 1d4be853e92f..9c94f2581aad 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1710,28 +1710,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.isReconnectingStoredMac = false self.didFinishStoredMacReconnectAttempt = true } - // The registry endpoint returns every Mac for this account. Load it at - // most once, and only after a local route failed or was intentionally - // skipped. Reusing this immutable snapshot avoids one request per saved - // Mac and prevents one reconnect pass from mixing registry generations. - var didLoadRegistrySnapshot = false - var didSuccessfullyLoadRegistrySnapshot = false - var registrySnapshot: [RegistryDevice]? - func loadRegistrySnapshotIfNeeded() async -> [RegistryDevice]? { - if didLoadRegistrySnapshot { return registrySnapshot } - didLoadRegistrySnapshot = true - guard let deviceRegistry else { return nil } - switch await deviceRegistry.listDevices() { - case .ok(let devices): - didSuccessfullyLoadRegistrySnapshot = true - registrySnapshot = devices - case .authRejected, .transientFailure: - registrySnapshot = nil - } - return registrySnapshot - } - - var firstCandidateNeedsMacUpdate = false + // Capture one coherent post-request view of the registry and paired-Mac + // store. The store read happens after the registry await, so an + // authenticated Presence write that lands during the request wins. The + // immutable indexes are then reused for every candidate, keeping this + // reconnect pass linear and on one authority generation. + var didLoadRefreshSnapshot = false + var refreshSnapshot: ReconnectRefreshSnapshot? + func loadRefreshSnapshotIfNeeded() async -> ReconnectRefreshSnapshot? { + if didLoadRefreshSnapshot { return refreshSnapshot } + didLoadRefreshSnapshot = true + refreshSnapshot = await loadReconnectRefreshSnapshot(scope: scope) + return refreshSnapshot + } + + var firstCandidateNeedingMacUpdateID: String? // Try each candidate until one connects, so a single offline Mac never // blocks the others. for (candidateIndex, mac) in candidates.enumerated() { @@ -1745,7 +1738,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { || localRoutes.contains { $0.kind == .debugLoopback } let isLegacyPrivateNetworkPairing = !mac.routes.contains { $0.kind == .iroh } && mac.routes.contains { $0.kind == .tailscale } - var foundPublishedIroh = localHasIroh // New clients never send a Stack bearer directly over a raw // Tailscale/TCP route. Such a saved route is a migration hint only; @@ -1761,15 +1753,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } ) } - if connectionState != .connected, - let refreshedRoutes = await freshReconnectRoutesAfterLocalFailure( - for: mac, - scope: scope, - registryDevices: await loadRegistrySnapshotIfNeeded() - ) { - let refreshedHasIroh = refreshedRoutes.contains { $0.kind == .iroh } - foundPublishedIroh = foundPublishedIroh || refreshedHasIroh - if refreshedHasIroh || refreshedRoutes.contains(where: { $0.kind == .debugLoopback }) { + if connectionState != .connected { + switch await freshReconnectRoutesAfterLocalFailure( + for: mac, + scope: scope, + snapshot: await loadRefreshSnapshotIfNeeded() + ) { + case .refreshedRoutes(let refreshedRoutes): _ = await connectStoredMac( name: mac.displayName ?? mac.macDeviceID, routes: refreshedRoutes, @@ -1779,15 +1769,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self?.storedMacReconnectGeneration == generation } ) + case .confirmedMissingIroh: + if isLegacyPrivateNetworkPairing, + candidateIndex == candidates.startIndex { + firstCandidateNeedingMacUpdateID = mac.macDeviceID + } + case .inconclusive: + break } } if connectionState == .connected { break } - if isLegacyPrivateNetworkPairing, - candidateIndex == candidates.startIndex, - didSuccessfullyLoadRegistrySnapshot, - !foundPublishedIroh { - firstCandidateNeedsMacUpdate = true - } } restoringDeadline.cancel() // A newer attempt may have started during the connect; it now owns the flags. @@ -1796,8 +1787,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { didFinishStoredMacReconnectAttempt = true if connectionState != .connected, !connectionRequiresReauth, - firstCandidateNeedsMacUpdate { - applyStoredMacUpdateRequiredFailure(disconnect: true) + let firstCandidateNeedingMacUpdateID { + let latestForgottenIDs = await forgottenMacDeviceIDs(scope: scope) + if generation == storedMacReconnectGeneration, + await isScopeCurrent(scope), + !latestForgottenIDs.contains(firstCandidateNeedingMacUpdateID) { + applyStoredMacUpdateRequiredFailure(disconnect: true) + } } return connectionState == .connected } @@ -2200,8 +2196,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { || candidateRoutes.contains { $0.kind == .debugLoopback } let isLegacyPrivateNetworkPairing = !refreshedTarget.routes.contains { $0.kind == .iroh } && refreshedTarget.routes.contains { $0.kind == .tailscale } - var foundPublishedIroh = localHasIroh - var registryConfirmedMissingIroh = false + var refreshOutcome = ReconnectRouteRefreshOutcome.inconclusive if localCanConnectSecurely { _ = await connectStoredMac( @@ -2223,24 +2218,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { var switched = connectionState == .connected && remoteClient != nil && foregroundMacDeviceID == macDeviceID - if !switched, let scope, let deviceRegistry { - let registryDevices: [RegistryDevice]? - switch await deviceRegistry.listDevices() { - case .ok(let devices): - registryDevices = devices - registryConfirmedMissingIroh = isLegacyPrivateNetworkPairing - case .authRejected, .transientFailure: - registryDevices = nil - } - if let refreshedRoutes = await freshReconnectRoutesAfterLocalFailure( + if !switched, let scope { + refreshOutcome = await freshReconnectRoutesAfterLocalFailure( for: refreshedTarget, scope: scope, - registryDevices: registryDevices - ) { - let refreshedHasIroh = refreshedRoutes.contains { $0.kind == .iroh } - foundPublishedIroh = foundPublishedIroh || refreshedHasIroh - registryConfirmedMissingIroh = registryConfirmedMissingIroh && !refreshedHasIroh - if refreshedHasIroh || refreshedRoutes.contains(where: { $0.kind == .debugLoopback }) { + snapshot: await loadReconnectRefreshSnapshot(scope: scope) + ) + if case .refreshedRoutes(let refreshedRoutes) = refreshOutcome { _ = await connectStoredMac( name: refreshedTarget.displayName ?? macDeviceID, routes: refreshedRoutes, @@ -2251,7 +2235,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self?.isCurrentMacSwitchAttempt(switchAttemptID) == true } ) - } } guard isCurrentMacSwitchAttempt(switchAttemptID) else { await restoreMacSwitchBaselineIfCancelled(switchAttemptID, fallback: previousForegroundMac) @@ -2292,9 +2275,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } if isLegacyPrivateNetworkPairing, - registryConfirmedMissingIroh, - !foundPublishedIroh { - applyStoredMacUpdateRequiredFailure(disconnect: !hasActiveMacConnection) + case .confirmedMissingIroh = refreshOutcome, + let scope, + isCurrentMacSwitchAttempt(switchAttemptID), + await isScopeCurrent(scope) { + let latestForgottenIDs = await forgottenMacDeviceIDs(scope: scope) + if isCurrentMacSwitchAttempt(switchAttemptID), + await isScopeCurrent(scope), + !latestForgottenIDs.contains(macDeviceID) { + applyStoredMacUpdateRequiredFailure(disconnect: !hasActiveMacConnection) + } } await loadPairedMacs() return false diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift index ae5051846875..f9bbca7d261a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift @@ -262,10 +262,10 @@ struct DisconnectedWorkspaceShellView: View { /// The reconnect attempt owns the store's latest classified failure. Show /// it with its guidance, falling back only when no specific reason exists. private var connectFailedMessage: String { - if let failure = MobileDisconnectedFailureCopy.combine( + if let failure = MobileDisconnectedFailureCopy( error: store?.connectionError, guidance: store?.connectionErrorGuidance - ) { + ).combined { return failure } return L10n.string( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index a914514763cf..fdde86eefc5a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -498,10 +498,10 @@ struct WorkspaceListView: View { /// saved legacy pairing never looks like an account or QR failure. var disconnectedConnectionFailureDescription: String? { guard connectionStatus == .unavailable else { return nil } - return MobileDisconnectedFailureCopy.combine( + return MobileDisconnectedFailureCopy( error: store?.connectionError, guidance: store?.connectionErrorGuidance - ) + ).combined } private func updateMachineSnapshots(_ snapshots: WorkspaceMachineSnapshots) { @@ -666,8 +666,11 @@ struct WorkspaceListView: View { /// Keeps the classified headline and its recovery guidance together anywhere /// the disconnected shell presents a failure. -enum MobileDisconnectedFailureCopy { - static func combine(error: String?, guidance: String?) -> String? { +struct MobileDisconnectedFailureCopy { + let error: String? + let guidance: String? + + var combined: String? { let parts = [error, guidance].compactMap { value -> String? in guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 0e198284f3e5..296d5a2d65f2 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3458,13 +3458,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your saved computer will reconnect automatically after the Mac publishes its Iroh route. You do not need to sign out or pair again." + "value": "Your saved computer will reconnect automatically after you update cmux on the Mac. You do not need to sign out or pair again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacがIrohルートを公開すると、保存済みのコンピュータに自動的に再接続します。サインアウトや再ペアリングは不要です。" + "value": "Macのcmuxをアップデートすると、保存済みのコンピュータに自動的に再接続します。サインアウトや再ペアリングは不要です。" } } } From 9d7c8b364c89fc060791353b1694835e0a6d6973 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 18:50:44 -0700 Subject: [PATCH 12/14] test: cover final reconnect authority races --- .../DelayedTeamPairedMacStore.swift | 33 ++- .../IrohReconnectRouteSelectionTests.swift | 199 ++++++++++++++++++ 2 files changed, 227 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift index fb7d837c4359..58a4c08ca1a5 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift @@ -10,6 +10,11 @@ actor DelayedTeamPairedMacStore: MobilePairedMacStoring { private var blockers: [String: CheckedContinuation] = [:] private var upsertCount = 0 private var loadAllCount = 0 + private var recordReplacement: ( + afterLoadAllCount: Int, + teamKey: String, + records: [MobilePairedMac] + )? private var upsertWaiters: [(Int, CheckedContinuation)] = [] private var gatedUpsertIDs: Set = [] private var upsertStartedIDs: Set = [] @@ -142,12 +147,22 @@ actor DelayedTeamPairedMacStore: MobilePairedMacStoring { blockers[key] = continuation } } - let scoped = recordsByTeam[key] ?? [] - guard key != "" else { return scoped } - let legacyTeamless = (recordsByTeam[""] ?? []).filter { mac in - mac.stackUserID == nil || mac.stackUserID == stackUserID + let result: [MobilePairedMac] + if key.isEmpty { + result = recordsByTeam[key] ?? [] + } else { + let scoped = recordsByTeam[key] ?? [] + let legacyTeamless = (recordsByTeam[""] ?? []).filter { mac in + mac.stackUserID == nil || mac.stackUserID == stackUserID + } + result = scoped + legacyTeamless + } + if let recordReplacement, + loadAllCount == recordReplacement.afterLoadAllCount { + recordsByTeam[recordReplacement.teamKey] = recordReplacement.records + self.recordReplacement = nil } - return scoped + legacyTeamless + return result } func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? { nil } @@ -219,6 +234,14 @@ actor DelayedTeamPairedMacStore: MobilePairedMacStoring { loadAllCount = 0 } + func replaceRecords( + afterLoadAllCount: Int, + teamID: String?, + with records: [MobilePairedMac] + ) { + recordReplacement = (afterLoadAllCount, teamID ?? "", records) + } + func currentLoadAllCount() -> Int { loadAllCount } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift index 8302a4a3777d..bab60a5dfbc2 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift @@ -320,6 +320,205 @@ extension ReconnectRouteSelectionTests { #expect(store.connectionError?.localizedCaseInsensitiveContains("update cmux") != true) } + @Test func changedTailscaleOnlyRowIsNeverReturnedAsAReconnectRoute() async throws { + let clock = TestClock() + let runtime = LivenessTestRuntime( + transportFactory: KindRecordingTransportFactory( + router: LivenessHostRouter(), + box: TransportBox() + ), + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let captured = MobilePairedMac( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [try tailscale(51_012)], + createdAt: clock.now, + lastSeenAt: clock.now, + isActive: true, + stackUserID: "user-1", + instanceTag: "stable" + ) + var current = captured + current.routes = [try tailscale(51_013)] + current.lastSeenAt = clock.now.addingTimeInterval(1) + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [current]], + blockedTeams: [] + ) + let registryDevice = RegistryDevice( + deviceId: current.macDeviceID, + platform: "mac", + displayName: current.displayName, + lastSeenAt: current.lastSeenAt, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: current.routes, + lastSeenAt: current.lastSeenAt + ), + ] + ) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: SnapshotCountingDeviceRegistry(outcome: .ok([registryDevice])), + runtime: runtime + ) + let scope = try #require(await store.currentScopeSnapshot(userID: "user-1")) + let outcome = await store.freshReconnectRoutesAfterLocalFailure( + for: captured, + scope: scope, + snapshot: ReconnectRefreshSnapshot( + pairedMacs: [current], + registryDevices: [registryDevice] + ) + ) + + guard case .confirmedMissingIroh = outcome else { + Issue.record("Expected an authenticated missing-Iroh result") + return + } + } + + @Test func reconnectRevalidatesMissingIrohBeforeShowingUpdateGuidance() async throws { + let clock = TestClock() + let factory = KindRecordingTransportFactory( + router: LivenessHostRouter(), + box: TransportBox() + ) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let legacy = MobilePairedMac( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [try tailscale(51_014)], + createdAt: clock.now, + lastSeenAt: clock.now, + isActive: true, + stackUserID: "user-1", + instanceTag: "stable" + ) + var upgraded = legacy + upgraded.routes = [ + try registryIroh( + id: "iroh-current", + endpointID: String(repeating: "a", count: 64) + ), + legacy.routes[0], + ] + upgraded.lastSeenAt = clock.now.addingTimeInterval(1) + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [legacy]], + blockedTeams: [] + ) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([ + RegistryDevice( + deviceId: legacy.macDeviceID, + platform: "mac", + displayName: legacy.displayName, + lastSeenAt: legacy.lastSeenAt, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: legacy.routes, + lastSeenAt: legacy.lastSeenAt + ), + ] + ), + ])) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + await pairedStore.resetLoadAllCount() + await pairedStore.replaceRecords( + afterLoadAllCount: 2, + teamID: nil, + with: [upgraded] + ) + + #expect(!(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))) + #expect(factory.attemptedKinds().isEmpty) + let copy = [store.connectionError, store.connectionErrorGuidance] + .compactMap { $0 } + .joined(separator: " ") + #expect(!copy.localizedCaseInsensitiveContains("update cmux")) + } + + @Test func switchRevalidatesMissingIrohBeforeShowingUpdateGuidance() async throws { + let clock = TestClock() + let factory = KindRecordingTransportFactory( + router: LivenessHostRouter(), + box: TransportBox() + ) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { clock.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let legacy = MobilePairedMac( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [try tailscale(51_015)], + createdAt: clock.now, + lastSeenAt: clock.now, + isActive: true, + stackUserID: "user-1", + instanceTag: "stable" + ) + var upgraded = legacy + upgraded.routes = [ + try registryIroh( + id: "iroh-current", + endpointID: String(repeating: "b", count: 64) + ), + legacy.routes[0], + ] + upgraded.lastSeenAt = clock.now.addingTimeInterval(1) + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [legacy]], + blockedTeams: [] + ) + let registry = SnapshotCountingDeviceRegistry(outcome: .ok([ + RegistryDevice( + deviceId: legacy.macDeviceID, + platform: "mac", + displayName: legacy.displayName, + lastSeenAt: legacy.lastSeenAt, + instances: [ + RegistryAppInstance( + tag: "stable", + routes: legacy.routes, + lastSeenAt: legacy.lastSeenAt + ), + ] + ), + ])) + let store = await makeMigrationShell( + pairedStore: pairedStore, + registry: registry, + runtime: runtime + ) + await pairedStore.resetLoadAllCount() + await pairedStore.replaceRecords( + afterLoadAllCount: 2, + teamID: nil, + with: [upgraded] + ) + + #expect(!(await store.switchToMac(macDeviceID: "test-mac"))) + #expect(factory.attemptedKinds().isEmpty) + let copy = [store.connectionError, store.connectionErrorGuidance] + .compactMap { $0 } + .joined(separator: " ") + #expect(!copy.localizedCaseInsensitiveContains("update cmux")) + } + @Test func ambiguousRegistryAuthorityDoesNotClaimTheMacNeedsAnUpdate() async throws { let clock = TestClock() let runtime = LivenessTestRuntime( From f7f7141751c3940384d8d94522e4ad98b5b30c8f Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 18:52:49 -0700 Subject: [PATCH 13/14] fix: revalidate reconnect migration authority --- ...MobileShellComposite+ReconnectRoutes.swift | 30 ++++++++++++++++++- .../MobileShellComposite.swift | 24 ++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index 14673561b270..abe588e9e845 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -238,6 +238,32 @@ extension MobileShellComposite { ) } + /// Re-read one exact account/device/instance row immediately before + /// presenting legacy-Mac migration guidance. A registry snapshot can become + /// stale while Presence persists an Iroh route, so only the current paired + /// store may authorize that user-facing conclusion. + func isCurrentLegacyPrivateNetworkPairing( + _ captured: MobilePairedMac, + scope: MobileShellScopeSnapshot + ) async -> Bool { + guard await isScopeCurrent(scope), + let pairedMacStore, + let pairedMacs = try? await pairedMacStore.loadAll( + stackUserID: scope.userID, + teamID: scope.teamID + ), + await isScopeCurrent(scope), + let currentMac = ReconnectRefreshSnapshot( + pairedMacs: pairedMacs, + registryDevices: nil + ).currentMac(for: captured), + await !isForgottenMacDeviceID(captured.macDeviceID, scope: scope) else { + return false + } + return currentMac.routes.contains { $0.kind == .tailscale } + && !currentMac.routes.contains { $0.kind == .iroh } + } + func freshReconnectRoutesAfterLocalFailure( for mac: MobilePairedMac, scope: MobileShellScopeSnapshot, @@ -270,7 +296,9 @@ extension MobileShellComposite { preferNonLoopback: Self.prefersNonLoopbackRoutes ) if !reconnectRoutes.isEmpty, - !requiresIroh || reconnectRoutes.contains(where: { $0.kind == .iroh }) { + reconnectRoutes.contains(where: { + $0.kind == .iroh || $0.kind == .debugLoopback + }) { return .refreshedRoutes(reconnectRoutes) } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 9c94f2581aad..312e6a0102de 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1724,7 +1724,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return refreshSnapshot } - var firstCandidateNeedingMacUpdateID: String? + var firstCandidateNeedingMacUpdate: MobilePairedMac? // Try each candidate until one connects, so a single offline Mac never // blocks the others. for (candidateIndex, mac) in candidates.enumerated() { @@ -1772,7 +1772,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { case .confirmedMissingIroh: if isLegacyPrivateNetworkPairing, candidateIndex == candidates.startIndex { - firstCandidateNeedingMacUpdateID = mac.macDeviceID + firstCandidateNeedingMacUpdate = mac } case .inconclusive: break @@ -1787,11 +1787,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { didFinishStoredMacReconnectAttempt = true if connectionState != .connected, !connectionRequiresReauth, - let firstCandidateNeedingMacUpdateID { + let firstCandidateNeedingMacUpdate { + let isStillLegacy = await isCurrentLegacyPrivateNetworkPairing( + firstCandidateNeedingMacUpdate, + scope: scope + ) let latestForgottenIDs = await forgottenMacDeviceIDs(scope: scope) if generation == storedMacReconnectGeneration, await isScopeCurrent(scope), - !latestForgottenIDs.contains(firstCandidateNeedingMacUpdateID) { + connectionState != .connected, + !connectionRequiresReauth, + isStillLegacy, + !latestForgottenIDs.contains(firstCandidateNeedingMacUpdate.macDeviceID) { applyStoredMacUpdateRequiredFailure(disconnect: true) } } @@ -2279,9 +2286,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let scope, isCurrentMacSwitchAttempt(switchAttemptID), await isScopeCurrent(scope) { + let isStillLegacy = await isCurrentLegacyPrivateNetworkPairing( + refreshedTarget, + scope: scope + ) let latestForgottenIDs = await forgottenMacDeviceIDs(scope: scope) if isCurrentMacSwitchAttempt(switchAttemptID), await isScopeCurrent(scope), + !connectionRequiresReauth, + !(connectionState == .connected + && remoteClient != nil + && foregroundMacDeviceID == macDeviceID), + isStillLegacy, !latestForgottenIDs.contains(macDeviceID) { applyStoredMacUpdateRequiredFailure(disconnect: !hasActiveMacConnection) } From 84fc14f6821361d0269b077e6099162d6cf76926 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 18:56:49 -0700 Subject: [PATCH 14/14] refactor: centralize legacy pairing codec --- .../CmxLegacyPrivateNetworkPairingCode.swift | 48 ++++++++++ ...LegacyPrivateNetworkPairingCodeTests.swift | 95 +++++++++++++++++++ .../CmuxMobileShellUI/WorkspaceListView.swift | 2 +- Sources/Mobile/MobileAttachTicketStore.swift | 45 +-------- 4 files changed, 148 insertions(+), 42 deletions(-) create mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift create mode 100644 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift new file mode 100644 index 000000000000..f21437813beb --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Encodes the full-key v1 pairing payload required by released iOS clients +/// that predate the compact ticket and bare-route grammars. +public struct CmxLegacyPrivateNetworkPairingCode: Sendable { + /// The compatibility payload is non-authorizing, so its synthetic expiry + /// only prevents historical decoders from rejecting a displayed code. + private static let compatibilityExpiry = Date(timeIntervalSince1970: 4_102_444_800) + + /// Creates the stateless compatibility encoder. + public init() {} + + /// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the + /// ticket has no Tailscale route to disclose. + public func encode(_ ticket: CmxAttachTicket) throws -> URL? { + let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale } + guard !tailscaleRoutes.isEmpty else { return nil } + + let legacyTicket = try CmxAttachTicket( + version: ticket.version, + workspaceID: ticket.workspaceID, + terminalID: ticket.terminalID, + macDeviceID: ticket.macDeviceID, + macDisplayName: ticket.macDisplayName, + macUserEmail: nil, + macUserID: ticket.macUserID, + macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion, + macAppVersion: ticket.macAppVersion, + macAppBuild: ticket.macAppBuild, + routes: tailscaleRoutes, + expiresAt: Self.compatibilityExpiry, + authToken: nil + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let payload = base64URLEncode(try encoder.encode(legacyTicket)) + return URL( + string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)" + ) + } + + private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift new file mode 100644 index 000000000000..f9e57ebeb3c2 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import CMUXMobileCore + +@Suite struct CmxLegacyPrivateNetworkPairingCodeTests { + @Test func encodesTokenlessTailscaleOnlyFullKeyPayload() throws { + let tailscale = try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.5", port: 58_465), + priority: 10 + ) + let iroh = try CmxAttachRoute( + id: "iroh", + kind: .iroh, + endpoint: .peer( + identity: CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ), + pathHints: [] + ), + priority: 0 + ) + let sourceExpiry = Date(timeIntervalSince1970: 1_800_000_000) + let ticket = try CmxAttachTicket( + version: CmxAttachTicket.currentVersion, + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-1", + macDisplayName: "Mac", + macUserEmail: "private@example.com", + macUserID: "opaque-user-id", + macPairingCompatibilityVersion: 1, + macAppVersion: "1.0", + macAppBuild: "100", + routes: [iroh, tailscale], + expiresAt: sourceExpiry, + authToken: "secret" + ) + + let encodedURL = try CmxLegacyPrivateNetworkPairingCode().encode(ticket) + let url = try #require(encodedURL) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let encoded = try #require( + components.queryItems?.first(where: { $0.name == "payload" })?.value + ) + let data = try #require(Self.decodeBase64URL(encoded)) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(CmxAttachTicket.self, from: data) + + #expect(decoded.routes == [tailscale]) + #expect(decoded.authToken == nil) + #expect(decoded.macUserEmail == nil) + #expect(decoded.macUserID == "opaque-user-id") + #expect(try #require(decoded.expiresAt) > sourceExpiry.addingTimeInterval(365 * 24 * 60 * 60)) + } + + @Test func returnsNilWithoutTailscaleRoute() throws { + let ticket = try CmxAttachTicket( + version: CmxAttachTicket.currentVersion, + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-1", + macDisplayName: "Mac", + macUserEmail: nil, + macUserID: "opaque-user-id", + routes: [ + try CmxAttachRoute( + id: "iroh", + kind: .iroh, + endpoint: .peer( + identity: CmxIrohPeerIdentity( + endpointID: String(repeating: "b", count: 64) + ), + pathHints: [] + ), + priority: 0 + ), + ], + expiresAt: nil, + authToken: nil + ) + + #expect(try CmxLegacyPrivateNetworkPairingCode().encode(ticket) == nil) + } + + private static func decodeBase64URL(_ value: String) -> Data? { + var normalized = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + normalized += String(repeating: "=", count: (4 - normalized.count % 4) % 4) + return Data(base64Encoded: normalized) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index fdde86eefc5a..4abe847dfa53 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -496,7 +496,7 @@ struct WorkspaceListView: View { /// Prefer the classified migration/reconnect failure over the generic /// unavailable description. Guidance stays attached to its headline so a /// saved legacy pairing never looks like an account or QR failure. - var disconnectedConnectionFailureDescription: String? { + private var disconnectedConnectionFailureDescription: String? { guard connectionStatus == .unavailable else { return nil } return MobileDisconnectedFailureCopy( error: store?.connectionError, diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index ad10c19d9734..5a78a5ef8b27 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -163,7 +163,10 @@ final class MobileAttachTicketStore { // never changes the EndpointID-only Iroh representation. if routeDisclosureMode == .legacyPrivateNetworkCompatibility, ticket.routes.contains(where: { $0.kind == .tailscale }) { - return try legacyPrivateNetworkAttachURL(for: ticket) + guard let url = try CmxLegacyPrivateNetworkPairingCode().encode(ticket) else { + throw MobileAttachTicketStoreError.invalidAttachURL + } + return url } if let pairingURL = CmxPairingQRCode().encode( @@ -191,46 +194,6 @@ final class MobileAttachTicketStore { return url } - private func legacyPrivateNetworkAttachURL(for ticket: CmxAttachTicket) throws -> URL { - let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale } - guard !tailscaleRoutes.isEmpty else { - throw MobileAttachTicketStoreError.invalidAttachURL - } - - // Historical clients require an expiry field and the oldest supported - // decoder rejects the whole QR after that date. Give this tokenless - // compatibility payload a synthetic far-future expiry so the displayed - // QR follows the current never-expiring pairing contract. Authorization - // still comes exclusively from the old client's Stack bearer. - let legacyTicket = try CmxAttachTicket( - version: ticket.version, - workspaceID: ticket.workspaceID, - terminalID: ticket.terminalID, - macDeviceID: ticket.macDeviceID, - macDisplayName: ticket.macDisplayName, - // A public QR does not need a human-readable account identifier. - // Newer legacy clients can preflight with the opaque user id, while - // every supported version is authorized by the host after connect. - macUserEmail: nil, - macUserID: ticket.macUserID, - macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion, - macAppVersion: ticket.macAppVersion, - macAppBuild: ticket.macAppBuild, - routes: tailscaleRoutes, - expiresAt: Date(timeIntervalSince1970: 4_102_444_800), - authToken: nil - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let payload = Self.base64URLEncode(try encoder.encode(legacyTicket)) - guard let url = URL( - string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)" - ) else { - throw MobileAttachTicketStoreError.invalidAttachURL - } - return url - } - private func attachURL( for ticket: CmxAttachTicket, target: MobileAttachTarget,