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