// // LocalNamespaceImporter.swift // Yattee // // Hands accounts created while signed out of iCloud to the identity that // signs in. // import Foundation /// Imports the `local` account namespace into whichever identity is active. /// /// Accounts added while no Apple account was available are stored under /// `accounts.local.*`, with their credentials keyed `local|`. When an /// identity resolves, everything namespaced by it is reloaded — and without this /// the local data would simply stop being read, permanently. /// /// A repeatable sweep rather than a one-shot migration, for two reasons. Signing /// out refills the local namespace, so the same work is owed again on the next /// sign-in; and any part of it that cannot be completed — a credential the /// Keychain refuses to copy — has to be retried rather than lost. Every step is /// therefore idempotent, and per item: an account is removed from the local /// namespace only once both its record and its secret have been dealt with. /// /// Runs entirely synchronously on the main actor. The fences below are checked /// once, at entry, and nothing awaits afterwards — so an identity change cannot /// interleave with a sweep in progress. @MainActor final class LocalNamespaceImporter { private let accountsManager: AccountsManager private let identityScope: CredentialIdentityScope /// The credential stores that could be holding an account's secret. /// /// Usually the one its instance's type names. An account whose instance has /// not synced yet resolves to none, so every account-keyed store is asked /// instead — at most one of them holds anything, and the alternative is /// stranding a credential under a namespace nothing will ever read again. private let credentialStores: @MainActor (Account) -> [any AccountCredentialsManager] init( accountsManager: AccountsManager, identityScope: CredentialIdentityScope, credentialStores: @escaping @MainActor (Account) -> [any AccountCredentialsManager] ) { self.accountsManager = accountsManager self.identityScope = identityScope self.credentialStores = credentialStores } /// Moves whatever the signed-out namespace still holds into the active /// identity. /// /// - Returns: whether anything was taken over, so the caller can recompute /// what it derives from accounts and credentials. @discardableResult func sweepIfNeeded() -> Bool { let local = CredentialIdentityScope.localScope // Decisions 9 and 10: an install with no Apple account — signed out, or // sideloaded with no iCloud entitlement — is permanently scoped `local` // and has nothing to import into, and the `unresolved` holding pen is // never a destination either. guard let scope = identityScope.current, scope != local else { return false } // The same fence `save()` uses. The scope moves the instant the identity // does, while the reload that refills memory runs on a main-queue // observer afterwards; sweeping in that window would merge the local // namespace into a manager still describing the previous identity. guard accountsManager.loadedNamespace == scope else { LoggingService.shared.info( "Skipping the local-namespace import — the account namespace is still reloading", category: .general, details: "loaded=\(accountsManager.loadedNamespace), current=\(scope)" ) return false } let snapshot = accountsManager.localNamespaceSnapshot() guard snapshot.hasContent else { return false } var pruneable: Set = [] var refused: Set = [] for account in snapshot.accounts { switch accountsManager.adoptImportedAccount(account) { case .rejectedByTombstone: // Decision 5: the account was deliberately deleted under this // identity, so the local copy is not resurrected — and its // secret goes with it rather than outliving the record. refused.insert(account.id) for store in credentialStores(account) { store.deleteCredential(forAccountID: account.id, inScope: local) } pruneable.insert(account.id) case .imported, .mergedIntoExisting: // Per-item atomicity: the local record is only dropped once its // credential is verifiably no longer owed by the local scope. A // failure leaves the record in *both* namespaces until the next // sweep — safe, because the collision rule then reads it as an // account the identity already holds and only fills in what is // missing. // Every store is asked even after one fails: they hold separate // Keychain services, and whichever can complete its move should, // rather than waiting on a retry it has nothing to do with. var moved = true for store in credentialStores(account) { let storeMoved = store.migrateCredential(forAccountID: account.id, fromScope: local) moved = moved && storeMoved } if moved { pruneable.insert(account.id) } else { LoggingService.shared.warning( "An imported account's credential could not be moved — leaving it for the next sweep", category: .keychain, details: "accountID=\(account.id)" ) } case .deferred: // Only reachable if the namespace moved under a sweep, which // nothing here awaits long enough to allow. Nothing is pruned. LoggingService.shared.warning( "The account namespace moved during an import — leaving the record where it is", category: .general, details: "accountID=\(account.id)" ) } } // Decision 4: the markers are copied, never moved. accountsManager.adoptImportedMigratedInstances(snapshot.migratedInstanceIDs) var contextHandled = false if let carried = snapshot.storedContext { // Handled either way: adopted when this identity has never chosen a // context, dropped when it has. The local keys are cleared in both // cases, or every activation would offer the same stale selection. contextHandled = true var context = carried.context // Decision 6: never carry a selection naming an account that was // just refused — or one that exists in neither namespace. The user's // own data is the only thing left to point at. if let accountID = context.accountID, refused.contains(accountID) || accountsManager.account(id: accountID) == nil { context = .local } accountsManager.adoptImportedContext( StoredDataContext(context: context, changedAt: carried.changedAt) ) } accountsManager.pruneLocalNamespace( removingAccountIDs: pruneable, clearContext: contextHandled ) let didChange = !pruneable.isEmpty || contextHandled if didChange { LoggingService.shared.info( "Imported the signed-out account namespace into the active identity", category: .general, details: "imported=\(pruneable.count - refused.count), refused=\(refused.count)" ) } return didChange } }