Skip to content

Commit 7652d3b

Browse files
Fix iOS deleted computer recovery empty state (#8712)
* Fix deleted computer recovery empty state * Centralize deleted computer recovery state * Tighten deleted computer recovery outcomes * Fail closed on unknown deleted-computer recovery state * Close team-switch recovery race * Keep recovery button busy through reload
1 parent 38b8ca7 commit 7652d3b

7 files changed

Lines changed: 463 additions & 100 deletions

File tree

Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ForgottenMacRecovery.swift

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import CMUXMobileCore
22
import CmuxMobilePairedMac
33
import Foundation
44

5+
/// Result of an explicit, user-triggered deleted-computer recovery attempt.
6+
public enum MobileDeletedComputerRecoveryResult: Equatable, Sendable {
7+
/// A forgotten Mac was found through same-account Iroh discovery and persisted again.
8+
case recovered
9+
/// No eligible forgotten Mac was live for the current account/team scope.
10+
case notFound
11+
/// A previous recovery attempt is still running, so this tap did not start another scan.
12+
case alreadyInProgress
13+
/// The account or team changed while recovery was running.
14+
case staleScope
15+
}
16+
517
@MainActor
618
extension MobileShellComposite {
719
/// Recover a deleted Mac through live same-account Iroh discovery.
@@ -12,26 +24,30 @@ extension MobileShellComposite {
1224
/// connection path must authenticate the Mac's device ID and app-instance tag
1325
/// before persistence clears the forgotten marker.
1426
@discardableResult
15-
public func recoverForgottenIrohMacFromAccount() async -> Bool {
27+
public func recoverForgottenIrohMacFromAccount() async -> MobileDeletedComputerRecoveryResult {
28+
guard !isRecoveringDeletedComputer else { return .alreadyInProgress }
29+
isRecoveringDeletedComputer = true
30+
defer { isRecoveringDeletedComputer = false }
31+
1632
guard isSignedIn,
1733
let scope = await currentScopeSnapshot(),
18-
let personalIrohDiscovery else { return false }
34+
let personalIrohDiscovery else { return .notFound }
1935
let forgottenIDs = await forgottenMacDeviceIDs(scope: scope)
20-
guard !forgottenIDs.isEmpty else { return false }
36+
guard !forgottenIDs.isEmpty else { return .notFound }
2137

2238
connectionRecoveryOwner.cancel()
2339
applyConnectionRecoveryOwnerState()
2440
invalidateStoredMacReconnectAttempt()
2541

2642
let discovered = await personalIrohDiscovery.discoverLiveMacs()
27-
guard await isScopeCurrent(scope) else { return false }
43+
guard await isScopeCurrent(scope) else { return .staleScope }
2844
let candidates = forgottenIrohRecoveryCandidates(
2945
from: discovered,
3046
forgottenIDs: forgottenIDs
3147
)
3248

3349
for mac in candidates {
34-
guard await isScopeCurrent(scope) else { return false }
50+
guard await isScopeCurrent(scope) else { return .staleScope }
3551
guard await isForgottenMacDeviceID(
3652
mac.deviceID,
3753
instanceTag: mac.instanceTag,
@@ -43,15 +59,17 @@ extension MobileShellComposite {
4359
ifStillCurrent: { [weak self] in
4460
guard let self else { return false }
4561
return self.isSignedIn
62+
&& self.secondaryAggregationScopeGeneration == scope.generation
4663
&& self.identityProvider?.currentUserID == scope.userID
4764
}
4865
)
66+
guard await isScopeCurrent(scope) else { return .staleScope }
4967
guard recovered else { continue }
5068
await loadPairedMacs()
5169
await loadRegistryDevices()
52-
return true
70+
return .recovered
5371
}
54-
return false
72+
return .notFound
5573
}
5674

5775
private func forgottenIrohRecoveryCandidates(

Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
12591259
storedPairedMacs = []
12601260
pairedMacAliasIDsByRepresentativeID = [:]
12611261
pairedMacs = []
1262+
pairedMacLoadState = .notLoaded
12621263
hasRecoverableDeletedComputers = false
12631264
resetTerminalThemes()
12641265
// Likewise drop the registry-backed device tree so a shared device never
@@ -1371,6 +1372,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
13711372
storedPairedMacs = []
13721373
pairedMacAliasIDsByRepresentativeID = [:]
13731374
pairedMacs = []
1375+
pairedMacLoadState = .notLoaded
13741376
forgottenMacDeviceIDsByScope = [:]
13751377
hasRecoverableDeletedComputers = false
13761378
registryDevices = []
@@ -2107,6 +2109,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
21072109

21082110
// MARK: - Paired Mac switching
21092111

2112+
/// Whether the current signed-in scope's paired-Mac list is known.
2113+
public enum PairedMacLoadState: Equatable, Sendable {
2114+
/// No load has completed for the current scope.
2115+
case notLoaded
2116+
/// The current scope's paired-Mac list loaded successfully.
2117+
case loaded
2118+
/// The current scope's paired-Mac list could not be loaded.
2119+
case failed
2120+
}
2121+
21102122
/// Every Mac paired with this device, for the host switcher. Refreshed via
21112123
/// ``loadPairedMacs()`` and after switch/forget. Cleared on sign-out so a
21122124
/// shared device never shows the previous user's Macs. The active row is
@@ -2122,6 +2134,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
21222134

21232135
/// Full store rows for identity-sensitive paths; ``pairedMacs`` is display-coalesced.
21242136
private var storedPairedMacs: [MobilePairedMac] = []
2137+
/// Load status for ``pairedMacs`` in the current signed-in account/team scope.
2138+
public internal(set) var pairedMacLoadState: PairedMacLoadState = .notLoaded
21252139
/// Visible representative id to all stored ids for that logical paired Mac.
21262140
public private(set) var pairedMacAliasIDsByRepresentativeID: [String: [String]] = [:]
21272141
/// Same-session delete tombstones keyed by signed-in account/team scope.
@@ -2136,6 +2150,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
21362150
/// True when the current account/team scope has a deleted-computer marker
21372151
/// that can be recovered through explicit same-account Iroh discovery.
21382152
public internal(set) var hasRecoverableDeletedComputers = false
2153+
/// True while the explicit deleted-computer recovery path is scanning and
2154+
/// reconnecting through account-scoped Iroh discovery.
2155+
public internal(set) var isRecoveringDeletedComputer = false
21392156

21402157
var pairedMacsForIdentityMatching: [MobilePairedMac] {
21412158
storedPairedMacs.isEmpty ? pairedMacs : storedPairedMacs
@@ -2370,14 +2387,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
23702387
storedPairedMacs = []
23712388
pairedMacAliasIDsByRepresentativeID = [:]
23722389
pairedMacs = []
2390+
pairedMacLoadState = .failed
23732391
hasRecoverableDeletedComputers = false
23742392
return
23752393
}
2394+
pairedMacLoadState = .notLoaded
23762395
let loaded: [MobilePairedMac]
23772396
do {
23782397
loaded = try await pairedMacStore.loadAll(stackUserID: scope.userID, teamID: scope.teamID)
23792398
} catch {
23802399
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
2400+
if await isScopeCurrent(scope) {
2401+
pairedMacLoadState = .failed
2402+
hasRecoverableDeletedComputers = false
2403+
}
23812404
return
23822405
}
23832406
// The await above suspended the main actor; a sign-out, user switch, or
@@ -2392,6 +2415,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
23922415
return
23932416
}
23942417
hasRecoverableDeletedComputers = hasForgottenMacs
2418+
pairedMacLoadState = .loaded
23952419
storedPairedMacs = visibleLoaded
23962420
let supportedRouteKinds = runtime?.supportedRouteKinds ?? []
23972421
let coalesced = Self.coalescePairedMacsByDialEndpoint(

Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ struct IrohZeroTouchDiscoveryTests {
9898

9999
await fixture.shell.loadPairedMacs()
100100
#expect(fixture.shell.hasRecoverableDeletedComputers)
101-
#expect(await fixture.shell.recoverForgottenIrohMacFromAccount())
101+
#expect(await fixture.shell.recoverForgottenIrohMacFromAccount() == .recovered)
102102

103103
#expect(fixture.shell.connectionState == .connected)
104104
#expect(fixture.factory.attemptedRouteIDs() == ["iroh-mac-a"])
@@ -141,7 +141,7 @@ struct IrohZeroTouchDiscoveryTests {
141141

142142
await fixture.shell.loadPairedMacs()
143143
#expect(fixture.shell.hasRecoverableDeletedComputers)
144-
#expect(await fixture.shell.recoverForgottenIrohMacFromAccount())
144+
#expect(await fixture.shell.recoverForgottenIrohMacFromAccount() == .recovered)
145145

146146
#expect(fixture.factory.attemptedRouteIDs() == ["iroh-mac-a"])
147147
let rows = try await fixture.store.loadAll(stackUserID: "user-1", teamID: nil)
@@ -163,7 +163,7 @@ struct IrohZeroTouchDiscoveryTests {
163163
scope: scope
164164
)
165165

166-
#expect(!(await fixture.shell.recoverForgottenIrohMacFromAccount()))
166+
#expect(await fixture.shell.recoverForgottenIrohMacFromAccount() == .notFound)
167167

168168
#expect(fixture.shell.connectionState == .disconnected)
169169
#expect(fixture.factory.attemptedRouteIDs() == ["iroh-mac-a"])
@@ -175,6 +175,86 @@ struct IrohZeroTouchDiscoveryTests {
175175
))
176176
}
177177

178+
@Test
179+
func concurrentExplicitRecoveryReturnsAlreadyInProgress() async throws {
180+
let live = try candidate(deviceID: "mac-a", endpointByte: "a")
181+
let discovery = SuspendedIrohDiscovery(candidates: [live])
182+
let fixture = try await makeFixture(
183+
discovery: discovery,
184+
reportedDeviceID: "mac-a"
185+
)
186+
defer { fixture.cleanup() }
187+
let scope = try #require(await fixture.shell.currentScopeSnapshot(userID: "user-1"))
188+
await fixture.shell.rememberForgottenMacDeviceID(
189+
MobilePairedMac.pairingID(macDeviceID: "mac-a", instanceTag: "stable"),
190+
scope: scope
191+
)
192+
let firstRecovery = Task { @MainActor in
193+
await fixture.shell.recoverForgottenIrohMacFromAccount()
194+
}
195+
await discovery.waitUntilRequested()
196+
197+
#expect(await fixture.shell.recoverForgottenIrohMacFromAccount() == .alreadyInProgress)
198+
199+
discovery.resume()
200+
#expect(await firstRecovery.value == .recovered)
201+
#expect(fixture.factory.attemptedRouteIDs() == ["iroh-mac-a"])
202+
}
203+
204+
@Test
205+
func signOutWhileExplicitRecoveryIsSuspendedReturnsStaleScope() async throws {
206+
let live = try candidate(deviceID: "mac-a", endpointByte: "a")
207+
let discovery = SuspendedIrohDiscovery(candidates: [live])
208+
let fixture = try await makeFixture(
209+
discovery: discovery,
210+
reportedDeviceID: "mac-a"
211+
)
212+
defer { fixture.cleanup() }
213+
let scope = try #require(await fixture.shell.currentScopeSnapshot(userID: "user-1"))
214+
await fixture.shell.rememberForgottenMacDeviceID(
215+
MobilePairedMac.pairingID(macDeviceID: "mac-a", instanceTag: "stable"),
216+
scope: scope
217+
)
218+
let recovery = Task { @MainActor in
219+
await fixture.shell.recoverForgottenIrohMacFromAccount()
220+
}
221+
await discovery.waitUntilRequested()
222+
223+
fixture.shell.signOut()
224+
discovery.resume()
225+
226+
#expect(await recovery.value == .staleScope)
227+
#expect(fixture.factory.attemptedRouteIDs().isEmpty)
228+
#expect(try await fixture.store.loadAll(stackUserID: "user-1", teamID: nil).isEmpty)
229+
}
230+
231+
@Test
232+
func teamSwitchWhileExplicitRecoveryIsSuspendedReturnsStaleScope() async throws {
233+
let live = try candidate(deviceID: "mac-a", endpointByte: "a")
234+
let discovery = SuspendedIrohDiscovery(candidates: [live])
235+
let fixture = try await makeFixture(
236+
discovery: discovery,
237+
reportedDeviceID: "mac-a"
238+
)
239+
defer { fixture.cleanup() }
240+
let scope = try #require(await fixture.shell.currentScopeSnapshot(userID: "user-1"))
241+
await fixture.shell.rememberForgottenMacDeviceID(
242+
MobilePairedMac.pairingID(macDeviceID: "mac-a", instanceTag: "stable"),
243+
scope: scope
244+
)
245+
let recovery = Task { @MainActor in
246+
await fixture.shell.recoverForgottenIrohMacFromAccount()
247+
}
248+
await discovery.waitUntilRequested()
249+
250+
fixture.shell.currentTeamDidChange()
251+
discovery.resume()
252+
253+
#expect(await recovery.value == .staleScope)
254+
#expect(fixture.factory.attemptedRouteIDs().isEmpty)
255+
#expect(try await fixture.store.loadAll(stackUserID: "user-1", teamID: nil).isEmpty)
256+
}
257+
178258
@Test
179259
func unreachableCandidateFallsThroughToNextLiveMac() async throws {
180260
let first = try candidate(deviceID: "mac-a", endpointByte: "a")

0 commit comments

Comments
 (0)