// // AccountCredentialsManager.swift // Yattee // // Account-keyed credential storage — the multi-login replacement for the // instance-keyed InstanceCredentialsManager. // import Foundation /// Credential storage keyed by `Account`, not by `Instance`. /// /// Adopted by `InvidiousCredentialsManager` and `PipedCredentialsManager`. /// `BasicAuthCredentialsManager` deliberately does **not** adopt it and keeps /// `InstanceCredentialsManager`: HTTP Basic auth is a property of the instance /// (or the reverse proxy in front of it), not of a user account, and a single /// proxy credential covers every account on that instance. @MainActor protocol AccountCredentialsManager: AnyObject, Observable { /// Whether the identity scope has been resolved yet. /// /// While `false`, every read below reports "no credential" because the app /// cannot yet tell which scope to look in. Callers must render that as /// *temporarily unavailable*, never as "signed out" — correcting state on /// it would discard a perfectly good login. var isScopeResolved: Bool { get } func setCredential(_ credential: String, for account: Account) func credential(for account: Account) -> String? func deleteCredential(for account: Account) /// Deletes the credential and stored login identifier for an account this /// device may know nothing else about. /// /// A deletion can reach this device before the account it deletes does — /// the tombstone and the account are separate records, and iCloud Keychain /// runs on its own timeline. The secret must go regardless, and the scoped /// key needs only the account's ID. func deleteCredential(forAccountID accountID: UUID) /// The login identifier the user typed. Kept in the Keychain beside the /// credential so it never enters synced metadata. func setUsername(_ username: String, for account: Account) func username(for account: Account) -> String? func isLoggedIn(for account: Account) -> Bool func refreshLoginStatus(for account: Account) // MARK: - Identity migration /// Moves an account's credential and username from `sourceScope` into the /// current one, for a record being imported out of the `local` namespace /// when an Apple account claims it. /// /// - Returns: `true` only when `sourceScope` holds nothing for this account /// any more — credential and username both. `false` means something is /// still keyed to the source, so the record it belongs to must stay where /// it is: that record is what brings the retry. @discardableResult func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool /// Deletes an account's credential and username under a named scope. /// /// The scoped counterpart of `deleteCredential(forAccountID:)`: an import /// refused because this identity already tombstoned the account has to take /// the secret with it, and that secret is keyed by the scope being left /// rather than the one in force. /// /// - Returns: whether that scope is now free of the account, on the same /// terms as `migrateCredential(forAccountID:fromScope:)`. @discardableResult func deleteCredential(forAccountID accountID: UUID, inScope scope: String) -> Bool // MARK: - Legacy adoption /// Whether a pre-revamp credential for this account's instance exists on /// this device and has not been adopted by the current identity. /// /// True only for the account the instance's legacy login was migrated into /// — the old item is keyed by instance, and a second login the user added /// themselves must never inherit it. func hasAdoptableLegacyCredential(for account: Account) -> Bool /// Copies the legacy item into this account's scoped key. Returns whether /// the copy verified. /// /// Runs by itself as part of recomputing account status: the upgrade is not /// a decision to put to the user. A retained Keychain item carries no proof /// of which Apple account it belonged to (measured — see /// ``CredentialIdentityScope``), but the pre-revamp release read that item /// with `kSecAttrSynchronizableAny` and no identity in its key, so it was /// already being served to whoever held the device. The copy is written /// device-local so this migration cannot put a secret of unproven /// provenance into the current identity's iCloud Keychain. @discardableResult func adoptLegacyCredential(for account: Account) -> Bool /// Deletes the unscoped legacy item **only** if this identity adopted it /// and it still holds the adopted secret. Called when an account is really /// deleted, never on mere absence. func deleteLegacyCredentialIfOwned(for account: Account) }