From 828f9ebd5c5ef3e91cf00d56a7173fc07066eb22 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:47:04 -0700 Subject: [PATCH 1/2] test(ios): preserve workspace selection across reconnect --- .../IrohConnectionRecoveryOwnerTests.swift | 24 +++++++++++ ...leShellRenderGridLivenessTestSupport.swift | 40 +++++++++++-------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift index 6e8abb9c04b0..2329520fc7d3 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift @@ -34,6 +34,30 @@ extension ReconnectRouteSelectionTests { #expect(attemptedKinds.allSatisfy { $0 == .iroh }) } + @Test func sameMacEventStreamRecoveryPreservesSelectedWorkspace() async throws { + let fixture = try await makeRecoveryOwnerFixture() + defer { fixture.release() } + await fixture.router.setWorkspaceIDs(["cmux-master", "hevy-cli"]) + + #expect(await fixture.store.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(await fixture.router.waitForCount(of: "mobile.events.subscribe", atLeast: 1)) + let firstClient = try #require(fixture.store.remoteClient) + let hevyWorkspace = try #require( + fixture.store.workspaces.first { $0.rpcWorkspaceID.rawValue == "hevy-cli" } + ) + fixture.store.selectedWorkspaceID = hevyWorkspace.id + let firstTransport = try #require(fixture.box.get()) + + await firstTransport.close() + + #expect(try await pollUntil { + guard let replacement = fixture.store.remoteClient else { return false } + return replacement !== firstClient + && fixture.store.connectionState == .connected + }) + #expect(fixture.store.selectedWorkspace?.rpcWorkspaceID.rawValue == "hevy-cli") + } + @Test func recoveryWaitsForOldPhysicalTransportBeforeRedialing() async throws { let closeGate = LivenessTransportCloseGate() let fixture = try await makeRecoveryOwnerFixture( diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 83b185da2280..167172624b80 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -81,6 +81,7 @@ actor LivenessHostRouter { private var macInstanceTag: String? = "default" private var macDisplayName: String? = "Test Mac" private var workspaceListResponseHook: (@Sendable () -> Void)? + private var workspaceIDs = ["live-workspace"] private var workspaceListTitles: [String] = [] /// FIFO of scripted `mobile.sync.fetch` results (state sync v2 tests). private var syncFetchResults: [[String: Any]] = [] @@ -336,6 +337,10 @@ actor LivenessHostRouter { workspaceListTitles.append(contentsOf: titles) } + func setWorkspaceIDs(_ workspaceIDs: [String]) { + self.workspaceIDs = workspaceIDs + } + func scriptNotificationFeedRevisions(_ revisions: [Int]) { notificationFeedRevisions.append(contentsOf: revisions) } @@ -483,24 +488,27 @@ actor LivenessHostRouter { message: "scripted workspace list failure" ) } - return try? Self.resultFrame(id: id, result: [ - "workspaces": [ - [ - "id": "live-workspace", - "title": workspaceTitle, - "current_directory": "/Users/test/project", - "is_selected": true, - "terminals": [ - [ - "id": "live-terminal", - "title": "Terminal", - "current_directory": "/Users/test/project", - "is_ready": true, - "is_focused": true, - ], + let workspaces: [[String: Any]] = workspaceIDs.enumerated().map { index, workspaceID in + [ + "id": workspaceID, + "title": index == 0 ? workspaceTitle : workspaceID, + "current_directory": "/Users/test/project", + "is_selected": index == 0, + "terminals": [ + [ + "id": workspaceID == "live-workspace" + ? "live-terminal" + : "\(workspaceID)-terminal", + "title": "Terminal", + "current_directory": "/Users/test/project", + "is_ready": true, + "is_focused": true, ], ], - ], + ] + } + return try? Self.resultFrame(id: id, result: [ + "workspaces": workspaces, ]) case "mobile.host.status": hostStatusRequestCount += 1 From 314c45ce4ece936dc793b3d551e704fa98662a43 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:59:42 -0700 Subject: [PATCH 2/2] fix(ios): preserve workspace selection on reconnect --- .../CmuxMobileShell/MobileShellComposite.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index add10e841596..9923df33f3df 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1167,6 +1167,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// recovery-scoped UI attributes flags to the exact pairing being /// redialed, never a healthy sibling build on the same physical Mac. private(set) var recoveryTargetInstanceTag: String? + /// The foreground pairing that a new connection attempt replaces. During + /// recovery the live identity is intentionally cleared before redial, so + /// the retained target remains the ownership authority for distinguishing + /// a same-Mac reconnect from a real Mac switch. + private var foregroundOrRecoveryMacKey: MacPairingKey { + guard foregroundMacDeviceID == nil, + let recoveryTargetMacDeviceID else { + return foregroundMacKey + } + return MacPairingKey( + macDeviceID: recoveryTargetMacDeviceID, + instanceTag: recoveryTargetInstanceTag + ) + } /// Compatibility view over registry entries whose role is `.control`. var secondaryMacSubscriptions: MobileMacConnectionRegistry.ControlSubscriptions { macConnectionRegistry.controlSubscriptions @@ -8020,7 +8034,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { .trimmingCharacters(in: .whitespacesAndNewlines) let requestedMacDeviceID = pairedMacDeviceID ?? (ticketMacDeviceID.isEmpty ? nil : ticketMacDeviceID) - let previousForegroundKeyBeforeConnect = foregroundMacKey + let previousForegroundKeyBeforeConnect = foregroundOrRecoveryMacKey let currentFocusedConnection: MacConnection? = foregroundMacDeviceID.flatMap { macID in guard let connection = connections[macID],