Files
yattee/Yattee/Core/AccountsManager.swift
Arkadiusz Fal 03037e6769 Let a refused host count as unreachable and the switcher own every login
Residual round after the macOS fix verification, five closures:

A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.

Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.

The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.

The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.

The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
2026-09-12 17:10:58 +02:00

1563 lines
72 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 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.
var selectableAccounts: [Account] {
accounts.filter { 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)
}
/// 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 { 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 }
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, 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 }
// 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")
}