// // AccountsManager.swift // Yattee // // Owns the set of configured accounts and the currently selected data context. // import Foundation /// Why an account cannot currently be used. enum AccountStatus: Hashable, Sendable { /// Nothing is known yet — the Apple account this device's credentials are /// keyed by has not been established, so the Keychain cannot be read under /// the right scope. Distinct from `credentialMissing`, which is an answer: /// this one is the absence of one, and must never be presented as "signed /// out" or acted on. case checking /// Signed in and usable. case signedIn /// No credential on this device. On tvOS this is permanent until the user /// signs in locally — Apple never syncs app Keychain items there — so it /// must not be presented as a sync that is still in progress. case credentialMissing /// The server rejected the credential. Account-scoped: a rejected login on /// one account never disables the instance or its sibling accounts. case credentialRejected /// The account's source has been switched off. Not a credential state at /// all — the login is untouched and comes back the moment the source is /// turned on again — but nothing may be read from or written to it /// meanwhile, so no surface may present the account as usable. case instanceDisabled /// The user set the account itself to inactive. Like `instanceDisabled` /// this is not a credential state — the login is untouched and comes back /// the moment the account is activated again — and only `displayStatus` /// ever reports it. case inactive /// The server could not be reached. Never invalidates a credential. case unreachable } /// Owns accounts and the selected `DataContext`. /// /// Storage is UserDefaults, namespaced by the credential identity scope so the /// previous Apple account's account list is never displayed under a new one. /// The dirty-flag outbox records what still needs uploading; CloudKit sync /// attaches to it rather than being the source of truth for reads, so the app /// works fully offline and in sideloaded builds where CloudKit is unavailable. @MainActor @Observable final class AccountsManager { // MARK: - Storage private let defaults: UserDefaults private let identityScope: CredentialIdentityScope private weak var instancesManager: InstancesManager? // MARK: - State private(set) var accounts: [Account] = [] private(set) var accountStatuses: [UUID: AccountStatus] = [:] /// The user's selected context, as persisted. Never silently corrected — /// see `DataContextValidator` for the rules that may change it. private(set) var storedContext = StoredDataContext(context: .local) /// Instances whose legacy single-login has already been turned into an /// account. Permanent: `usesAccountLogin` stays true on the instance /// forever, so the marker — not the account's existence — is what stops a /// deliberately deleted account from being recreated on next launch. private(set) var migratedInstanceIDs: Set = [] /// Records that still need uploading, so a change made offline or lost to a /// crash before the sync engine registered it is replayed on next launch. private(set) var pendingUploads: Set = [] private(set) var pendingDeletions: Set = [] /// Instances whose migration marker still has to reach the server. /// /// Tracked apart from `migratedInstanceIDs` because the marker is held back /// until the account it describes is confirmed: a marker that outran a /// silently failed account save would suppress synthesis everywhere for an /// account no device has. private(set) var pendingMarkerUploads: Set = [] /// Whether the selected context still has to reach the server. private(set) var pendingContextUpload = false /// Whether `storedContext` holds an automatic repair rather than a choice. /// /// A repair keeps the timestamp of the selection it replaced, so every /// ordering comparison reads it as that choice — including the one that /// decides an older server copy has to be corrected. Without this the /// fallback would be pushed back over a deliberate choice another device /// made, which is exactly what not stamping it "now" is meant to prevent. /// It is also what makes a repair yield to any context that arrives, rather /// than winning on that borrowed rank and standing against the server /// forever. Persisted beside the context, or a relaunch would forget and /// publish it on the next stale record it saw. private(set) var storedContextIsRepair = false /// Instances the user has deleted here. /// /// Deleting an instance deletes the accounts on it, but those accounts may /// not have arrived yet — CloudKit and the instance list travel /// separately. Remembered so a late arrival is deleted rather than /// installed as an orphan whose credential nothing can reach. private(set) var deletedInstanceIDs: Set = [] /// Accounts known to be deleted. Permanent, and the only thing that /// authorises removing an account that arrives from sync: a record's /// *absence* proves nothing, because a fetch resumes from a change token /// rather than enumerating the zone. private(set) var deletedAccountIDs: Set = [] /// Which instance each deleted account lived on. /// /// Kept beside the tombstones for one purpose: repairing a context that /// names a deleted account looks for another login on the same server, and /// by then the account record itself is gone. Device-local and never /// published, like the statuses that repair consults. private(set) var deletedAccountInstanceIDs: [UUID: UUID] = [:] var currentContext: DataContext { storedContext.context } // MARK: - Initialization init( identityScope: CredentialIdentityScope, instancesManager: InstancesManager? = nil, defaults: UserDefaults = .standard, unreachableRetryInterval: TimeInterval = 60 ) { self.identityScope = identityScope self.instancesManager = instancesManager self.defaults = defaults self.unreachableRetryInterval = unreachableRetryInterval load() // A change of Apple Account changes which namespace this manager reads, // so everything held in memory belongs to the previous one. NotificationCenter.default.addObserver( forName: .credentialIdentityScopeDidChange, object: identityScope, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.reloadForCurrentIdentity() } } NotificationCenter.default.addObserver( forName: .instancesDidChange, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { // Instances arrive from iCloud on their own timeline, so // migration is a repeatable reconciliation rather than a // one-shot at launch: a legacy instance can show up minutes in. self?.reconcileLegacyAccounts() } } } // MARK: - Lookup func account(id: UUID) -> Account? { accounts.first { $0.id == id } } func accounts(forInstance instanceID: UUID) -> [Account] { accounts.filter { $0.instanceID == instanceID } } /// The instance an account lives on, or `nil` when it hasn't arrived yet /// (or was deleted, leaving the account orphaned). func instance(for account: Account) -> Instance? { instancesManager?.instances.first { $0.id == account.instanceID } } /// What this account can do, resolved through its instance. /// /// `nil` when the instance is unknown — an account whose instance hasn't /// synced yet has no *known* capabilities, which is different from having /// none, and callers must not treat it as a usable context. func capabilities(for account: Account) -> AccountCapabilities? { instance(for: account)?.type.accountCapabilities } /// Accounts that can be selected as the current context right now. /// /// Excludes accounts still waiting for their instance: showing a context /// the app cannot actually resolve would be a dead end. Inactive accounts /// are excluded too — the user set them aside, and the pickers must not /// keep offering what was deliberately switched off. var selectableAccounts: [Account] { accounts.filter { $0.isActive && capabilities(for: $0)?.isSelectableContext == true } } /// Accounts whose instance is gone. /// /// Never deleted automatically: an instance can be missing because iCloud /// has not delivered it yet, and absence is not a deletion. The screen that /// lists these and offers to remove them is Phase 3 work; until it exists /// an orphan stays out of the selectable contexts and out of the way. var orphanedAccounts: [Account] { accounts.filter { instance(for: $0) == nil } } // MARK: - Mutation @discardableResult func addAccount(instanceID: UUID, displayName: String? = nil) -> Account { let account = Account(instanceID: instanceID, displayName: displayName) accounts.append(account) pendingUploads.insert(account.id) // Signing in marks the instance migrated. Creating an account flips // `usesAccountLogin`, which posts instancesDidChange and re-runs // reconciliation immediately — without this the reconciliation would // synthesize a second, credential-less account for the same instance. markMigrated(instanceID) save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) return account } /// Records that this instance's legacy login is now represented by an /// account, and that the fact has to be published. /// /// Publishing matters even when the account was created by a fresh sign-in /// rather than by synthesis: without the marker, every other device would /// still synthesize its own credential-less account for the same instance. private func markMigrated(_ instanceID: UUID) { guard !migratedInstanceIDs.contains(instanceID) else { return } migratedInstanceIDs.insert(instanceID) pendingMarkerUploads.insert(instanceID) } /// Stores the account's user-set label, or clears it with `nil`. /// /// A save that changes nothing is dropped rather than written. The rename /// field is prefilled, so "Save" without an edit is the ordinary way out of /// the alert — and every write here stamps `updatedAt` and queues an /// upload, which under last-write-wins would let that idle tap overwrite a /// peer's genuine rename that has not reached this device yet. func updateDisplayName(_ displayName: String?, for accountID: UUID) { guard let index = accounts.firstIndex(where: { $0.id == accountID }) else { return } guard accounts[index].displayName != displayName else { return } accounts[index].displayName = displayName accounts[index].updatedAt = Date() pendingUploads.insert(accountID) save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) } /// Sets whether the account is active, syncing the change like a rename. /// /// A write that changes nothing is dropped for the same reason /// `updateDisplayName` drops one: every real write stamps `updatedAt`, and /// under last-write-wins an idle stamp would overwrite a peer's genuine /// edit that has not arrived yet. func updateIsActive(_ isActive: Bool, for accountID: UUID) { guard let index = accounts.firstIndex(where: { $0.id == accountID }) else { return } guard accounts[index].isActive != isActive else { return } accounts[index].isActive = isActive accounts[index].updatedAt = Date() pendingUploads.insert(accountID) save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) // Deactivating the selected account moves the selection to local — as // a real choice, not a repair: the user just said "stop using this", // and unlike a deletion there is no ambiguity for a sibling to settle. // Publishing it keeps every device's selection off the account the // user set aside. if !isActive, storedContext.context == .account(accountID) { setContext(.local) } } /// Removes an account and everything keyed to it. /// /// The deletion is recorded in the outbox before anything else so that a /// crash mid-way still replays it — and so a peer that recreates the /// account from a stale queued edit is corrected rather than obeyed. func removeAccount(_ accountID: UUID) { guard account(id: accountID) != nil else { return } // Queued before anything else can persist: a crash in between would // otherwise leave the account deleted here with no tombstone owed to // the server, and every other device would keep — or recreate — it. pendingDeletions.insert(accountID) forgetAccount(accountID) save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) } /// Undoes an account that was created moments ago and could not be /// completed — a sign-in whose credential the Keychain refused to store. /// /// Not the same as deleting it. Creating an account also marks its instance /// migrated and queues the permanent marker that stops every other device /// from synthesizing an account for that instance's pre-revamp login. A /// deletion is meant to leave that marker standing; a creation that never /// happened must not, or a legacy login that is still perfectly valid would /// be suppressed everywhere on the strength of an account nobody has. func rollbackAccountCreation(_ accountID: UUID) { guard let account = account(id: accountID) else { return } // Confirmed by the server already? Then it is a real account elsewhere, // and undoing it means a real deletion, tombstone and all. guard pendingUploads.contains(accountID) else { removeAccount(accountID) return } accounts.removeAll { $0.id == accountID } accountStatuses[accountID] = nil pendingUploads.remove(accountID) // Retracted only while the marker is still ours to retract: never // confirmed by the server, and nothing else on the instance relying on // it. A tombstone for the migrated account keeps synthesis suppressed // on its own, so nothing is resurrected by this. if pendingMarkerUploads.contains(account.instanceID), accounts(forInstance: account.instanceID).isEmpty { pendingMarkerUploads.remove(account.instanceID) migratedInstanceIDs.remove(account.instanceID) } if storedContext.context == .account(accountID) { correctContextAfterDeletion(onInstance: account.instanceID) } save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) } /// Drops an account and everything keyed to it, and records the permanent /// local tombstone. /// /// Shared by explicit local deletion and by deletions arriving from sync, so /// that both leave exactly the same state behind — including the tombstone, /// which is what stops a stale device's queued edit from resurrecting the /// account here. private func forgetAccount(_ accountID: UUID) { let instanceID = account(id: accountID)?.instanceID accounts.removeAll { $0.id == accountID } accountStatuses[accountID] = nil pendingUploads.remove(accountID) deletedAccountIDs.insert(accountID) if let instanceID { deletedAccountInstanceIDs[accountID] = instanceID } // Selecting a deleted account would leave the UI pointing at nothing. // A deletion the app can point at is one of the few things allowed to // correct the context. if storedContext.context == .account(accountID) { correctContextAfterDeletion(onInstance: instanceID) } } /// Repairs a context left pointing at an account that is gone. /// /// Deliberately not a new choice: it keeps the timestamp of the selection /// it replaces and is never published. Stamping it "now" would let an /// automatic fallback outrank a deliberate choice another device made /// between the deletion and this device hearing about it — and publishing /// it would push that fallback over their choice. /// /// The devices no longer converge on the same *value* the way they did when /// this always chose local: a sibling is picked by status, and statuses are /// device-local, so a device that has no credential for the sibling repairs /// to local while another lands on the sibling. That is acceptable precisely /// because the repair is never uploaded — nothing overwrites anybody — and /// the next explicit choice re-converges everyone. private func correctContextAfterDeletion(onInstance instanceID: UUID?) { storedContext = StoredDataContext( context: repairTarget(onInstance: instanceID), changedAt: storedContext.changedAt ) storedContextIsRepair = true // Including any upload the deleted choice had queued: sending it now // would publish this fallback under that choice's timestamp, which is // exactly what not stamping it "now" is meant to prevent. pendingContextUpload = false NotificationCenter.default.post(name: .dataContextDidChange, object: nil) } /// Repairs a context still naming an account this device knows was deleted. /// /// The deletion paths repair as they run, so this is for a selection that /// was left behind anyway: a tombstone recorded while the context named /// something else, or a launch that reads both back from disk. Repaired /// exactly like a deletion — same sibling preference, same timestamp, never /// published — because it *is* one, only noticed later. /// /// The tombstone is the whole authorisation. An account merely missing from /// the store has not been deleted, it has not arrived, and moving the /// selection on that is how a slow sync costs the user their choice. /// /// - Returns: `true` when the selection actually moved. @discardableResult func correctContextIfAccountDeleted() -> Bool { // The namespace moved and the reload has not refilled memory yet, so // the selection and the tombstones weighed against each other here both // belong to the previous identity. Fenced on the loaded namespace for // the same reason `save()` is: acting on that state repairs — and // announces — a choice the identity being switched to never made. The // reload replaces both sides, and the next activation asks again. guard loadedNamespace == namespace else { return false } guard case let .account(id) = storedContext.context else { return false } guard deletedAccountIDs.contains(id) else { return false } correctContextAfterDeletion(onInstance: deletedAccountInstanceIDs[id]) save() return true } /// Where a context whose account was deleted should land. /// /// Another signed-in login on the same server is far closer to what the user /// was doing than their local data, and on an instance holding two logins it /// is usually the only other thing they meant. Only `signedIn` qualifies: a /// sibling this device cannot use would repair the context onto something /// that immediately falls back to local anyway, without the user's choice /// surviving anywhere. private func repairTarget(onInstance instanceID: UUID?) -> DataContext { guard let instanceID else { return .local } // Asked here rather than at the call sites because a repair commits: // once the selection no longer names the deleted account there is // nothing left for a later status to move. A deletion delivered by a // silent push arrives without any of the events that recompute statuses // — a credential can reach this device over iCloud Keychain while it is // in the background — so the sibling would be skipped as signed out // while its credential sat right there. refreshStatusesForRepair?() let sibling = accounts(forInstance: instanceID) .filter(\.isActive) .filter { status(for: $0.id) == .signedIn } .filter { capabilities(for: $0)?.isSelectableContext == true } // Ordered so the answer does not depend on which order the accounts // happened to arrive in, the same way a context tie is broken. .min { $0.id.uuidString < $1.id.uuidString } guard let sibling else { return .local } return .account(sibling.id) } /// Recomputes account statuses from the Keychain, just before a repair /// decides which sibling — if any — it may land on. /// /// A hook rather than a dependency for the same reason as /// `onSuspectedCredentialRejection`: reading the Keychain needs the /// credential stores, and this manager deliberately knows nothing about /// them. `nil` — tests, and the window before wiring completes — simply /// leaves the statuses as they are, which is what the repair did before. @ObservationIgnored var refreshStatusesForRepair: (@MainActor () -> Void)? /// Marks a deletion as confirmed by the server. Until then it stays in the /// outbox and is retried, because a tombstone that never lands lets a stale /// device resurrect the account. func confirmDeletion(_ accountID: UUID) { pendingDeletions.remove(accountID) save() } /// Marks an account upload as confirmed by the server. /// /// `updatedAt` is the timestamp of the record that was actually sent: an /// edit made between the record being built and the server acknowledging it /// leaves the account dirty, or that edit would never be uploaded. func confirmUpload(_ accountID: UUID, updatedAt: Date? = nil) { if let updatedAt, let account = account(id: accountID), account.updatedAt > updatedAt { return } pendingUploads.remove(accountID) save() } func confirmMarkerUpload(instanceID: UUID) { pendingMarkerUploads.remove(instanceID) save() } /// Marks the context upload as confirmed, unless the user has chosen a /// different context since the uploaded record was built. func confirmContextUpload(changedAt: Date) { guard storedContext.changedAt <= changedAt else { return } pendingContextUpload = false save() } /// Records that an instance was deleted here, so accounts on it that have /// yet to arrive are deleted on sight rather than orphaned. func recordInstanceDeletion(_ instanceID: UUID) { guard !deletedInstanceIDs.contains(instanceID) else { return } deletedInstanceIDs.insert(instanceID) save() } func isDeleted(_ accountID: UUID) -> Bool { deletedAccountIDs.contains(accountID) } /// Remembers which instance a deleted account lived on, learned from a /// record that arrived after its tombstone. /// /// A device that meets the tombstone first — a fresh install, or any fetch /// that stages both — has no account to read the instance off, and the /// account record that follows is dominated and discarded. That record is /// the only thing that still knows where the account lived, and without it /// every later repair of a context naming it falls to local past a /// perfectly good sibling on the same server. func recordDeletedAccountInstance(_ instanceID: UUID, for accountID: UUID) { // The namespace moved and the reload has not refilled memory yet, so // this would file one identity's deletion under another's. guard loadedNamespace == namespace else { return } guard deletedAccountIDs.contains(accountID) else { return } // The deletion this device performed itself read the instance off the // account it removed, which is closer to the truth than a record that // may have been resurrected since. guard deletedAccountInstanceIDs[accountID] == nil else { return } deletedAccountInstanceIDs[accountID] = instanceID save() } /// Marks every account record dirty again. /// /// For a server that holds nothing — sync being enabled for the first time, /// or the zone having been deleted from another device. The outbox /// otherwise tracks only what changed, which says nothing about records the /// server never received. Tombstones are included: without them a device /// that still has a deleted account could put it back. func markAllRecordsForUpload() { pendingUploads.formUnion(accounts.map(\.id)) pendingMarkerUploads.formUnion(migratedInstanceIDs) pendingDeletions.formUnion(deletedAccountIDs) // An empty zone is no reason to publish a fallback nobody chose: a // repair is never uploaded, however little the server holds. The next // explicit choice fills the gap, and until then every device keeps // repairing to whatever it can actually use. pendingContextUpload = hasStoredContext && !storedContextIsRepair save() } /// Forgets what the server was still owed. /// /// The counterpart to `markAllRecordsForUpload()`, for a zone whose contents /// the account owner purged from iCloud: CloudKit requires that data deleted /// that way is not sent back, and every entry here is a record waiting to be /// sent. Nothing local is deleted — only the intention to upload it, which /// the next edit, or a later full seed, writes again. func clearPendingUploads() { pendingUploads = [] pendingDeletions = [] pendingMarkerUploads = [] pendingContextUpload = false save() } // MARK: - Upload ordering /// Whether an instance's marker may be published yet. /// /// Held while any account on that instance is still waiting to upload — see /// `pendingMarkerUploads`. func canUploadMarker(instanceID: UUID) -> Bool { !accounts(forInstance: instanceID).contains { pendingUploads.contains($0.id) } } /// Whether the selected context may be published yet. /// /// A context naming an account the server has never seen would let a fresh /// device fetch the selection, find no such account, and treat a merely /// pending account as removed. var canUploadContext: Bool { guard case let .account(id) = storedContext.context else { return true } return !pendingUploads.contains(id) } // MARK: - Applying synced state /// Applies an account fetched from another device. /// /// - Returns: `true` when the local copy is the newer one and has to be /// pushed back, since the server still holds the older version. @discardableResult func applySyncedAccount(_ incoming: Account) -> Bool { // A tombstone dominates the account everywhere. The caller repairs the // server copy; here it simply never becomes local state again — except // for the one thing worth keeping: which instance it lived on, for a // context repair that has no account left to read it off. guard !deletedAccountIDs.contains(incoming.id) else { recordDeletedAccountInstance(incoming.instanceID, for: incoming.id) return false } // Its instance was deleted here while this account was still in // transit. Deleting an instance deletes its accounts, so this one is // deleted too — arriving late does not exempt it, and installing it // would leave an orphan nothing can reach the credential of. guard !deletedInstanceIDs.contains(incoming.instanceID) else { pendingDeletions.insert(incoming.id) deletedAccountIDs.insert(incoming.id) save() LoggingService.shared.info( "An account arrived for an instance that was deleted here — deleting it too", category: .general, details: "accountID=\(incoming.id)" ) return false } if let index = accounts.firstIndex(where: { $0.id == incoming.id }) { let existing = accounts[index] guard existing.isOutranked(by: incoming) else { guard incoming.isOutranked(by: existing) else { return false } // The server holds the older copy. Marked dirty rather than // merely re-registered with the engine, so the correction // survives a crash before it is sent. pendingUploads.insert(existing.id) save() return true } accounts[index] = incoming } else { accounts.append(incoming) } save() // The legacy selection may name exactly this account. Migration refuses // to adopt a context whose account is not there yet, and on a fresh // install the account arrives from iCloud long after the migration ran — // so it is retried here rather than left until the next launch. Free to // repeat: it never runs once a context has been chosen. let contextBefore = storedContext.context migrateLegacyContextIfNeeded() NotificationCenter.default.post(name: .accountsDidChange, object: nil) if storedContext.context != contextBefore { NotificationCenter.default.post(name: .dataContextDidChange, object: nil) } return false } /// Applies a deletion tombstone fetched from another device. /// /// The tombstone's presence on the server also settles our own outbox: an /// entry still queued here would only re-save a record that already exists. @discardableResult func applyRemoteTombstone(_ accountID: UUID) -> Account? { let removed = account(id: accountID) forgetAccount(accountID) pendingDeletions.remove(accountID) save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) return removed } /// Applies a deletion *event* — the account record itself disappeared. /// /// Authorises removal just like a tombstone, but leaves the tombstone /// queued: a bare record deletion is not durable, and the account would come /// back the moment a device that never fetched this event flushes a queued /// edit. @discardableResult func applyRemoteAccountDeletionEvent(_ accountID: UUID) -> Account? { let removed = account(id: accountID) pendingDeletions.insert(accountID) forgetAccount(accountID) save() NotificationCenter.default.post(name: .accountsDidChange, object: nil) return removed } /// Applies a legacy-migration marker fetched from another device. /// /// The marker only ever *prevents* synthesis. It never retracts an account /// already synthesized here — an account record may simply be in a later /// fetch event, or beyond the incremental window. func applySyncedMigrationMarker(instanceID: UUID) { let isNew = !migratedInstanceIDs.contains(instanceID) migratedInstanceIDs.insert(instanceID) // Already on the server, so ours is redundant. pendingMarkerUploads.remove(instanceID) save() if isNew { NotificationCenter.default.post(name: .accountsDidChange, object: nil) } } // MARK: - Context /// Resolves a legacy instance-keyed selection onto a real account context. /// /// `DataContext(legacy:)` maps an instance UUID straight onto an account /// UUID, which only holds for *migrated* accounts — those deliberately /// reuse the instance UUID. A login created after the revamp has a random /// ID, so the legacy mapping would name an account that does not exist. func context(forLegacyInstanceID instanceID: UUID?) -> DataContext { guard let instanceID else { return .local } let candidates = accounts(forInstance: instanceID) guard !candidates.isEmpty else { return .local } // Prefer a signed-in account when the instance holds several. let chosen = candidates.first { status(for: $0.id) == .signedIn } ?? candidates[0] return .account(chosen.id) } /// Records an explicit user choice of context. func setContext(_ context: DataContext) { storedContext = StoredDataContext(context: context) hasStoredContext = true // Whatever stood here before, this is a choice — and choices publish. storedContextIsRepair = false pendingContextUpload = true save() NotificationCenter.default.post(name: .dataContextDidChange, object: nil) } /// Applies a context that arrived from another device, honouring the /// app-managed timestamp: an older value never overwrites a newer one, so a /// migration write can't undo a deliberate choice made elsewhere. /// /// - Returns: `true` when the local choice is the newer one and has to be /// pushed back over the stale server copy. @discardableResult func applySyncedContext(_ incoming: StoredDataContext) -> Bool { var incoming = incoming var wasRepaired = false // A tombstone dominates the account everywhere, contexts included. An // offline device can select an account another device has deleted and // publish that choice with a newer timestamp; adopting it would leave // every device pointing at an account that is never coming back. The // choice's own timestamp is kept, so a later real selection still wins, // and the repair is never published — a device that repairs to something // else does not overwrite anybody. if let accountID = incoming.context.accountID, deletedAccountIDs.contains(accountID) { // Repaired the same way a local deletion is: onto a signed-in login // on the same server when there is one, local otherwise. incoming = StoredDataContext( context: repairTarget(onInstance: deletedAccountInstanceIDs[accountID]), changedAt: incoming.changedAt ) wasRepaired = true LoggingService.shared.info( "Ignoring a synced context that names a deleted account", category: .general, details: "accountID=\(accountID)" ) } // Before the user has ever chosen here there is nothing to order // against: the local value is a default stamped when this manager was // constructed, so a synced choice made yesterday would lose to it and a // fresh device would never adopt the selection at all. guard hasStoredContext else { adoptSyncedContext(incoming, wasRepaired: wasRepaired) return false } // A repair never outranks a context somebody chose. It borrowed the // replaced selection's timestamp only so that a later choice still beats // it — that borrowed rank was never meant to win an argument with an // explicit value, by being newer or by settling a tie on // `DataContext.tieBreakKey`. Leaving it in place instead would keep a // local fallback standing against the server's value indefinitely, since // a repair is also never pushed back: nothing would ever resolve the // disagreement. Migrated contexts make the tie real rather than // theoretical — they are all stamped at the epoch. // // Adopting is safe whatever the incoming value says: one naming an // account that is gone has already been turned back into a repair above, // so it stays unpublished and lands on the same target this repair did. if storedContextIsRepair { adoptSyncedContext(incoming, wasRepaired: wasRepaired) return false } guard storedContext.isOutranked(by: incoming) else { // The local choice is the newer one and survives untouched, keeping // whatever it still owes the server. guard incoming.isOutranked(by: storedContext) else { return false } // The server holds the older copy of a deliberate choice, so it has // to be pushed back over it. pendingContextUpload = true save() return true } adoptSyncedContext(incoming, wasRepaired: wasRepaired) return false } /// Takes on a context that arrived from another device. /// /// A repaired value is an automatic fallback rather than a choice, so any /// upload the replaced selection still owed is dropped along with it: /// sending it now would publish this fallback under that choice's /// timestamp, which is exactly what keeping the timestamp is meant to /// prevent — the same reasoning as `correctContextAfterDeletion`. private func adoptSyncedContext(_ incoming: StoredDataContext, wasRepaired: Bool) { storedContext = incoming hasStoredContext = true // A value adopted as it stands is somebody's choice and may be pushed // back if the server ever holds an older one; a repaired one may not. storedContextIsRepair = wasRepaired if wasRepaired { pendingContextUpload = false } save() NotificationCenter.default.post(name: .dataContextDidChange, object: nil) } // MARK: - Status /// Records what the app currently knows about an account's usability. /// /// Announced separately from the account *list* changing. Recomputing /// statuses from the Keychain is one of the things that listens for a list /// change, and it cannot tell that a server rejected a credential or failed /// to answer — so sharing one notification meant every status the API layer /// recorded was overwritten by the reaction to recording it. /// - Parameter date: when the verdict was reached. Only `unreachable` uses /// it — to decide when to try again — and only tests pass anything but /// the default. func setStatus(_ status: AccountStatus, for accountID: UUID) { unreachableExpiries[accountID]?.cancel() unreachableExpiries[accountID] = nil if status == .unreachable { scheduleUnreachableExpiry(for: accountID) } guard accountStatuses[accountID] != status else { return } accountStatuses[accountID] = status NotificationCenter.default.post(name: .accountStatusesDidChange, object: nil) } /// Pending "try this account again" timers. private var unreachableExpiries: [UUID: Task] = [:] /// How long "could not be reached" stands before the app tries again. /// /// It has to expire on its own, and visibly: the state stops reads from /// going to the server at all, so nothing would discover that the server /// came back — and a timeout during an outage produces no connectivity /// change to react to either. Expiring by mutating the status, rather than /// by having the getter compare the clock, is what makes the fallback /// actually lift: nothing re-reads a value merely because time passed. /// Injectable so tests do not have to wait a real minute. private let unreachableRetryInterval: TimeInterval private func scheduleUnreachableExpiry(for accountID: UUID) { unreachableExpiries[accountID] = Task { [weak self] in try? await Task.sleep(for: .seconds(self?.unreachableRetryInterval ?? 60)) guard !Task.isCancelled else { return } await MainActor.run { guard let self, self.accountStatuses[accountID] == .unreachable else { return } self.unreachableExpiries[accountID] = nil // Back to being usable: the credential was never in question, // and the next request settles it either way. self.setStatus(.signedIn, for: accountID) } } } /// An account with no recorded status is *unknown*, not signed out. While /// the identity scope is unresolved nothing can read the Keychain under the /// right scope, and reporting a missing credential then would tell a user /// with a perfectly good login to sign in again — and drop reads to local /// data — for as long as iCloud stayed unreachable. func status(for accountID: UUID) -> AccountStatus { accountStatuses[accountID] ?? (identityScope.isResolved ? .credentialMissing : .checking) } /// The status as the user is shown it, which is not the same question. /// /// `status(for:)` answers what the *credential* is doing, and a source the /// user switched off leaves that answer untouched — deliberately, so /// turning the source back on restores whatever the login really was. What /// no list may do is report that answer on its own: an account on a /// disabled source cannot be read from, cannot be written to and is not /// offered by the switcher, so a row saying "Signed in" beside it is the /// app claiming health it will not deliver. func displayStatus(for account: Account) -> AccountStatus { guard instance(for: account)?.isEnabled != false else { return .instanceDisabled } guard account.isActive else { return .inactive } return status(for: account.id) } /// Asked to confirm an auth-shaped failure before anything is concluded /// from it. `nil` — tests, and the window before wiring completes — means /// nothing is concluded at all, which is the safe direction. /// /// Held here as a hook rather than as a dependency because the confirmation /// needs an API client, and this manager deliberately knows nothing about /// the network. @ObservationIgnored var onSuspectedCredentialRejection: (@MainActor (Account) -> Void)? /// Records what a server call implies about the account that made it. /// /// Nothing here rejects a credential. A raw 401 or 403 is *suspicion*: the /// same statuses come back from a reverse proxy, from endpoints a valid /// session may not touch, and — on Invidious — from a genuinely revoked /// session, and the request that failed cannot tell them apart. It is /// handed to `onSuspectedCredentialRejection`, which asks the question again /// against an endpoint that exists to be authenticated; only that second /// answer marks the account. Until it arrives the status is left alone, /// rather than wrongly telling the user to sign in again. /// /// Shared by the subscription providers and the feed cache: a credential /// that expires during a feed-only refresh has to mark the account too, or /// nothing ever reports it and the fallback never engages. func recordServerOutcome(_ error: Error?, for account: Account) { guard let error else { setStatus(.signedIn, for: account.id) return } // A server that cannot be reached says nothing about the credential. // Recorded all the same: the account has to stop reporting itself as // usable, or reads never fall back and the user is shown failures // instead of local data. A host that *refuses* the connection counts: // it is as unreachable as one that never answers, and being told so // instantly rather than after a timeout does not make it a different // verdict. switch error { case APIError.timeout, APIError.noConnection, APIError.connectionFailed: setStatus(.unreachable, for: account.id) case APIError.cancelled: return case APIError.unauthorized: suspectRejection(of: account) case let APIError.httpError(statusCode, _) where statusCode == 401 || statusCode == 403: // 403 arrives untranslated on purpose: the shared HTTP mapping // turns only 401 into `.unauthorized`, because unauthenticated call // sites read 403 as "this instance is blocked" rather than "this // login is". Both shapes mean the same thing here. suspectRejection(of: account) default: return } } /// Passes an auth-shaped failure on for confirmation, unless the instance /// itself can explain it. private func suspectRejection(of account: Account) { // An instance behind HTTP Basic Auth answers 401 at the proxy for every // request made to it, whichever account made it — so the status says // nothing about this credential, and a probe would only be answered by // the same proxy. An orphaned account, whose instance is unknown, is // skipped by the same guard: there is nothing to probe against. guard instance(for: account)?.usesBasicAuth == false else { return } onSuspectedCredentialRejection?(account) } // MARK: - Legacy migration /// Turns each pre-revamp single-login instance into an account. /// /// Repeatable by design: instances arrive over iCloud whenever they arrive, /// so this runs on every `instancesDidChange` rather than once at launch. /// The marker set is what makes re-running safe — without it, deleting a /// migrated account would simply recreate it, because `usesAccountLogin` /// remains true on the instance forever. func reconcileLegacyAccounts() { // Nothing may be synthesized into the unresolved namespace: the app // cannot yet tell whose accounts these are, and the marker it would // write there is permanent while the namespace itself is discarded. // Nothing is lost by waiting — resolution runs // `reloadForCurrentIdentity()`, which reconciles under the namespace // that will actually be read. guard identityScope.isResolved else { return } guard let instancesManager else { return } var didChange = false for instance in instancesManager.instances { guard instance.usesAccountLogin else { continue } guard instance.type.accountCapabilities.isSelectableContext else { continue } guard !migratedInstanceIDs.contains(instance.id) else { continue } // A synthesized account reuses the instance's UUID, so a tombstone // for that UUID is this instance's migration already having been // undone. It can arrive before the marker record does — both are // permanent, but they are separate records — so the tombstone has // to gate synthesis in its own right. guard !deletedAccountIDs.contains(instance.id) else { markMigrated(instance.id) didChange = true continue } // Any account on this instance means its login is already // represented — whether by an earlier migration or by a fresh // sign-in. Synthesizing beside it would leave a credential-less // duplicate. guard accounts(forInstance: instance.id).isEmpty else { markMigrated(instance.id) didChange = true continue } markMigrated(instance.id) didChange = true let account = Account.migrated(from: instance.id, dateAdded: instance.dateAdded) accounts.append(account) pendingUploads.insert(account.id) LoggingService.shared.info( "Migrated a legacy instance login into an account", category: .general, details: "instanceID=\(instance.id)" ) } if didChange { save() migrateLegacyContextIfNeeded() NotificationCenter.default.post(name: .accountsDidChange, object: nil) } } /// One-way migration of the legacy `subscriptionAccount` selection. /// /// Runs only while no context has ever been chosen. The legacy value is read /// from local defaults first and then from the raw KVS value, because the /// previous release wrote the key to iCloud but never imported it back — on /// a fresh install the only copy lives in KVS. func migrateLegacyContextIfNeeded() { // A one-way migration is spent the moment it runs, so running it // against the unresolved namespace would consume it against storage // that is thrown away — and mark a context chosen that the identity // owning the choice never sees. guard identityScope.isResolved else { return } guard !hasStoredContext else { return } guard let legacy = legacySubscriptionAccount() else { return } let context = DataContext(legacy: legacy) // Only adopt a context whose account actually exists; otherwise the app // would start up pointing at something it cannot resolve. if case let .account(id) = context, account(id: id) == nil { return } storedContext = .migrated(context) hasStoredContext = true storedContextIsRepair = false // Published like any other context, but stamped at the epoch: if a real // choice exists anywhere it outranks this one whichever arrives first. pendingContextUpload = true save() LoggingService.shared.info( "Migrated the legacy subscription account into a data context", category: .general ) } private func legacySubscriptionAccount() -> SubscriptionAccount? { let key = "subscriptionAccount" if let data = defaults.data(forKey: key), let value = try? JSONDecoder().decode(SubscriptionAccount.self, from: data) { return value } // The previous release published this to iCloud but never read it back, // so a fresh install can hold the legacy selection only here. if let data = NSUbiquitousKeyValueStore.default.data(forKey: key), let value = try? JSONDecoder().decode(SubscriptionAccount.self, from: data) { return value } return nil } // MARK: - Local-namespace import /// What the `local` namespace still holds, read without loading it. /// /// Only the three things an identity may take over. The deletion facts /// stored beside them are deliberately absent: a deletion made while signed /// out is a local fact and must never be published into an Apple account's /// synced set (decision 3). struct LocalNamespaceSnapshot { var accounts: [Account] = [] var migratedInstanceIDs: Set = [] /// The context chosen there, or `nil` when none ever was. var storedContext: StoredDataContext? /// Whether there is anything worth sweeping. The migration markers /// alone are not: they are copied as a side effect of importing the /// accounts they describe (decision 4), and unioning them on their own /// every activation would write for nothing. var hasContent: Bool { !accounts.isEmpty || storedContext != nil } } /// What became of a record offered to the active identity. enum ImportOutcome { case imported case mergedIntoExisting case rejectedByTombstone /// Nothing was decided, because the namespace this manager holds is not /// the one being imported into. The caller must leave the local record /// exactly where it is. case deferred } /// What this identity would do with a record from the local namespace, asked /// without doing any of it. enum ImportEligibility { case adoptable case rejectedByTombstone /// The namespace moved under the question. Nothing may be decided. case deferred } /// Answers a record's fate without writing anything. /// /// Separate from `adoptImportedAccount` because the sweep has to know what /// it is dealing with *before* it touches the Keychain: adopting announces /// the record, and an announcement is enough to bring observers — status /// refresh, and the legacy adoption it can trigger — down on an account /// whose credential has not settled yet. func importEligibility(for account: Account) -> ImportEligibility { guard canImportIntoCurrentNamespace else { return .deferred } // Decision 5, first rule: this identity deleted the account, and the // IDs are deterministic enough for that to be the same account rather // than a coincidence — a migrated one reuses its instance's UUID. A // stale local copy must never resurrect it. The instance's own deletion // says the same thing about everything that lived on it, and arrives as // its own permanent fact. guard !deletedAccountIDs.contains(account.id), !deletedInstanceIDs.contains(account.instanceID) else { LoggingService.shared.info( "Refusing to import an account this identity has deleted", category: .general, details: "accountID=\(account.id)" ) return .rejectedByTombstone } return .adoptable } /// Whether the local namespace may be imported into what is loaded now. /// /// Three fences in one place. The first is the one `save()` uses: the scope /// moved and the reload has not refilled memory yet, so anything decided /// here would be one identity's data merged into another's. The second is /// decision 9 — a sideloaded or signed-out install is permanently `local`, /// where the sweep would import a namespace into itself. The third is /// decision 10: `unresolved` is a holding pen that gets purged, never a /// destination. private var canImportIntoCurrentNamespace: Bool { guard identityScope.isResolved else { return false } guard namespace != CredentialIdentityScope.localScope else { return false } return loadedNamespace == namespace } /// Reads the `local` namespace without disturbing it. func localNamespaceSnapshot() -> LocalNamespaceSnapshot { guard canImportIntoCurrentNamespace else { return LocalNamespaceSnapshot() } let local = CredentialIdentityScope.localScope var snapshot = LocalNamespaceSnapshot() if let data = defaults.data(forKey: key("accounts", in: local)), let decoded = try? JSONDecoder().decode([Account].self, from: data) { snapshot.accounts = decoded } if let ids = defaults.array(forKey: key("migrated", in: local)) as? [String] { snapshot.migratedInstanceIDs = Set(ids.compactMap(UUID.init(uuidString:))) } if defaults.bool(forKey: key("hasContext", in: local)), let data = defaults.data(forKey: key("context", in: local)), let decoded = try? JSONDecoder().decode(StoredDataContext.self, from: data) { snapshot.storedContext = decoded } return snapshot } /// Offers a record from the local namespace to the active identity. /// /// Only ever called once the record's credential is settled — the fate it /// re-checks here is `importEligibility(for:)`'s, asked again because this /// is the call that writes. @discardableResult func adoptImportedAccount(_ account: Account) -> ImportOutcome { switch importEligibility(for: account) { case .deferred: return .deferred case .rejectedByTombstone: return .rejectedByTombstone case .adoptable: break } // Decision 5, second rule: the identity's own copy stands. A strictly // newer label from the local copy is applied through the ordinary edit // path, so it is marked dirty and ordered by `updatedAt` like any other // change — nothing else about the record is taken. if let existing = self.account(id: account.id) { if account.updatedAt > existing.updatedAt { if account.displayName != existing.displayName { updateDisplayName(account.displayName, for: account.id) } if account.isActive != existing.isActive { updateIsActive(account.isActive, for: account.id) } } return .mergedIntoExisting } // Decision 5, third rule: imported verbatim. `updatedAt` and `dateAdded` // are preserved so conflict resolution everywhere else orders this // record by when the user actually made it, not by when it moved. accounts.append(account) pendingUploads.insert(account.id) save() LoggingService.shared.info( "Imported an account created while signed out into the active identity", category: .general, details: "accountID=\(account.id)" ) NotificationCenter.default.post(name: .accountsDidChange, object: nil) return .imported } /// Takes on the local namespace's migration markers (decision 4). /// /// Copied rather than moved: the local set stays where it is, so a later /// sign-out cannot let `reconcileLegacyAccounts()` synthesize the same /// account again from an `usesAccountLogin` flag that is still true — every /// cycle would otherwise mint another import. func adoptImportedMigratedInstances(_ ids: Set) { guard canImportIntoCurrentNamespace else { return } // Only markers for instances this identity actually holds an account on // — imported by the sweep that is running, or already here. The local // set outlives the accounts it describes: deleting a migrated account // while signed out leaves its marker behind (decisions 3 and 4 both keep // the local namespace as it is), and copying that one over would say // "this instance's legacy login is already an account" under an identity // where it is not. Synthesis would be suppressed there forever, which is // a deletion made while signed out leaking into an Apple account — the // exact thing decision 3 refuses. let added = ids .subtracting(migratedInstanceIDs) .filter { !accounts(forInstance: $0).isEmpty } guard !added.isEmpty else { return } migratedInstanceIDs.formUnion(added) // A marker suppresses synthesis on every other device, so it is only // published for an instance whose account this identity holds — which, // after the filter above, is all of them. pendingMarkerUploads.formUnion(added) save() } /// Carries the local namespace's context over, if nothing better stands here. /// /// Epoch-stamped through the same rule the legacy-context migration uses: /// a selection made while signed out is a guess at what the user wants under /// this Apple account, so any explicit or synced choice outranks it whichever /// arrives first (decision 6). Held back from the server by the existing /// `canUploadContext` gate until the account it names is confirmed. /// /// "Nothing better" is the part that has to be judged rather than assumed. /// `reloadForCurrentIdentity()` runs `migrateLegacyContextIfNeeded()` before /// the sweep, so this identity can already hold an auto-derived context that /// was never chosen by anyone — and refusing on the mere presence of one /// would drop the user's own signed-out selection in favour of a guess, with /// the local keys cleared behind it. Auto-derived contexts are recognisable: /// they are stamped at the epoch, which is exactly the rank that says "any /// real choice beats this". A repair that inherited an epoch stamp is /// likewise a fallback rather than a choice, and is replaced on the same /// terms. An explicit or synced selection is not epoch-stamped, and still /// wins. func adoptImportedContext(_ stored: StoredDataContext) { guard canImportIntoCurrentNamespace else { return } guard !hasStoredContext || storedContext.isMigrated else { return } storedContext = .migrated(stored.context) hasStoredContext = true storedContextIsRepair = false pendingContextUpload = true save() LoggingService.shared.info( "Carried a data context chosen while signed out into the active identity", category: .general ) NotificationCenter.default.post(name: .dataContextDidChange, object: nil) } /// Removes imported records from the local namespace (decision 2). /// /// Move semantics: a local record is only ever passed here once it has /// landed under the identity — or was correctly refused — and its credential /// has been dealt with, so a sign-out afterwards shows only what was never /// imported. The local `migrated` and `deleted*` keys are never touched: /// they are retained on purpose (decisions 3 and 4). func pruneLocalNamespace(removingAccountIDs removed: Set, clearContext: Bool) { guard canImportIntoCurrentNamespace else { return } let local = CredentialIdentityScope.localScope if !removed.isEmpty { var stored: [Account] = [] if let data = defaults.data(forKey: key("accounts", in: local)), let decoded = try? JSONDecoder().decode([Account].self, from: data) { stored = decoded } let remaining = stored.filter { !removed.contains($0.id) } if remaining.isEmpty { defaults.removeObject(forKey: key("accounts", in: local)) } else if let data = try? JSONEncoder().encode(remaining) { defaults.set(data, forKey: key("accounts", in: local)) } } guard clearContext else { return } for name in ["context", "hasContext", "contextIsRepair"] { defaults.removeObject(forKey: key(name, in: local)) } } /// Called once the in-memory state describes an identity namespace that is /// ready to be read and written. /// /// A hook rather than another `.credentialIdentityScopeDidChange` observer /// because the import sweep has to run *after* this manager has reloaded, /// reconciled and announced — and relying on notification observer /// registration order for that would be silently fragile. /// /// Deliberately not fired for the load this manager performs in its own /// initializer: nothing can have installed a hook by then. A launch that /// resolves its identity from cache never changes scope afterwards, so the /// app invokes the sweep once itself after wiring completes. @ObservationIgnored var onNamespaceActivated: (@MainActor () -> Void)? // MARK: - Persistence /// Storage keys are namespaced by identity so a switch of Apple account /// cannot show — or upload — the previous one's accounts. An unresolved /// scope falls back to a namespace of its own rather than sharing one. private var namespace: String { identityScope.current ?? "unresolved" } /// Whether the identity everything here is keyed by is known. /// /// Exposed because the corrections other services make — rewriting the /// legacy selection, resetting a context whose account is "missing" — are /// only meaningful once this is true. While it is false the app is looking /// at a namespace it will discard, and acting on what it finds there /// corrects real state from state that was never authoritative. var isIdentityResolved: Bool { identityScope.isResolved } /// The namespace the in-memory state was loaded from. /// /// `namespace` moves the instant the identity scope does, but the reload /// that refills memory runs on a main-queue observer afterwards. In that /// window everything held here still belongs to the previous identity, so /// writes are fenced on this rather than on `namespace` — otherwise one /// Apple account's accounts would be persisted, and uploaded, under /// another's. Kept out of observation: it is a storage fence, not state /// anything renders. @ObservationIgnored private(set) var loadedNamespace = "" private func key(_ name: String) -> String { key(name, in: namespace) } /// A key in a namespace other than the active one — the import sweep reads /// and prunes `local` while a resolved identity is loaded. private func key(_ name: String, in namespace: String) -> String { "accounts.\(namespace).\(name)" } private var hasStoredContext: Bool { get { defaults.bool(forKey: key("hasContext")) } set { guard loadedNamespace == namespace else { return } defaults.set(newValue, forKey: key("hasContext")) } } private func load() { loadedNamespace = namespace if let data = defaults.data(forKey: key("accounts")), let decoded = try? JSONDecoder().decode([Account].self, from: data) { accounts = decoded } if let data = defaults.data(forKey: key("context")), let decoded = try? JSONDecoder().decode(StoredDataContext.self, from: data) { storedContext = decoded } if let ids = defaults.array(forKey: key("migrated")) as? [String] { migratedInstanceIDs = Set(ids.compactMap(UUID.init(uuidString:))) } if let ids = defaults.array(forKey: key("pendingUploads")) as? [String] { pendingUploads = Set(ids.compactMap(UUID.init(uuidString:))) } if let ids = defaults.array(forKey: key("pendingDeletions")) as? [String] { pendingDeletions = Set(ids.compactMap(UUID.init(uuidString:))) } if let ids = defaults.array(forKey: key("pendingMarkerUploads")) as? [String] { pendingMarkerUploads = Set(ids.compactMap(UUID.init(uuidString:))) } if let ids = defaults.array(forKey: key("deletedAccounts")) as? [String] { deletedAccountIDs = Set(ids.compactMap(UUID.init(uuidString:))) } if let ids = defaults.array(forKey: key("deletedInstances")) as? [String] { deletedInstanceIDs = Set(ids.compactMap(UUID.init(uuidString:))) } if let pairs = defaults.dictionary(forKey: key("deletedAccountInstances")) as? [String: String] { deletedAccountInstanceIDs = pairs.reduce(into: [:]) { result, pair in guard let accountID = UUID(uuidString: pair.key), let instanceID = UUID(uuidString: pair.value) else { return } result[accountID] = instanceID } } pendingContextUpload = defaults.bool(forKey: key("pendingContextUpload")) // An install that repaired its context before this key existed reads // back as "chosen", so that one repair can still be marked dirty by an // older incoming context and published once. Accepted: no shipped // release ever persisted a data context — the whole revamp ships as a // single release — so the window covers only interim development and // TestFlight builds, it is one-shot, and the next explicit choice // heals it. storedContextIsRepair = defaults.bool(forKey: key("contextIsRepair")) purgeUnresolvedNamespace() } /// Every key `save()` writes, for purging a namespace wholesale. private static let storedKeyNames = [ "accounts", "context", "hasContext", "migrated", "pendingUploads", "pendingDeletions", "pendingMarkerUploads", "deletedAccounts", "deletedInstances", "deletedAccountInstances", "pendingContextUpload", "contextIsRepair" ] /// Deletes whatever was written while the identity was still unresolved. /// /// That namespace is a holding pen no resolved identity ever reads back, /// and what accumulates there is actively wrong: a snapshot from before a /// tombstone was applied, outboxes owing uploads to a zone nothing will /// ever send them to. With iCloud Drive switched off the scope dips to /// unresolved on every foreground activation, so it is written in ordinary /// use rather than only in exotic states. /// /// Run from `load()`, which covers both a launch that resolves immediately /// (where no reload ever runs) and one that resolves later. Repeatable, so /// there is nothing to remember about having done it. private func purgeUnresolvedNamespace() { guard identityScope.isResolved else { return } for name in Self.storedKeyNames { defaults.removeObject(forKey: "accounts.unresolved.\(name)") } } private func save() { // The namespace moved and the reload has not run yet, so this is the // previous identity's state being written under the new identity's // keys. Dropped rather than deferred: the reload that follows loads // whatever storage already holds for the new identity, and everything // dropped here is either that identity's — and still on disk — or the // previous one's, which its own namespace still holds. guard loadedNamespace == namespace else { LoggingService.shared.warning( "Dropping an account save made while the identity namespace was reloading", category: .general, details: "loaded=\(loadedNamespace), current=\(namespace)" ) return } if let data = try? JSONEncoder().encode(accounts) { defaults.set(data, forKey: key("accounts")) } if let data = try? JSONEncoder().encode(storedContext) { defaults.set(data, forKey: key("context")) } defaults.set(migratedInstanceIDs.map(\.uuidString), forKey: key("migrated")) defaults.set(pendingUploads.map(\.uuidString), forKey: key("pendingUploads")) defaults.set(pendingDeletions.map(\.uuidString), forKey: key("pendingDeletions")) defaults.set(pendingMarkerUploads.map(\.uuidString), forKey: key("pendingMarkerUploads")) defaults.set(deletedAccountIDs.map(\.uuidString), forKey: key("deletedAccounts")) defaults.set(deletedInstanceIDs.map(\.uuidString), forKey: key("deletedInstances")) defaults.set( deletedAccountInstanceIDs.reduce(into: [String: String]()) { result, pair in result[pair.key.uuidString] = pair.value.uuidString }, forKey: key("deletedAccountInstances") ) defaults.set(pendingContextUpload, forKey: key("pendingContextUpload")) defaults.set(storedContextIsRepair, forKey: key("contextIsRepair")) } /// Re-reads storage under the current identity namespace. /// /// Called when the credential scope resolves or changes: the accounts held /// in memory belong to whichever namespace was active when they were loaded. func reloadForCurrentIdentity() { accounts = [] accountStatuses = [:] // Their accounts belong to the namespace being left. A migrated account // reuses its instance's UUID, so the same ID can exist under the new // identity — and an old timer would then declare *that* account // reachable again on the strength of a verdict about someone else's. for expiry in unreachableExpiries.values { expiry.cancel() } unreachableExpiries = [:] storedContext = StoredDataContext(context: .local) migratedInstanceIDs = [] pendingUploads = [] pendingDeletions = [] pendingMarkerUploads = [] deletedAccountIDs = [] deletedInstanceIDs = [] deletedAccountInstanceIDs = [:] pendingContextUpload = false storedContextIsRepair = false load() reconcileLegacyAccounts() // Both migrations are held back while the scope is unresolved, and this // is where a launch that resolved late gets them. Reconciliation only // runs the context migration when it changed something, so a namespace // whose accounts were already migrated would otherwise keep its legacy // selection until the next relaunch. Idempotent via `hasStoredContext`. migrateLegacyContextIfNeeded() NotificationCenter.default.post(name: .accountsDidChange, object: nil) // The context belongs to the namespace too, so this reload replaced it. // Anything keyed to the selection — the legacy setting, the feed cache — // would otherwise keep serving the previous Apple account's data. NotificationCenter.default.post(name: .dataContextDidChange, object: nil) // Last, and only once everything above has settled: whoever imports the // signed-out namespace needs this manager to already describe the // identity it is importing into. onNamespaceActivated?() } } extension Notification.Name { static let accountsDidChange = Notification.Name("stream.yattee.accountsDidChange") /// An account's usability changed — not the list of accounts. static let accountStatusesDidChange = Notification.Name("stream.yattee.accountStatusesDidChange") /// An account's stored credential was replaced. The account itself is /// unchanged, so anything holding data fetched with the old one has to be /// told: it may belong to an entirely different person on that server. static let accountCredentialDidChange = Notification.Name("stream.yattee.accountCredentialDidChange") static let dataContextDidChange = Notification.Name("stream.yattee.dataContextDidChange") }