Let activation know the accounts before it repairs the selection

Foreground activation started identity resolution, the deletion repair and
the CloudKit fetch as three things racing each other. With a tombstone for
the selected account already on disk and a sibling login on the same server
whose status had not been read back yet, the repair ran against `checking`
— which it will not repair onto — and landed on local. It commits, so the
status refresh arriving a moment later changed nothing: the selection no
longer named an account for a second repair to act on.

The steps now run as one sequence in both platform paths, because each one
is what makes the next correct: the identity says whose accounts these are,
the status refresh says which of them this device can use, the repair reads
those statuses, and the fetch goes last so a deletion it delivers repairs
against the same refreshed picture.
This commit is contained in:
Arkadiusz Fal
2026-09-05 22:27:21 +02:00
parent f44c1d91c3
commit ca73b68ad9
2 changed files with 68 additions and 34 deletions

View File

@@ -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

View File

@@ -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()