Let the account that signs in claim what was made signed out

This commit is contained in:
Arkadiusz Fal
2026-09-07 23:46:12 +02:00
parent 250fc239c6
commit 87ab014467
9 changed files with 1230 additions and 0 deletions

View File

@@ -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<UUID> = []
/// 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<UUID>) {
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<UUID>, 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?()
}
}

View File

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

View File

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

View File

@@ -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|<accountID>` 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) {

View File

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

View File

@@ -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|<accountID>`. 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<UUID> = []
var refused: Set<UUID> = []
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
}
}

View File

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

View File

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

View File

@@ -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<UUID> { 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)
}
}