mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Invidious answers a revoked session with 403, not 401, so the failure reached recordServerOutcome as a plain HTTP error and fell through the switch: an account whose session died server-side kept reporting signedIn forever, and credentialRejected was unreachable on the commonest backend. The roadmap's rule cuts the other way too — a raw 401/403 alone must never reject, because the failure may be about the operation (someone else's playlist) rather than the login. recordServerOutcome now treats .unauthorized and HTTP 401/403 alike as suspicion, not verdict: after the existing basic-auth-proxy exclusion, it hands the account to a new CredentialProbeService, which re-asks the server with a harmless authenticated read — Invidious's auth subscriptions endpoint with the same SID, Piped's subscriptions with the same token. Only a probe-level 401/403 marks the account credentialRejected; success means the original failure was operation-specific and the account stays signedIn; an unreachable probe records exactly that, and anything ambiguous leaves the status alone. The probe applies its verdict only if the account still exists and still holds the credential it tested — a replacement or an identity flip mid-flight drops the answer, and a dropped answer starts no cooldown, so the login the user just fixed is examined without delay. One probe per account may be in flight; an answered probe stands for a cooldown before the next suspicion re-asks. With rejection finally reachable, the existing machinery engages on its own: the validators fall back to local data, server mutations pause, and a successful call or re-sign-in lifts the mark.
1017 lines
44 KiB
Swift
1017 lines
44 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 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
|
|
|
|
/// 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> = []
|
|
|
|
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)
|
|
}
|
|
|
|
func updateDisplayName(_ displayName: String?, for accountID: UUID) {
|
|
guard let index = accounts.firstIndex(where: { $0.id == accountID }) 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()
|
|
}
|
|
|
|
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) {
|
|
accounts.removeAll { $0.id == accountID }
|
|
accountStatuses[accountID] = nil
|
|
pendingUploads.remove(accountID)
|
|
deletedAccountIDs.insert(accountID)
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
/// 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. Every device that sees
|
|
/// the deletion performs the same repair, so they converge without anyone
|
|
/// writing anything.
|
|
private func correctContextAfterDeletion() {
|
|
storedContext = StoredDataContext(context: .local, changedAt: storedContext.changedAt)
|
|
// 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)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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)
|
|
pendingContextUpload = hasStoredContext
|
|
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.
|
|
guard !deletedAccountIDs.contains(incoming.id) else { 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()
|
|
NotificationCenter.default.post(name: .accountsDidChange, 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
|
|
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
|
|
|
|
// 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 every device that holds the tombstone repairs it identically.
|
|
if let accountID = incoming.context.accountID, deletedAccountIDs.contains(accountID) {
|
|
incoming = StoredDataContext(context: .local, changedAt: incoming.changedAt)
|
|
|
|
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 {
|
|
storedContext = incoming
|
|
hasStoredContext = true
|
|
save()
|
|
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
|
return false
|
|
}
|
|
|
|
guard storedContext.isOutranked(by: incoming) else {
|
|
guard incoming.isOutranked(by: storedContext) else { return false }
|
|
pendingContextUpload = true
|
|
save()
|
|
return true
|
|
}
|
|
storedContext = incoming
|
|
hasStoredContext = true
|
|
save()
|
|
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
|
return false
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
/// 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.
|
|
switch error {
|
|
case APIError.timeout, APIError.noConnection:
|
|
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
|
|
// 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: - 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 {
|
|
"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:)))
|
|
}
|
|
|
|
pendingContextUpload = defaults.bool(forKey: key("pendingContextUpload"))
|
|
|
|
purgeUnresolvedNamespace()
|
|
}
|
|
|
|
/// Every key `save()` writes, for purging a namespace wholesale.
|
|
private static let storedKeyNames = [
|
|
"accounts", "context", "hasContext", "migrated", "pendingUploads",
|
|
"pendingDeletions", "pendingMarkerUploads", "deletedAccounts",
|
|
"deletedInstances", "pendingContextUpload"
|
|
]
|
|
|
|
/// 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(pendingContextUpload, forKey: key("pendingContextUpload"))
|
|
}
|
|
|
|
/// 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 = []
|
|
pendingContextUpload = 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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|