mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
174 lines
7.8 KiB
Swift
174 lines
7.8 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|