diff --git a/Yattee/Core/AccountsManager.swift b/Yattee/Core/AccountsManager.swift index a6002c20..6568bbd0 100644 --- a/Yattee/Core/AccountsManager.swift +++ b/Yattee/Core/AccountsManager.swift @@ -1022,6 +1022,222 @@ final class AccountsManager { 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 + } + + /// 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. + @discardableResult + func adoptImportedAccount(_ account: Account) -> ImportOutcome { + 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 + } + + // 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, account.displayName != existing.displayName { + updateDisplayName(account.displayName, 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 } + + let added = ids.subtracting(migratedInstanceIDs) + guard !added.isEmpty else { return } + + migratedInstanceIDs.formUnion(added) + // A marker suppresses synthesis on every other device, so it may only be + // published for an instance whose account this identity actually holds. + // Publishing it for one whose account was refused would leave that + // instance's pre-revamp login unrepresented everywhere. + for instanceID in added where !accounts(forInstance: instanceID).isEmpty { + pendingMarkerUploads.insert(instanceID) + } + save() + } + + /// Carries the local namespace's context over, if this identity has none. + /// + /// 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. + func adoptImportedContext(_ stored: StoredDataContext) { + guard canImportIntoCurrentNamespace else { return } + guard !hasStoredContext 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 @@ -1052,6 +1268,12 @@ final class AccountsManager { @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)" } @@ -1225,6 +1447,11 @@ final class AccountsManager { // 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?() } } diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 3400855d..1fcc4588 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -53,6 +53,8 @@ final class AppEnvironment { let credentialIdentityScope: CredentialIdentityScope let accountsManager: AccountsManager let accountSyncCoordinator: AccountSyncCoordinator + /// Hands accounts added while signed out to whichever identity signs in. + let localNamespaceImporter: LocalNamespaceImporter let homeInstanceCache: HomeInstanceCache let invidiousAPI: InvidiousAPI let pipedAPI: PipedAPI @@ -218,6 +220,37 @@ final class AppEnvironment { let accounts = AccountsManager(identityScope: identityScope, instancesManager: instances) self.accountsManager = accounts + // Accounts created while signed out live in the `local` namespace, and + // nothing reads that namespace once an identity resolves. This is what + // moves them — records, credentials and the chosen context — into the + // Apple account that signs in. + self.localNamespaceImporter = LocalNamespaceImporter( + accountsManager: accounts, + identityScope: identityScope, + credentialStores: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in + // Same resolution as `accountCredentialsManager(for:)`, except + // that an instance which has not synced yet is not an answer: + // the scoped key is the account ID either way, so both stores + // are asked and at most one of them holds anything. + var stores: [any AccountCredentialsManager] = [] + + guard let type = accounts?.instance(for: account)?.type else { + if let invidiousCreds { stores.append(invidiousCreds) } + if let pipedCreds { stores.append(pipedCreds) } + return stores + } + + switch type { + case .invidious: if let invidiousCreds { stores.append(invidiousCreds) } + case .piped: if let pipedCreds { stores.append(pipedCreds) } + // The slot a future account-keyed store lands in, exactly as in + // `accountCredentialsManager(for:)`. + default: break + } + return stores + } + ) + // Initialize Invidious API (used by SubscriptionService and SubscriptionFeedCache) let invidiousAPI = InvidiousAPI(httpClient: client) self.invidiousAPI = invidiousAPI @@ -518,6 +551,17 @@ final class AppEnvironment { self?.refreshAccountStatuses() } + // Every activation of an identity namespace sweeps the signed-out one. + // A hook rather than another observer of the scope change: the sweep + // must run after the manager has reloaded, and observer registration + // order is not something to depend on for that. Statuses are recomputed + // only when something moved — a credential that just arrived under this + // identity is what decides whether its account reads as signed in. + accounts.onNamespaceActivated = { [weak self] in + guard let self, self.localNamespaceImporter.sweepIfNeeded() else { return } + self.refreshAccountStatuses() + } + accountSync.onAccountDeleted = { [weak self] accountID, account in guard let self else { return } @@ -592,6 +636,14 @@ final class AppEnvironment { // it runs again whenever instances arrive from iCloud. accounts.reconcileLegacyAccounts() accounts.migrateLegacyContextIfNeeded() + + // A launch that restores its identity scope from cache resolves inside + // the initializer above, so no scope change will ever be announced for + // it and `onNamespaceActivated` never fires. Invoked once here so that + // path sweeps too; the sweep is idempotent, so an identity that does + // arrive later simply finds nothing left. + localNamespaceImporter.sweepIfNeeded() + refreshAccountStatuses() // Seeded once the statuses it depends on are known, so launching does diff --git a/Yattee/Services/Credentials/AccountCredentialsManager.swift b/Yattee/Services/Credentials/AccountCredentialsManager.swift index d75cca53..fd58e87e 100644 --- a/Yattee/Services/Credentials/AccountCredentialsManager.swift +++ b/Yattee/Services/Credentials/AccountCredentialsManager.swift @@ -55,6 +55,26 @@ protocol AccountCredentialsManager: AnyObject, Observable { func isLoggedIn(for account: Account) -> Bool func refreshLoginStatus(for account: Account) + // MARK: - Identity migration + + /// Moves an account's credential and username from `sourceScope` into the + /// current one, for a record being imported out of the `local` namespace + /// when an Apple account claims it. + /// + /// - Returns: `true` when `sourceScope` no longer holds anything for this + /// account. `false` means the move failed and left the source intact, so + /// the record it belongs to must stay where it is and be retried. + @discardableResult + func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool + + /// Deletes an account's credential and username under a named scope. + /// + /// The scoped counterpart of `deleteCredential(forAccountID:)`: an import + /// refused because this identity already tombstoned the account has to take + /// the secret with it, and that secret is keyed by the scope being left + /// rather than the one in force. + func deleteCredential(forAccountID accountID: UUID, inScope scope: String) + // MARK: - Legacy adoption /// Whether a pre-revamp credential for this account's instance exists on diff --git a/Yattee/Services/Credentials/AccountKeychainCredentials.swift b/Yattee/Services/Credentials/AccountKeychainCredentials.swift index e78a1cb3..59a9f125 100644 --- a/Yattee/Services/Credentials/AccountKeychainCredentials.swift +++ b/Yattee/Services/Credentials/AccountKeychainCredentials.swift @@ -210,6 +210,180 @@ final class AccountKeychainCredentials { ) } + // MARK: - Identity migration + + /// Moves an account's credential and username out of `sourceScope` and into + /// the scope that is current now. + /// + /// For accounts created while no Apple account was available: they live in + /// the `local` namespace, and the credential keyed `local|` has + /// to follow the record when an identity claims it. Written the way a fresh + /// sign-in under this identity would write it, with three protections: + /// `addIfAbsent` so a credential already held under the current scope is + /// never clobbered (decision 5), a read-back before anything is deleted, and + /// the source left completely untouched on any failure so the next sweep can + /// try again. + /// + /// - Returns: `true` when `sourceScope` no longer owes this account + /// anything — the credential moved, the target already held one and the + /// source copy was redundant, or there was never one to move. `false` + /// means the source is exactly as it was, and the caller must not prune + /// the record that points at it. + @discardableResult + func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool { + guard let scope = currentScope() else { return false } + // Nothing to move, and moving would delete what it had just written. + guard sourceScope != scope else { return true } + + let sourceKey = KeychainCredentialStore.credentialKey(scope: sourceScope, accountID: accountID) + let targetKey = KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID) + + guard let secret = store.value(forKey: sourceKey) else { + // An account that was never signed in under the source scope. Its + // username still moves — it is what the sign-in sheet prefills — + // and there is nothing left for a retry to recover, so the record + // may be pruned. + migrateUsername(forAccountID: accountID, from: sourceScope, to: scope) + loginsByScope[sourceScope]?.remove(accountID) + return true + } + + // Decision 5: a credential already stored under the current identity was + // written by a sign-in this Apple account actually made, so the copy + // being migrated is redundant rather than a replacement. + if store.value(forKey: targetKey) != nil { + migrateUsername(forAccountID: accountID, from: sourceScope, to: scope) + store.delete(key: sourceKey) + loginsByScope[sourceScope]?.remove(accountID) + loginsByScope[scope, default: []].insert(accountID) + + LoggingService.shared.info( + "Dropped a \(credentialNoun) made redundant by one already held under this identity", + category: .keychain, + details: "accountID=\(accountID), from=\(sourceScope)" + ) + return true + } + + // A secret adopted from the pre-revamp unscoped item keeps its + // provenance across the move: `adoptLegacyCredential` deliberately + // writes it device-local so a login of unproven origin never reaches an + // iCloud Keychain, and re-filing it as synchronizable here would undo + // exactly that. Markers are keyed by instance, and only a migrated + // account — whose ID *is* its instance's — is ever allowed to adopt, so + // the account ID is the right instance to ask about. + let wasAdopted = markers.hasAdopted( + service: store.service, + instanceID: accountID, + scope: sourceScope + ) + let syncEnabled = wasAdopted ? false : shouldSyncToiCloud + + let status = store.addIfAbsent(secret, forKey: targetKey, synchronizable: syncEnabled) + guard status == errSecSuccess else { + LoggingService.shared.error( + "Could not move a \(credentialNoun) into the current identity — leaving it where it is", + category: .keychain, + details: "accountID=\(accountID), from=\(sourceScope), status=\(store.describe(status))" + ) + return false + } + + // Verified before the source is deleted, the same way legacy adoption + // is: a write that reports success and reads back as something else is + // the one case where deleting the source would lose the credential + // outright. Whatever is there is left alone and the source stands, so + // the next sweep resolves it — as a redundant copy if a real credential + // arrived in the gap. + guard store.value(forKey: targetKey) == secret else { + LoggingService.shared.error( + "A moved \(credentialNoun) did not verify — the source is left for the next attempt", + category: .keychain, + details: "accountID=\(accountID), from=\(sourceScope)" + ) + return false + } + + migrateUsername(forAccountID: accountID, from: sourceScope, to: scope) + store.delete(key: sourceKey) + + if wasAdopted { + // The marker describes where the adopted copy now lives, or the + // deletion that retires the unscoped item would no longer recognise + // it as this account's to retire. + markers.record( + service: store.service, + instanceID: accountID, + accountID: accountID, + scope: scope, + secret: secret + ) + markers.removeMarker( + service: store.service, + instanceID: accountID, + accountID: accountID, + scope: sourceScope + ) + } + + loginsByScope[sourceScope]?.remove(accountID) + loginsByScope[scope, default: []].insert(accountID) + + LoggingService.shared.info( + "Moved a \(credentialNoun) into the current identity", + category: .keychain, + details: "accountID=\(accountID), from=\(sourceScope), iCloudSync=\(syncEnabled)" + ) + return true + } + + /// Moves the username item, which is a convenience rather than a secret. + /// + /// Never fails the migration around it: a credential is what a session + /// depends on, and a username that could not be copied costs a prefilled + /// field. The source is deleted only once the target really holds one. + private func migrateUsername(forAccountID accountID: UUID, from sourceScope: String, to scope: String) { + let sourceKey = KeychainCredentialStore.usernameKey(scope: sourceScope, accountID: accountID) + guard let username = store.value(forKey: sourceKey) else { return } + + let targetKey = KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID) + if store.value(forKey: targetKey) == nil { + store.addIfAbsent(username, forKey: targetKey, synchronizable: shouldSyncToiCloud) + } + + guard store.value(forKey: targetKey) != nil else { return } + store.delete(key: sourceKey) + } + + /// Deletes an account's credential and username under a scope that is not + /// necessarily the current one. + /// + /// For the import sweep's tombstone rule: an account this identity has + /// deliberately deleted is never imported, and the secret it was keyed to + /// goes with the record rather than sitting under a namespace nothing will + /// read again. + func deleteCredential(forAccountID accountID: UUID, inScope scope: String) { + store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID)) + store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID)) + loginsByScope[scope]?.remove(accountID) + + // Its marker described a copy that no longer exists. Keyed by instance, + // and only a migrated account can have adopted anything, so the account + // ID is the instance to name here. + markers.removeMarker( + service: store.service, + instanceID: accountID, + accountID: accountID, + scope: scope + ) + + LoggingService.shared.info( + "Deleted a \(credentialNoun) left behind by a refused import", + category: .keychain, + details: "accountID=\(accountID), scope=\(scope)" + ) + } + // MARK: - Username func setUsername(_ username: String, for account: Account) { diff --git a/Yattee/Services/Credentials/InvidiousCredentialsManager.swift b/Yattee/Services/Credentials/InvidiousCredentialsManager.swift index da60d1dd..a730755f 100644 --- a/Yattee/Services/Credentials/InvidiousCredentialsManager.swift +++ b/Yattee/Services/Credentials/InvidiousCredentialsManager.swift @@ -309,6 +309,15 @@ extension InvidiousCredentialsManager: AccountCredentialsManager { accountCredentials.refreshLoginStatus(for: account) } + @discardableResult + func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool { + accountCredentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope) + } + + func deleteCredential(forAccountID accountID: UUID, inScope scope: String) { + accountCredentials.deleteCredential(forAccountID: accountID, inScope: scope) + } + func hasAdoptableLegacyCredential(for account: Account) -> Bool { accountCredentials.hasAdoptableLegacyCredential(for: account) } diff --git a/Yattee/Services/Credentials/LocalNamespaceImporter.swift b/Yattee/Services/Credentials/LocalNamespaceImporter.swift new file mode 100644 index 00000000..a7e45b29 --- /dev/null +++ b/Yattee/Services/Credentials/LocalNamespaceImporter.swift @@ -0,0 +1,173 @@ +// +// 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 + } +} diff --git a/Yattee/Services/Credentials/PipedCredentialsManager.swift b/Yattee/Services/Credentials/PipedCredentialsManager.swift index bc03d086..6590e54c 100644 --- a/Yattee/Services/Credentials/PipedCredentialsManager.swift +++ b/Yattee/Services/Credentials/PipedCredentialsManager.swift @@ -238,6 +238,15 @@ extension PipedCredentialsManager: AccountCredentialsManager { accountCredentials.refreshLoginStatus(for: account) } + @discardableResult + func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool { + accountCredentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope) + } + + func deleteCredential(forAccountID accountID: UUID, inScope scope: String) { + accountCredentials.deleteCredential(forAccountID: accountID, inScope: scope) + } + func hasAdoptableLegacyCredential(for account: Account) -> Bool { accountCredentials.hasAdoptableLegacyCredential(for: account) } diff --git a/YatteeTests/AccountsScreenTests.swift b/YatteeTests/AccountsScreenTests.swift index aa90dfc1..c9ab3f88 100644 --- a/YatteeTests/AccountsScreenTests.swift +++ b/YatteeTests/AccountsScreenTests.swift @@ -243,6 +243,8 @@ private final class DeletionOrderStore: AccountCredentialsManager { && accountsManager.pendingDeletions.contains(accountID) } + func migrateCredential(forAccountID _: UUID, fromScope _: String) -> Bool { true } + func deleteCredential(forAccountID _: UUID, inScope _: String) {} func resyncCredentialsWithCurrentSetting() {} func setUsername(_: String, for _: Account) {} func username(for _: Account) -> String? { nil } diff --git a/YatteeTests/LocalNamespaceImportTests.swift b/YatteeTests/LocalNamespaceImportTests.swift new file mode 100644 index 00000000..567dcbc4 --- /dev/null +++ b/YatteeTests/LocalNamespaceImportTests.swift @@ -0,0 +1,564 @@ +// +// LocalNamespaceImportTests.swift +// YatteeTests +// +// Importing the signed-out account namespace into the Apple account that +// claims it. +// + +import Testing +import Foundation +@testable import Yattee + +@Suite("Local namespace import") +@MainActor +struct LocalNamespaceImportTests { + private var localScope: String { CredentialIdentityScope.localScope } + + // MARK: - Basics + + @Test("An account created while signed out follows the identity that signs in") + func accountCredentialAndContextAreImported() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let instance = harness.addInstance() + + let account = harness.accounts.addAccount(instanceID: instance.id, displayName: "Main") + harness.credentials.setCredential("secret", for: account) + harness.credentials.setUsername("btest1", for: account) + harness.accounts.setContext(.account(account.id)) + defer { harness.cleanUp(account.id) } + + try await harness.flip(to: "identity-A") + + // The record landed in the identity's namespace, owed to the server. + #expect(harness.accounts.account(id: account.id)?.displayName == "Main") + #expect(harness.accounts.pendingUploads.contains(account.id)) + + // The credential came with it and reads under the new scope. + #expect(harness.credentials.credential(for: account) == "secret") + #expect(harness.credentials.username(for: account) == "btest1") + #expect(harness.credentials.isLoggedIn(for: account)) + + // Move semantics: nothing is left behind in either store. + #expect(harness.rawCredential(scope: localScope, accountID: account.id) == nil) + #expect(harness.rawUsername(scope: localScope, accountID: account.id) == nil) + #expect(harness.storedAccounts(in: localScope).isEmpty) + + // The selection carried over, epoch-stamped and owed to the server. + #expect(harness.accounts.currentContext == .account(account.id)) + #expect(harness.accounts.storedContext.isMigrated) + #expect(harness.accounts.pendingContextUpload) + + // Its instance's migration marker travelled with it. + #expect(harness.accounts.migratedInstanceIDs.contains(instance.id)) + } + + @Test("An account that was never signed in imports and prunes cleanly") + func accountWithoutCredentialImports() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Never signed in") + defer { harness.cleanUp(account.id) } + + try await harness.flip(to: "identity-A") + + #expect(harness.accounts.account(id: account.id)?.displayName == "Never signed in") + #expect(harness.credentials.credential(for: account) == nil) + #expect(harness.storedAccounts(in: localScope).isEmpty) + } + + // MARK: - Collisions + + @Test("An account the identity already holds keeps its record and only fills gaps") + func collisionKeepsTheIdentityRecord() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let instanceID = UUID() + + let newer = UUID() + let older = UUID() + let alreadySignedIn = UUID() + defer { harness.cleanUp(newer, older, alreadySignedIn) } + + // What the identity already knows, from its own devices. + harness.seed("identity-A") { manager in + for id in [newer, older, alreadySignedIn] { + manager.applySyncedAccount( + Account( + id: id, + instanceID: instanceID, + displayName: "Identity", + updatedAt: Date(timeIntervalSince1970: 1000) + ) + ) + } + } + harness.seedCredential("identity-secret", accountID: alreadySignedIn, scope: "identity-A") + + // What the device holds from being signed out. + harness.accounts.applySyncedAccount( + Account(id: newer, instanceID: instanceID, displayName: "Local newer", updatedAt: Date()) + ) + harness.accounts.applySyncedAccount( + Account( + id: older, + instanceID: instanceID, + displayName: "Local older", + updatedAt: Date(timeIntervalSince1970: 500) + ) + ) + harness.accounts.applySyncedAccount( + Account( + id: alreadySignedIn, + instanceID: instanceID, + displayName: "Identity", + updatedAt: Date(timeIntervalSince1970: 1000) + ) + ) + harness.seedCredential("local-secret", accountID: newer, scope: localScope) + harness.seedCredential("local-secret", accountID: alreadySignedIn, scope: localScope) + + try await harness.flip(to: "identity-A") + + // A strictly newer label is merged; an older one is not. + #expect(harness.accounts.account(id: newer)?.displayName == "Local newer") + #expect(harness.accounts.account(id: older)?.displayName == "Identity") + + // A credential is only ever filled in where the identity had none. + #expect(harness.rawCredential(scope: "identity-A", accountID: newer) == "local-secret") + #expect(harness.rawCredential(scope: "identity-A", accountID: alreadySignedIn) == "identity-secret") + + // The redundant copies are gone and the local namespace is empty. + #expect(harness.rawCredential(scope: localScope, accountID: newer) == nil) + #expect(harness.rawCredential(scope: localScope, accountID: alreadySignedIn) == nil) + #expect(harness.storedAccounts(in: localScope).isEmpty) + } + + @Test("An account this identity deleted is never resurrected from a local copy") + func tombstoneRefusesTheImport() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let deleted = UUID() + let instanceID = UUID() + defer { harness.cleanUp(deleted) } + + harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) } + + harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: instanceID)) + harness.seedCredential("local-secret", accountID: deleted, scope: localScope) + let survivor = harness.accounts.addAccount(instanceID: UUID()) + defer { harness.cleanUp(survivor.id) } + + try await harness.flip(to: "identity-A") + + #expect(harness.accounts.account(id: deleted) == nil) + // Nothing is owed to the server for a record that was refused — and the + // account that *was* imported still is. + #expect(harness.accounts.pendingUploads == [survivor.id]) + #expect(harness.accounts.pendingDeletions.isEmpty) + + // The secret went with the refused record, under both scopes. + #expect(harness.rawCredential(scope: localScope, accountID: deleted) == nil) + #expect(harness.rawCredential(scope: "identity-A", accountID: deleted) == nil) + #expect(harness.storedAccounts(in: localScope).isEmpty) + } + + // MARK: - Context + + @Test("A context chosen while signed out is dropped when the identity has one") + func identityContextWins() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let account = harness.accounts.addAccount(instanceID: UUID()) + defer { harness.cleanUp(account.id) } + + harness.accounts.setContext(.account(account.id)) + harness.seed("identity-A") { $0.setContext(.local) } + + try await harness.flip(to: "identity-A") + + #expect(harness.accounts.currentContext == .local) + // The local keys are cleared either way, or every activation would offer + // the same stale selection again. + #expect(harness.defaults.data(forKey: "accounts.local.context") == nil) + #expect(!harness.defaults.bool(forKey: "accounts.local.hasContext")) + } + + @Test("A context naming an account the identity refused falls back to local data") + func contextNamingARefusedAccountFallsBack() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let deleted = UUID() + defer { harness.cleanUp(deleted) } + + harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) } + + harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: UUID())) + harness.accounts.setContext(.account(deleted)) + + try await harness.flip(to: "identity-A") + + #expect(harness.accounts.account(id: deleted) == nil) + #expect(harness.accounts.currentContext == .local) + #expect(harness.accounts.storedContext.isMigrated) + } + + // MARK: - Repetition + + @Test("Sweeping again changes nothing and duplicates nothing") + func sweepIsIdempotent() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main") + harness.credentials.setCredential("secret", for: account) + harness.accounts.setContext(.account(account.id)) + defer { harness.cleanUp(account.id) } + + try await harness.flip(to: "identity-A") + + let accountsAfterFirst = harness.accounts.accounts + let uploadsAfterFirst = harness.accounts.pendingUploads + let contextAfterFirst = harness.accounts.storedContext + + #expect(harness.importer.sweepIfNeeded() == false) + #expect(harness.importer.sweepIfNeeded() == false) + + #expect(harness.accounts.accounts == accountsAfterFirst) + #expect(harness.accounts.pendingUploads == uploadsAfterFirst) + #expect(harness.accounts.storedContext == contextAfterFirst) + #expect(harness.credentials.credential(for: account) == "secret") + } + + @Test("A sign-out and sign-in cycle imports only what is new") + func repeatedCyclesImportOnlyTheNewAccounts() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let first = harness.accounts.addAccount(instanceID: UUID(), displayName: "First") + harness.credentials.setCredential("first-secret", for: first) + defer { harness.cleanUp(first.id) } + + try await harness.flip(to: "identity-A") + #expect(harness.accounts.accounts.map(\.id) == [first.id]) + + // Signed out again: the local namespace is empty, and whatever is added + // now lands there. + try await harness.flip(to: CredentialIdentityScope.localScope) + #expect(harness.accounts.accounts.isEmpty) + + let second = harness.accounts.addAccount(instanceID: UUID(), displayName: "Second") + harness.credentials.setCredential("second-secret", for: second) + defer { harness.cleanUp(second.id) } + + try await harness.flip(to: "identity-A") + + #expect(harness.accounts.accounts.count == 2) + #expect(harness.accounts.account(id: first.id)?.displayName == "First") + #expect(harness.accounts.account(id: second.id)?.displayName == "Second") + #expect(harness.credentials.credential(for: first) == "first-secret") + #expect(harness.credentials.credential(for: second) == "second-secret") + #expect(harness.storedAccounts(in: localScope).isEmpty) + } + + @Test("The signed-out migration markers stay behind, so nothing is synthesized twice") + func localMigrationMarkersAreRetained() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let instance = harness.addInstance(usesAccountLogin: true) + defer { harness.cleanUp(instance.id) } + + harness.accounts.reconcileLegacyAccounts() + let synthesized = try #require(harness.accounts.accounts(forInstance: instance.id).first) + #expect(synthesized.id == instance.id) + + try await harness.flip(to: "identity-A") + #expect(harness.accounts.account(id: instance.id) != nil) + #expect(harness.accounts.migratedInstanceIDs.contains(instance.id)) + + // Signing out again must not re-synthesize the account that just left: + // `usesAccountLogin` is still true on the instance forever, and only the + // retained marker stops the migration running a second time. + try await harness.flip(to: CredentialIdentityScope.localScope) + #expect(harness.accounts.accounts.isEmpty) + + harness.accounts.reconcileLegacyAccounts() + #expect(harness.accounts.accounts.isEmpty) + #expect(harness.accounts.migratedInstanceIDs.contains(instance.id)) + } + + @Test("A deletion made while signed out is never published into the Apple account") + func localDeletionFactsStayLocal() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let doomed = harness.accounts.addAccount(instanceID: UUID()) + harness.accounts.removeAccount(doomed.id) + + let kept = harness.accounts.addAccount(instanceID: UUID()) + defer { harness.cleanUp(doomed.id, kept.id) } + + try await harness.flip(to: "identity-A") + + #expect(harness.accounts.accounts.map(\.id) == [kept.id]) + #expect(harness.accounts.deletedAccountIDs.isEmpty) + #expect(harness.accounts.pendingDeletions.isEmpty) + // ...and the local namespace keeps them, so signing out still works. + #expect( + harness.defaults.array(forKey: "accounts.local.deletedAccounts") as? [String] + == [doomed.id.uuidString] + ) + } + + // MARK: - Fences + + @Test("A sweep is refused while the account namespace is still reloading") + func sweepIsFencedOnTheLoadedNamespace() { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let account = harness.accounts.addAccount(instanceID: UUID()) + harness.credentials.setCredential("secret", for: account) + defer { harness.cleanUp(account.id) } + + // The scope moves before the observer that reloads the manager runs. + harness.scope.setFixedScope("identity-A", announcing: false) + + #expect(harness.importer.sweepIfNeeded() == false) + #expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id]) + #expect(harness.defaults.data(forKey: "accounts.identity-A.accounts") == nil) + #expect(harness.rawCredential(scope: localScope, accountID: account.id) == "secret") + } + + @Test("An unresolved scope is never a destination, and the identity sweeps once") + func unresolvedScopeIsNotADestination() async throws { + let harness = Harness(startingScope: CredentialIdentityScope.localScope) + let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main") + harness.credentials.setCredential("secret", for: account) + defer { harness.cleanUp(account.id) } + + // iCloud is signed in but the user record has not been fetched yet. + try await harness.flip(to: nil) + #expect(harness.sweeps == 0) + #expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id]) + #expect(harness.defaults.data(forKey: "accounts.unresolved.accounts") == nil) + + try await harness.flip(to: "identity-A") + #expect(harness.sweeps == 1) + #expect(harness.accounts.account(id: account.id) != nil) + + // Any later activation of the same identity finds nothing left to do. + harness.accounts.reloadForCurrentIdentity() + #expect(harness.sweeps == 1) + #expect(harness.accounts.account(id: account.id) != nil) + } + + @Test("Whichever identity activates claims what was made while signed out") + func identitySwitchClaimsTheLocalNamespace() async throws { + let harness = Harness(startingScope: "identity-A") + let held = harness.accounts.addAccount(instanceID: UUID(), displayName: "A's own") + defer { harness.cleanUp(held.id) } + + // Made while the device was signed out, before B signed in. + let madeSignedOut = UUID() + defer { harness.cleanUp(madeSignedOut) } + harness.seed(CredentialIdentityScope.localScope) { manager in + manager.applySyncedAccount( + Account(id: madeSignedOut, instanceID: UUID(), displayName: "Made signed out") + ) + } + harness.seedCredential("local-secret", accountID: madeSignedOut, scope: localScope) + + try await harness.flip(to: "identity-B") + + #expect(harness.accounts.account(id: madeSignedOut)?.displayName == "Made signed out") + #expect(harness.accounts.pendingUploads.contains(madeSignedOut)) + #expect(harness.rawCredential(scope: "identity-B", accountID: madeSignedOut) == "local-secret") + #expect(harness.storedAccounts(in: localScope).isEmpty) + + // A's own namespace is untouched by B claiming the local one. + #expect(harness.storedAccounts(in: "identity-A").map(\.id) == [held.id]) + #expect(harness.accounts.account(id: held.id) == nil) + } +} + +// MARK: - Test Support + +/// A full local-namespace setup: storage, a Keychain service, an identity scope +/// that can be moved, and the importer wired the way the app wires it. +@MainActor +private final class Harness { + let defaults: UserDefaults + let scope: CredentialIdentityScope + let instances: InstancesManager + let accounts: AccountsManager + let credentials: TestCredentialsManager + let importer: LocalNamespaceImporter + + /// Sweeps that actually took something over. + private(set) var sweeps = 0 + + private let store: KeychainCredentialStore + + init(startingScope: String?) { + let defaults = UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")! + // Unique per run so a crashed run's leftovers can never be read back. + let service = "com.yattee.tests.\(UUID().uuidString)" + + self.defaults = defaults + self.store = KeychainCredentialStore(service: service) + self.instances = makeTestInstancesManager() + + let scope = CredentialIdentityScope(fixedScope: startingScope) + self.scope = scope + + let accounts = AccountsManager( + identityScope: scope, + instancesManager: instances, + defaults: defaults + ) + self.accounts = accounts + + let credentials = TestCredentialsManager( + service: service, + identityScope: scope, + markers: CredentialAdoptionMarkerStore(defaults: defaults) + ) + self.credentials = credentials + + self.importer = LocalNamespaceImporter( + accountsManager: accounts, + identityScope: scope, + credentialStores: { _ in [credentials] } + ) + + accounts.onNamespaceActivated = { [weak self] in + guard let self, self.importer.sweepIfNeeded() else { return } + self.sweeps += 1 + } + } + + // MARK: Driving + + /// Moves the identity scope the way a sign-in does, and lets the main-queue + /// observers that reload and sweep run. + func flip(to newScope: String?) async throws { + scope.setFixedScope(newScope) + try await Task.sleep(for: .milliseconds(50)) + } + + @discardableResult + func addInstance(usesAccountLogin: Bool = false) -> Instance { + let instance = Instance( + type: .invidious, + url: URL(string: "https://import-\(UUID().uuidString).example.com")!, + usesAccountLogin: usesAccountLogin + ) + instances.add(instance) + return instance + } + + /// Writes into a namespace other than the one under test, the way another + /// device's sync would have filled it. + func seed(_ namespace: String, _ body: (AccountsManager) -> Void) { + body( + AccountsManager( + identityScope: CredentialIdentityScope(fixedScope: namespace), + instancesManager: instances, + defaults: defaults + ) + ) + } + + func seedCredential(_ value: String, accountID: UUID, scope: String) { + store.set( + value, + forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID), + synchronizable: false + ) + } + + // MARK: Reading storage directly + + func storedAccounts(in namespace: String) -> [Account] { + guard let data = defaults.data(forKey: "accounts.\(namespace).accounts"), + let decoded = try? JSONDecoder().decode([Account].self, from: data) + else { return [] } + return decoded + } + + func rawCredential(scope: String, accountID: UUID) -> String? { + store.value(forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID)) + } + + func rawUsername(scope: String, accountID: UUID) -> String? { + store.value(forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID)) + } + + /// The Keychain outlives the test process, so every key a test could have + /// written is removed by hand. + func cleanUp(_ accountIDs: UUID...) { + for accountID in accountIDs { + for scope in [CredentialIdentityScope.localScope, "identity-A", "identity-B", "unresolved"] { + store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID)) + store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID)) + } + store.delete(key: KeychainCredentialStore.legacyKey(instanceID: accountID)) + } + } +} + +/// An account-keyed credential store over a Keychain service of its own. +/// +/// The shipping managers hard-code their service names, which under a test host +/// are the ones the installed app uses. This wraps the same engine they wrap, so +/// the protocol the importer talks to is exercised for real. +@MainActor +@Observable +private final class TestCredentialsManager: AccountCredentialsManager { + private let credentials: AccountKeychainCredentials + + init(service: String, identityScope: CredentialIdentityScope, markers: CredentialAdoptionMarkerStore) { + credentials = AccountKeychainCredentials( + service: service, + credentialNoun: "credential", + identityScope: identityScope, + markers: markers + ) + } + + var loggedInAccountIDs: Set { credentials.loggedInAccountIDs } + var isScopeResolved: Bool { credentials.isScopeResolved } + + func setCredential(_ credential: String, for account: Account) { + credentials.setCredential(credential, for: account) + } + + func credential(for account: Account) -> String? { credentials.credential(for: account) } + + func deleteCredential(for account: Account) { credentials.deleteCredential(for: account) } + + func deleteCredential(forAccountID accountID: UUID) { + credentials.deleteCredential(forAccountID: accountID) + } + + func resyncCredentialsWithCurrentSetting() { credentials.resyncCredentialsWithCurrentSetting() } + + func setUsername(_ username: String, for account: Account) { + credentials.setUsername(username, for: account) + } + + func username(for account: Account) -> String? { credentials.username(for: account) } + + func isLoggedIn(for account: Account) -> Bool { credentials.isLoggedIn(for: account) } + + func refreshLoginStatus(for account: Account) { credentials.refreshLoginStatus(for: account) } + + @discardableResult + func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool { + credentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope) + } + + func deleteCredential(forAccountID accountID: UUID, inScope scope: String) { + credentials.deleteCredential(forAccountID: accountID, inScope: scope) + } + + func hasAdoptableLegacyCredential(for account: Account) -> Bool { + credentials.hasAdoptableLegacyCredential(for: account) + } + + @discardableResult + func adoptLegacyCredential(for account: Account) -> Bool { + credentials.adoptLegacyCredential(for: account) + } + + func deleteLegacyCredentialIfOwned(for account: Account) { + credentials.deleteLegacyCredentialIfOwned(for: account) + } +}