diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index f6a97416..af03a048 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -288,24 +288,10 @@ struct YatteeApp: App { // Refresh media source password status (Keychain state can change in background) appEnvironment.mediaSourcesManager.refreshPasswordStoredStatus() - // Retry a credential identity scope that never resolved (e.g. - // launched offline) - account credentials stay unavailable until it does + // Identity, statuses, deletion repair and the fetch, in that + // order — each one is what makes the next one correct. Task { - await appEnvironment.credentialIdentityScope.resolveIfNeeded() - // A credential can arrive over iCloud Keychain without the - // scope moving, so statuses are recomputed unconditionally. - appEnvironment.refreshAccountStatuses() - } - - // Repair a context whose account was deleted on another device - // while this one was in the background. Only a deletion moves - // it — an account that merely has not synced yet is left alone. - appEnvironment.dataContextValidator.correctIfContextRemoved() - SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment) - - // Fetch remote CloudKit changes (catches missed push notifications) - Task { - await appEnvironment.cloudKitSync.fetchRemoteChanges() + await resumeAccountState() } // Start periodic polling as fallback for missed push notifications @@ -346,29 +332,15 @@ struct YatteeApp: App { // Refresh media source password status (Keychain state can change in background) appEnvironment.mediaSourcesManager.refreshPasswordStoredStatus() - // Retry a credential identity scope that never resolved (e.g. - // launched offline) - account credentials stay unavailable until it does + // Identity, statuses, deletion repair and the fetch, in that + // order — each one is what makes the next one correct. Task { - await appEnvironment.credentialIdentityScope.resolveIfNeeded() - // A credential can arrive over iCloud Keychain without the - // scope moving, so statuses are recomputed unconditionally. - appEnvironment.refreshAccountStatuses() + await resumeAccountState() } - // Repair a context whose account was deleted on another device - // while this one was in the background. Only a deletion moves - // it — an account that merely has not synced yet is left alone. - appEnvironment.dataContextValidator.correctIfContextRemoved() - SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment) - // Check clipboard for external video URLs checkClipboardForExternalURL() - // Fetch remote CloudKit changes (catches missed push notifications) - Task { - await appEnvironment.cloudKitSync.fetchRemoteChanges() - } - // Start periodic polling as fallback for missed push notifications appEnvironment.cloudKitSync.startForegroundPolling() } @@ -435,6 +407,39 @@ struct YatteeApp: App { #endif } + /// The part of foreground activation that has to happen in order. + /// + /// Every step here decides what the next one is allowed to see, so these + /// run as one sequence rather than as tasks racing each other: + /// + /// 1. The identity names whose accounts and credentials are in play at all. + /// 2. The status refresh says which of those this device can actually use. + /// 3. Only then may a tombstone move the selection — the repair prefers a + /// signed-in login on the same server, and a sibling whose status is + /// still being determined is one it would skip, landing on local + /// instead. The repair commits, so there is no second chance at it. + /// 4. The fetch goes last for the same reason: a deletion it delivers + /// repairs the context as it applies, off the statuses read above. + private func resumeAccountState() async { + // Retry a credential identity scope that never resolved (e.g. + // launched offline) - account credentials stay unavailable until it does + await appEnvironment.credentialIdentityScope.resolveIfNeeded() + + // A credential can arrive over iCloud Keychain without the + // scope moving, so statuses are recomputed unconditionally. + appEnvironment.refreshAccountStatuses() + + // Repair a context whose account was deleted on another device + // while this one was in the background. Only a deletion moves + // it — an account that merely has not synced yet is left alone. + appEnvironment.dataContextValidator.correctIfContextRemoved() + + SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment) + + // Fetch remote CloudKit changes (catches missed push notifications) + await appEnvironment.cloudKitSync.fetchRemoteChanges() + } + private func registerBackgroundTasksIfNeeded() { guard !backgroundTasksRegistered else { return } backgroundTasksRegistered = true diff --git a/YatteeTests/AccountsManagerTests.swift b/YatteeTests/AccountsManagerTests.swift index defa0b05..0bb261e1 100644 --- a/YatteeTests/AccountsManagerTests.swift +++ b/YatteeTests/AccountsManagerTests.swift @@ -795,6 +795,35 @@ struct DataContextRepairTests { #expect(!manager.pendingContextUpload) } + @Test("A repair made while a sibling was still being checked never comes back") + func repairIsNotReconsideredWhenStatusesSettle() { + let instances = InstancesManager() + let instance = makeInstance("inv") + instances.add(instance) + defer { instances.remove(instance) } + + let manager = makeManager(instances: instances) + let selected = manager.addAccount(instanceID: instance.id) + let sibling = manager.addAccount(instanceID: instance.id) + // Nothing is known about the sibling yet, so it cannot be repaired + // onto: `checking` is the absence of an answer, not a usable login. + manager.setStatus(.checking, for: sibling.id) + manager.setContext(.account(selected.id)) + + manager.removeAccount(selected.id) + #expect(manager.currentContext == .local) + + // Statuses settling afterwards is not a second chance. The selection no + // longer names an account, so the tombstone repair has nothing left to + // act on and local is where the user stays — which is why foreground + // activation refreshes statuses *before* it lets a tombstone move + // anything, rather than repairing twice. + manager.setStatus(.signedIn, for: sibling.id) + + #expect(!manager.correctContextIfAccountDeleted()) + #expect(manager.currentContext == .local) + } + @Test("A signed-in account on another server is never the repair") func repairNeverCrossesInstances() { let instances = InstancesManager()