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