diff --git a/Yattee/Core/AccountsManager.swift b/Yattee/Core/AccountsManager.swift new file mode 100644 index 00000000..e215a32a --- /dev/null +++ b/Yattee/Core/AccountsManager.swift @@ -0,0 +1,391 @@ +// +// 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 { + /// 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 = [] + + /// 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 = [] + private(set) var pendingDeletions: Set = [] + + var currentContext: DataContext { storedContext.context } + + // MARK: - Initialization + + init( + identityScope: CredentialIdentityScope, + instancesManager: InstancesManager? = nil, + defaults: UserDefaults = .standard + ) { + self.identityScope = identityScope + self.instancesManager = instancesManager + self.defaults = defaults + + load() + + 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. Surfaced in the accounts screen with a + /// remove action rather than being deleted automatically — an instance can + /// be missing because iCloud hasn't delivered it yet. + 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) + save() + return account + } + + 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() + } + + /// 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 let account = account(id: accountID) else { return } + + accounts.removeAll { $0.id == accountID } + accountStatuses[accountID] = nil + pendingUploads.remove(accountID) + pendingDeletions.insert(accountID) + + // Selecting a deleted account would leave the UI pointing at nothing. + // This is an explicit local deletion, which is one of the few things + // allowed to correct the context. + if storedContext.context == .account(accountID) { + setContext(.local) + } + + save() + + NotificationCenter.default.post(name: .accountsDidChange, object: nil) + _ = account + } + + /// 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() + } + + func confirmUpload(_ accountID: UUID) { + pendingUploads.remove(accountID) + save() + } + + // MARK: - Context + + /// Records an explicit user choice of context. + func setContext(_ context: DataContext) { + storedContext = StoredDataContext(context: context) + 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. + func applySyncedContext(_ incoming: StoredDataContext) { + guard incoming.changedAt > storedContext.changedAt else { return } + storedContext = incoming + save() + NotificationCenter.default.post(name: .dataContextDidChange, object: nil) + } + + // MARK: - Status + + func setStatus(_ status: AccountStatus, for accountID: UUID) { + guard accountStatuses[accountID] != status else { return } + accountStatuses[accountID] = status + NotificationCenter.default.post(name: .accountsDidChange, object: nil) + } + + func status(for accountID: UUID) -> AccountStatus { + accountStatuses[accountID] ?? .credentialMissing + } + + // 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() { + 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 } + + migratedInstanceIDs.insert(instance.id) + didChange = true + + // Deterministic: the account reuses the instance UUID, so two + // devices migrating the same instance independently produce the + // identical record and merge instead of duplicating. + guard !accounts.contains(where: { $0.id == instance.id }) else { continue } + + 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() { + 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 + 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" + } + + private func key(_ name: String) -> String { + "accounts.\(namespace).\(name)" + } + + private var hasStoredContext: Bool { + get { defaults.bool(forKey: key("hasContext")) } + set { defaults.set(newValue, forKey: key("hasContext")) } + } + + private func load() { + 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:))) + } + } + + private func save() { + 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")) + hasStoredContext = true + } + defaults.set(migratedInstanceIDs.map(\.uuidString), forKey: key("migrated")) + defaults.set(pendingUploads.map(\.uuidString), forKey: key("pendingUploads")) + defaults.set(pendingDeletions.map(\.uuidString), forKey: key("pendingDeletions")) + } + + /// 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 = [:] + storedContext = StoredDataContext(context: .local) + migratedInstanceIDs = [] + pendingUploads = [] + pendingDeletions = [] + + load() + reconcileLegacyAccounts() + + NotificationCenter.default.post(name: .accountsDidChange, object: nil) + } +} + +extension Notification.Name { + static let accountsDidChange = Notification.Name("stream.yattee.accountsDidChange") + static let dataContextDidChange = Notification.Name("stream.yattee.dataContextDidChange") +} diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 60b1f2e0..caf8eba6 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -51,10 +51,12 @@ final class AppEnvironment { /// Shared by both account credential managers so a single CloudKit lookup /// resolves the scope for all of them. let credentialIdentityScope: CredentialIdentityScope + let accountsManager: AccountsManager let homeInstanceCache: HomeInstanceCache let invidiousAPI: InvidiousAPI let pipedAPI: PipedAPI let subscriptionAccountValidator: SubscriptionAccountValidator + let dataContextValidator: DataContextValidator let playerControlsLayoutService: PlayerControlsLayoutService let legacyMigrationService: LegacyDataMigrationService let sourcesSettings: SourcesSettings @@ -150,6 +152,11 @@ final class AppEnvironment { pipedCreds.settingsManager = settings self.pipedCredentialsManager = pipedCreds + // Accounts share the credential identity namespace: the account list and + // the credentials it points at must always describe the same Apple account + let accounts = AccountsManager(identityScope: identityScope, instancesManager: instances) + self.accountsManager = accounts + // Initialize Invidious API (used by SubscriptionService and SubscriptionFeedCache) let invidiousAPI = InvidiousAPI(httpClient: client) self.invidiousAPI = invidiousAPI @@ -302,6 +309,14 @@ final class AppEnvironment { feedCache: .shared ) + // Validates the account-based data context. Unlike the validator above + // it never corrects a selection it merely cannot verify yet + self.dataContextValidator = DataContextValidator( + accountsManager: accounts, + instancesManager: instances, + toastManager: toast + ) + // Initialize Player Controls Layout Service let layoutService = PlayerControlsLayoutService() self.playerControlsLayoutService = layoutService @@ -347,10 +362,29 @@ final class AppEnvironment { // existed, so existing setups publish them to iCloud without a re-login backfillCredentialFlags() + dataContextValidator.setAppEnvironment(self) + + // Turn any pre-revamp single-instance logins into accounts. Repeatable: + // it runs again whenever instances arrive from iCloud. + accounts.reconcileLegacyAccounts() + accounts.migrateLegacyContextIfNeeded() + refreshAccountStatuses() + // Resolve the credential identity scope if this Apple account has never // been seen on this device. Until it lands, account credentials report // "can't tell yet" rather than "signed out". - Task { await identityScope.resolve() } + Task { + let scopeBefore = identityScope.current + await identityScope.resolve() + + // Accounts are stored per identity, so a scope that only just + // resolved (or moved) means the in-memory list is the wrong + // namespace's. + if identityScope.current != scopeBefore { + accounts.reloadForCurrentIdentity() + } + self.refreshAccountStatuses() + } // Log device capabilities on startup for debugging HardwareCapabilities.shared.logCapabilities() @@ -425,6 +459,41 @@ final class AppEnvironment { return false } + // MARK: - Accounts + + /// The account-keyed credentials manager for an account's instance. + /// `nil` when the instance is unknown or its type has no account concept. + func accountCredentialsManager(for account: Account) -> (any AccountCredentialsManager)? { + guard let instance = accountsManager.instance(for: account) else { return nil } + switch instance.type { + case .invidious: return invidiousCredentialsManager + case .piped: return pipedCredentialsManager + default: return nil + } + } + + /// Recomputes each account's status from the Keychain. + /// + /// Only ever reports `credentialMissing` versus `signedIn` — a rejected + /// credential is something only the server can tell us, so that state is set + /// by the API layer when a request actually fails. + func refreshAccountStatuses() { + for account in accountsManager.accounts { + guard let credentials = accountCredentialsManager(for: account) else { continue } + + // An unresolved identity scope means "cannot tell yet", not + // "signed out" — leave any known status alone rather than + // downgrading a working account to sign-in-required. + guard credentials.isScopeResolved else { continue } + + credentials.refreshLoginStatus(for: account) + accountsManager.setStatus( + credentials.isLoggedIn(for: account) ? .signedIn : .credentialMissing, + for: account.id + ) + } + } + // MARK: - Credentials Management /// Returns the appropriate credentials manager for an instance type. diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 6094edfe..f6c51c0a 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -142,6 +142,106 @@ }, "1.2M subscribers" : { + }, + "accounts.addAccount" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Account" + } + } + } + }, + "accounts.signInOnThisDevice" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign In on This Device" + } + } + } + }, + "accounts.status.credentialMissing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in required" + } + } + } + }, + "accounts.status.credentialRejected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in again" + } + } + } + }, + "accounts.status.instanceMissing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source missing" + } + } + } + }, + "accounts.status.signedIn" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signed in" + } + } + } + }, + "accounts.status.unreachable" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unreachable" + } + } + } + }, + "accounts.useSavedLogin" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use Saved Login From This Device" + } + } + } + }, + "accounts.useSavedLogin.failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not use the saved login. Sign in again." + } + } + } + }, + "accounts.useSavedLogin.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A login saved by an earlier version of Yattee is stored on this device. It cannot be confirmed to belong to the Apple Account currently signed in, so it is only used if you choose to." + } + } + } }, "alert.ambiguousLink.message %@" : { "localizations" : { @@ -5330,6 +5430,26 @@ } } }, + "login.signInAgain" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign In Again" + } + } + } + }, + "login.signOutAndRemove" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign Out & Remove" + } + } + } + }, "login.title" : { "comment" : "Title of login screen", "localizations" : { diff --git a/Yattee/Models/AccountCapabilities.swift b/Yattee/Models/AccountCapabilities.swift new file mode 100644 index 00000000..5c64e75e --- /dev/null +++ b/Yattee/Models/AccountCapabilities.swift @@ -0,0 +1,87 @@ +// +// AccountCapabilities.swift +// Yattee +// +// What a given backend lets a signed-in account do. +// + +import Foundation + +/// What server-side features an account supports. +/// +/// Call sites ask for capabilities rather than switching on `instance.type`, so +/// adding a backend — the planned per-user Yattee Server accounts, say — means +/// adding one row in ``InstanceType/accountCapabilities`` instead of hunting +/// down every `case .invidious` in the app. +struct AccountCapabilities: Hashable, Sendable { + var supportsSubscriptions: Bool + var supportsFeed: Bool + var supportsPlaylistReads: Bool + var supportsPlaylistWrites: Bool + var supportsWatchHistory: Bool + /// Whether playlists carry a visibility setting (public/unlisted/private). + var supportsPlaylistPrivacy: Bool + + static let none = AccountCapabilities( + supportsSubscriptions: false, + supportsFeed: false, + supportsPlaylistReads: false, + supportsPlaylistWrites: false, + supportsWatchHistory: false, + supportsPlaylistPrivacy: false + ) + + /// Whether this account can act as a `DataContext` at all. An account with + /// nothing to offer is never listed as a switch target. + var isSelectableContext: Bool { + supportsSubscriptions || supportsPlaylistReads + } +} + +extension InstanceType { + /// **The** capability switch. Verified against each backend's API surface. + /// + /// Adding a case here is the whole job of supporting a new account type; + /// nothing else in the app is allowed to decide what an account can do. + /// (Routing to the right API client, picking a credential manager and + /// choosing a presentation still switch on `type` legitimately — those are + /// about *how* to talk to a server, not what it supports.) + var accountCapabilities: AccountCapabilities { + switch self { + case .invidious: + // Full CRUD with privacy; removal by a stable per-entry indexId + // token; history is a boolean watched marker. + AccountCapabilities( + supportsSubscriptions: true, + supportsFeed: true, + supportsPlaylistReads: true, + supportsPlaylistWrites: true, + supportsWatchHistory: true, + supportsPlaylistPrivacy: true + ) + + case .piped: + // Full CRUD but no privacy setting, removal is positional, and + // there is no history endpoint at all. + AccountCapabilities( + supportsSubscriptions: true, + supportsFeed: true, + supportsPlaylistReads: true, + supportsPlaylistWrites: true, + supportsWatchHistory: false, + supportsPlaylistPrivacy: false + ) + + case .peertube: + // Yattee has no PeerTube account concept — instances are browsed + // anonymously. + .none + + case .yatteeServer: + // Stateless today: the client posts its own subscription list and + // gets a merged feed back, with HTTP Basic as the only auth. Per-user + // accounts are planned server-side; this row is where they land. + .none + } + } +} diff --git a/Yattee/Models/DataContext.swift b/Yattee/Models/DataContext.swift new file mode 100644 index 00000000..a6c83214 --- /dev/null +++ b/Yattee/Models/DataContext.swift @@ -0,0 +1,76 @@ +// +// DataContext.swift +// Yattee +// +// Which data set the Subscriptions, Feed and Playlists views are showing. +// + +import Foundation + +/// The globally selected source of user data. +/// +/// Replaces `SubscriptionAccount`, which could only ever describe *subscriptions* +/// and identified an account by the instance it lived on — a model that cannot +/// express two logins on the same instance. +enum DataContext: Codable, Hashable, Sendable { + /// The device's own data, synced through iCloud. + case local + /// A specific `Account.id`. + case account(UUID) + + var accountID: UUID? { + if case let .account(id) = self { return id } + return nil + } + + var isLocal: Bool { self == .local } +} + +/// A context together with the moment it was chosen. +/// +/// The timestamp is app-managed and is the only ordering used when two devices +/// pick different contexts — CloudKit's own modification dates are never +/// consulted, because they record when a *record* was written rather than when +/// the user made a choice. +struct StoredDataContext: Codable, Hashable, Sendable { + var context: DataContext + var changedAt: Date + + init(context: DataContext, changedAt: Date = Date()) { + self.context = context + self.changedAt = changedAt + } + + /// A context produced by migrating the legacy `subscriptionAccount` value. + /// + /// Stamped at the epoch so that any explicit choice the user has made on any + /// device outranks it regardless of which arrives first. A migration is a + /// guess at intent; an explicit choice is intent. + static func migrated(_ context: DataContext) -> StoredDataContext { + StoredDataContext(context: context, changedAt: Date(timeIntervalSince1970: 0)) + } + + var isMigrated: Bool { changedAt == Date(timeIntervalSince1970: 0) } +} + +// MARK: - Legacy migration + +extension DataContext { + /// Maps the pre-revamp `SubscriptionAccount` onto a context. + /// + /// The instance UUID becomes the account UUID directly: migrated accounts + /// deliberately reuse their instance's UUID (see `Account.migrated(from:)`), + /// so this is an identity mapping rather than a lookup that could fail. + init(legacy: SubscriptionAccount) { + switch legacy.type { + case .local: + self = .local + case .invidious, .piped: + if let instanceID = legacy.instanceID { + self = .account(instanceID) + } else { + self = .local + } + } + } +} diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift new file mode 100644 index 00000000..b7f776d8 --- /dev/null +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -0,0 +1,208 @@ +// +// DataContextValidator.swift +// Yattee +// +// Decides whether the selected data context can be used right now. +// + +import Foundation + +/// Why the selected context cannot be used at the moment. +enum DataContextUnavailability: Hashable, Sendable { + /// The account exists but its instance hasn't arrived from iCloud yet. + case instancePending + /// No credential on this device yet. On tvOS this never resolves on its own + /// — app Keychain items are not synced there — so the UI offers a local + /// sign-in rather than implying a wait. + case credentialMissing + /// The server rejected the credential; the user must sign in again. + case credentialRejected + /// The server could not be reached. + case unreachable +} + +/// The three states a context can be in. +/// +/// The distinction that matters is between *unknown* and *gone*. Anything that +/// might still resolve — a slow iCloud Keychain, an instance that hasn't synced +/// — is `temporarilyUnavailable` and leaves the user's choice untouched. Only +/// a deletion the app can point at turns into `removed`. +enum DataContextValidity: Hashable, Sendable { + case valid + case temporarilyUnavailable(DataContextUnavailability) + case removed + + var isValid: Bool { self == .valid } +} + +/// Validates the selected `DataContext` and supplies the list of switchable +/// contexts. +/// +/// Replaces `SubscriptionAccountValidator`, which auto-corrected on any +/// selection it could not immediately verify. That was safe when a context +/// could only be "an instance I am logged into", but with credentials arriving +/// asynchronously it would silently discard the user's choice during a slow +/// Keychain sync — so correction is now restricted to deletions. +@MainActor +@Observable +final class DataContextValidator { + private let accountsManager: AccountsManager + private let instancesManager: InstancesManager + private let toastManager: ToastManager + private weak var appEnvironment: AppEnvironment? + + init( + accountsManager: AccountsManager, + instancesManager: InstancesManager, + toastManager: ToastManager + ) { + self.accountsManager = accountsManager + self.instancesManager = instancesManager + self.toastManager = toastManager + } + + func setAppEnvironment(_ environment: AppEnvironment) { + appEnvironment = environment + } + + // MARK: - Validity + + var validity: DataContextValidity { + validity(of: accountsManager.currentContext) + } + + func validity(of context: DataContext) -> DataContextValidity { + switch context { + case .local: + // Local data is always present. The feed additionally needs a + // Yattee Server, but that is a feed-level concern, not a reason to + // reject the context. + return .valid + + case let .account(id): + guard let account = accountsManager.account(id: id) else { + // Absence is not proof. An account can be missing because it + // hasn't synced yet; only an actual deletion removes it, and + // that path corrects the context directly. + return .temporarilyUnavailable(.instancePending) + } + + guard accountsManager.instance(for: account) != nil else { + return .temporarilyUnavailable(.instancePending) + } + + switch accountsManager.status(for: id) { + case .signedIn: return .valid + case .credentialMissing: return .temporarilyUnavailable(.credentialMissing) + case .credentialRejected: return .temporarilyUnavailable(.credentialRejected) + case .unreachable: return .temporarilyUnavailable(.unreachable) + } + } + } + + /// The context reads should actually use. + /// + /// Falls back to `.local` while the selected context is unusable, but the + /// fallback is **never persisted** — the moment the credential or instance + /// arrives, the user's choice takes effect again without them having to + /// reselect it. + var effectiveContext: DataContext { + validity.isValid ? accountsManager.currentContext : .local + } + + /// Whether writes may be sent to the current context's server. + /// + /// Deliberately not the same as having a read fallback: a subscribe or a + /// playlist edit must fail visibly rather than being redirected into local + /// data the user did not choose. + var allowsServerMutations: Bool { + validity.isValid + } + + /// Set while the selected context is unusable, for the banner. + var unavailability: DataContextUnavailability? { + if case let .temporarilyUnavailable(reason) = validity { return reason } + return nil + } + + // MARK: - Available contexts + + /// Every context the user can switch to. + /// + /// Includes accounts that are currently signed out: hiding them would make + /// a temporarily unreachable account look deleted, and the switcher is + /// where the user goes to fix exactly that. + var availableContexts: [DataContext] { + let accountContexts = accountsManager.selectableAccounts + .filter { accountsManager.instance(for: $0)?.isEnabled ?? false } + .map { DataContext.account($0.id) } + + return [.local] + accountContexts + } + + // MARK: - Correction + + /// Corrects the context only when the selected account is provably gone. + /// + /// Called after deletions. A timeout, a failed fetch or a missing record + /// must never reach here — those are all "unknown", and treating them as + /// removal is how a user loses their selection during a slow sync. + func correctIfContextRemoved() { + guard case let .account(id) = accountsManager.currentContext else { return } + guard accountsManager.account(id: id) == nil else { return } + + // Only an explicit deletion clears the account from the store, so + // reaching here means the account really is gone. + accountsManager.setContext(.local) + + toastManager.showInfo( + String(localized: "subscriptions.accountSwitched.title"), + subtitle: String(localized: "subscriptions.account.local") + ) + + LoggingService.shared.info( + "Selected account was deleted — data context reset to local", + category: .general + ) + } + + // MARK: - Display + + func displayName(for context: DataContext) -> String { + switch context { + case .local: + return String(localized: "subscriptions.account.local") + + case let .account(id): + guard let account = accountsManager.account(id: id) else { + return String(localized: "subscriptions.account.local") + } + return displayName(for: account) + } + } + + /// User-set label, else the stored login identifier, else the host. + /// + /// The username lives in the Keychain beside the credential rather than in + /// synced metadata, so it is only available on a device that is signed in. + func displayName(for account: Account) -> String { + if let name = account.displayName, !name.isEmpty { return name } + + if let username = appEnvironment? + .accountCredentialsManager(for: account)? + .username(for: account), + !username.isEmpty { + if let host = accountsManager.instance(for: account)?.url.host { + return "\(username)@\(host)" + } + return username + } + + if let instance = accountsManager.instance(for: account) { + if let name = instance.name, !name.isEmpty { return name } + return instance.url.host ?? instance.type.displayName + } + + return String(localized: "accounts.status.instanceMissing") + } +} diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index b5165168..7bd4693b 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -44,8 +44,9 @@ private struct EditRemoteServerContent: View { /// "user cleared the fields" and delete the stored credentials on save. @State private var hadStoredBasicAuth: Bool = false - // Invidious login state - @State private var showLoginSheet = false + // Account login state. `loginTarget` is non-nil while the sheet is up and + // carries the account being signed into — nil inside it means "create one". + @State private var loginTarget: AccountLoginTarget? @State private var isLoggedIn = false // Yattee Server validation @@ -196,35 +197,7 @@ private struct EditRemoteServerContent: View { } if instance.supportsAuthentication { - Section { - if isLoggedIn { - Button(role: .destructive) { - logout() - } label: { - Label(String(localized: "login.logout"), systemImage: "rectangle.portrait.and.arrow.forward") - } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif - } else { - Button { - showLoginSheet = true - } label: { - Label(String(localized: "login.loginToAccount"), systemImage: "person.badge.key") - } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif - } - } header: { - Text(String(localized: "login.header.account")) - } footer: { - if isLoggedIn { - Text(String(localized: "login.footer.loggedIn")) - } else { - Text(String(localized: "login.footer.loginBenefits")) - } - } + InstanceAccountsSection(instance: instance, loginTarget: $loginTarget) // Import section - show when logged in for Invidious and Piped instances if isLoggedIn && (instance.type == .invidious || instance.type == .piped) { @@ -342,19 +315,15 @@ private struct EditRemoteServerContent: View { } .presentationCompactAdaptation(.sheet) #if os(tvOS) - .fullScreenCover(isPresented: $showLoginSheet) { - InstanceLoginView(instance: instance) { credential in - appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) - appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance) - isLoggedIn = true + .fullScreenCover(item: $loginTarget) { target in + InstanceLoginView(instance: instance) { credential, username in + storeLogin(credential: credential, username: username, target: target) } } #else - .sheet(isPresented: $showLoginSheet) { - InstanceLoginView(instance: instance) { credential in - appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) - appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance) - isLoggedIn = true + .sheet(item: $loginTarget) { target in + InstanceLoginView(instance: instance) { credential, username in + storeLogin(credential: credential, username: username, target: target) } } #endif @@ -409,10 +378,27 @@ private struct EditRemoteServerContent: View { // MARK: - Actions - private func logout() { - appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance) - appEnvironment?.instancesManager.setUsesAccountLogin(false, for: instance) - isLoggedIn = false + /// Files a successful login against an account, creating one if this was a + /// fresh sign-in. + /// + /// The identifier is stored beside the credential in the Keychain rather + /// than on the account record, so it never travels as synced metadata. + private func storeLogin(credential: String, username: String, target: AccountLoginTarget) { + guard let appEnvironment else { return } + + let account = target.account + ?? appEnvironment.accountsManager.addAccount(instanceID: instance.id) + + guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { return } + + credentials.setCredential(credential, for: account) + if !username.isEmpty { + credentials.setUsername(username, for: account) + } + + appEnvironment.accountsManager.setStatus(.signedIn, for: account.id) + appEnvironment.instancesManager.setUsesAccountLogin(true, for: instance) + isLoggedIn = true } private func loadServerInfo() async { diff --git a/Yattee/Views/Settings/InstanceAccountsSection.swift b/Yattee/Views/Settings/InstanceAccountsSection.swift new file mode 100644 index 00000000..a0ad7ead --- /dev/null +++ b/Yattee/Views/Settings/InstanceAccountsSection.swift @@ -0,0 +1,193 @@ +// +// InstanceAccountsSection.swift +// Yattee +// +// The per-instance account list shown in EditSourceView. +// + +import SwiftUI + +/// Which account a login sheet is for. `account == nil` means "create a new one". +struct AccountLoginTarget: Identifiable { + let id: UUID + let account: Account? + + static let newAccount = AccountLoginTarget(id: UUID(), account: nil) + static func existing(_ account: Account) -> AccountLoginTarget { + AccountLoginTarget(id: account.id, account: account) + } +} + +/// Lists the accounts signed in on one instance. +/// +/// Replaces the single "Log in / Log out" pair: an instance can now hold +/// several logins, each with its own credential and status. +struct InstanceAccountsSection: View { + let instance: Instance + @Binding var loginTarget: AccountLoginTarget? + + @Environment(\.appEnvironment) private var appEnvironment + + private var accounts: [Account] { + appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? [] + } + + var body: some View { + Section { + ForEach(accounts) { account in + accountRow(account) + } + + Button { + loginTarget = .newAccount + } label: { + Label( + String(localized: accounts.isEmpty ? "login.loginToAccount" : "accounts.addAccount"), + systemImage: "person.badge.plus" + ) + } + #if os(tvOS) + .buttonStyle(TVSettingsButtonStyle()) + #endif + } header: { + Text(String(localized: "login.header.account")) + } footer: { + Text(String(localized: accounts.isEmpty ? "login.footer.loginBenefits" : "login.footer.loggedIn")) + } + + adoptionSection + } + + // MARK: - Rows + + @ViewBuilder + private func accountRow(_ account: Account) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(displayName(for: account)) + .lineLimit(1) + + Text(statusText(for: account)) + .font(.caption) + .foregroundStyle(statusColor(for: account)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + Button { + loginTarget = .existing(account) + } label: { + Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key") + } + + Button(role: .destructive) { + signOut(account) + } label: { + Label(String(localized: "login.signOutAndRemove"), systemImage: "rectangle.portrait.and.arrow.forward") + } + } + + #if os(tvOS) + // tvOS never syncs app Keychain items, so an account that arrived from + // another device is permanently credential-less here until the user + // signs in on the Apple TV itself. Offer that directly rather than + // leaving a status the user cannot act on. + if status(for: account) == .credentialMissing { + Button { + loginTarget = .existing(account) + } label: { + Label(String(localized: "accounts.signInOnThisDevice"), systemImage: "person.badge.key") + } + .buttonStyle(TVSettingsButtonStyle()) + } + #endif + } + + /// The one-tap adoption of a login saved by an earlier version. + /// + /// Shown only when such an item exists and has not been adopted. It is a + /// deliberate action because a retained Keychain item carries no proof of + /// which Apple Account it belonged to — the app cannot tell an inherited + /// credential from the user's own. + @ViewBuilder + private var adoptionSection: some View { + let adoptable = accounts.filter { account in + appEnvironment? + .accountCredentialsManager(for: account)? + .hasAdoptableLegacyCredential(for: account) == true + } + + if !adoptable.isEmpty { + Section { + ForEach(adoptable) { account in + Button { + adoptLegacyCredential(for: account) + } label: { + Label(String(localized: "accounts.useSavedLogin"), systemImage: "key.horizontal") + } + #if os(tvOS) + .buttonStyle(TVSettingsButtonStyle()) + #endif + } + } footer: { + Text(String(localized: "accounts.useSavedLogin.footer")) + } + } + } + + // MARK: - Presentation + + private func displayName(for account: Account) -> String { + appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName + } + + private func status(for account: Account) -> AccountStatus { + appEnvironment?.accountsManager.status(for: account.id) ?? .credentialMissing + } + + private func statusText(for account: Account) -> String { + switch status(for: account) { + case .signedIn: String(localized: "accounts.status.signedIn") + case .credentialMissing: String(localized: "accounts.status.credentialMissing") + case .credentialRejected: String(localized: "accounts.status.credentialRejected") + case .unreachable: String(localized: "accounts.status.unreachable") + } + } + + private func statusColor(for account: Account) -> Color { + switch status(for: account) { + case .signedIn: .secondary + case .credentialMissing: .secondary + case .credentialRejected: .red + case .unreachable: .orange + } + } + + // MARK: - Actions + + private func signOut(_ account: Account) { + guard let appEnvironment else { return } + + // Deleting the account is what authorizes removing the legacy item, and + // only when this device adopted it and it still holds that same secret. + appEnvironment.accountCredentialsManager(for: account)?.deleteCredential(for: account) + appEnvironment.accountCredentialsManager(for: account)?.deleteLegacyCredentialIfOwned(for: account) + appEnvironment.accountsManager.removeAccount(account.id) + + if appEnvironment.accountsManager.accounts(forInstance: instance.id).isEmpty { + appEnvironment.instancesManager.setUsesAccountLogin(false, for: instance) + } + } + + private func adoptLegacyCredential(for account: Account) { + guard let appEnvironment, + let credentials = appEnvironment.accountCredentialsManager(for: account) + else { return } + + if credentials.adoptLegacyCredential(for: account) { + appEnvironment.accountsManager.setStatus(.signedIn, for: account.id) + } else { + appEnvironment.toastManager.showError( + String(localized: "accounts.useSavedLogin.failed") + ) + } + } +} diff --git a/Yattee/Views/Settings/InstanceLoginView.swift b/Yattee/Views/Settings/InstanceLoginView.swift index 6362742c..ddd8494a 100644 --- a/Yattee/Views/Settings/InstanceLoginView.swift +++ b/Yattee/Views/Settings/InstanceLoginView.swift @@ -9,7 +9,13 @@ import SwiftUI struct InstanceLoginView: View { let instance: Instance - let onLoginSuccess: (String) -> Void + /// Receives the credential **and** the identifier the user signed in with. + /// + /// The identifier is handed back rather than stored here because it belongs + /// beside the credential in the Keychain, not in synced account metadata — + /// so only the caller, which knows which account this login is for, can + /// file it correctly. + let onLoginSuccess: (_ credential: String, _ username: String) -> Void @Environment(\.dismiss) private var dismiss @Environment(\.appEnvironment) private var appEnvironment @@ -147,7 +153,7 @@ struct InstanceLoginView: View { let credential = try await performLogin(appEnvironment: appEnvironment) await MainActor.run { - onLoginSuccess(credential) + onLoginSuccess(credential, username) dismiss() } } catch APIError.unauthorized { @@ -207,13 +213,13 @@ struct InstanceLoginView: View { #Preview("Invidious") { InstanceLoginView( instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!) - ) { _ in } + ) { _, _ in } .appEnvironment(.preview) } #Preview("Piped") { InstanceLoginView( instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!) - ) { _ in } + ) { _, _ in } .appEnvironment(.preview) } diff --git a/YatteeTests/AccountsManagerTests.swift b/YatteeTests/AccountsManagerTests.swift new file mode 100644 index 00000000..4765a8c2 --- /dev/null +++ b/YatteeTests/AccountsManagerTests.swift @@ -0,0 +1,181 @@ +// +// AccountsManagerTests.swift +// YatteeTests +// +// Account storage, legacy migration and data-context validation. +// + +import Testing +import Foundation +@testable import Yattee + +@Suite("AccountsManager") +@MainActor +struct AccountsManagerTests { + private func makeDefaults() -> UserDefaults { + UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")! + } + + private func makeManager( + defaults: UserDefaults? = nil, + scope: String = "identity-A", + instances: InstancesManager? = nil + ) -> AccountsManager { + AccountsManager( + identityScope: CredentialIdentityScope(fixedScope: scope), + instancesManager: instances, + defaults: defaults ?? makeDefaults() + ) + } + + @Test("Accounts survive a relaunch") + func accountsPersist() { + let defaults = makeDefaults() + let instanceID = UUID() + + let first = makeManager(defaults: defaults) + let account = first.addAccount(instanceID: instanceID, displayName: "Main") + + let second = makeManager(defaults: defaults) + #expect(second.account(id: account.id)?.displayName == "Main") + } + + @Test("Accounts are namespaced by Apple account") + func accountsAreNamespacedByIdentity() { + let defaults = makeDefaults() + let instanceID = UUID() + + let identityA = makeManager(defaults: defaults, scope: "identity-A") + let account = identityA.addAccount(instanceID: instanceID) + + // The same storage, a different Apple account: A's list must not show. + let identityB = makeManager(defaults: defaults, scope: "identity-B") + #expect(identityB.accounts.isEmpty) + #expect(identityB.account(id: account.id) == nil) + + let identityAAgain = makeManager(defaults: defaults, scope: "identity-A") + #expect(identityAAgain.accounts.count == 1) + } + + @Test("Adding an account queues it for upload") + func addQueuesUpload() { + let manager = makeManager() + let account = manager.addAccount(instanceID: UUID()) + + #expect(manager.pendingUploads.contains(account.id)) + + manager.confirmUpload(account.id) + #expect(!manager.pendingUploads.contains(account.id)) + } + + @Test("Deleting an account queues a deletion that survives a relaunch") + func deletionOutboxIsDurable() { + let defaults = makeDefaults() + let manager = makeManager(defaults: defaults) + let account = manager.addAccount(instanceID: UUID()) + + manager.removeAccount(account.id) + #expect(manager.pendingDeletions.contains(account.id)) + + // A deletion that never reached the server must be retried, or a stale + // device can resurrect the account. + let relaunched = makeManager(defaults: defaults) + #expect(relaunched.pendingDeletions.contains(account.id)) + #expect(relaunched.account(id: account.id) == nil) + + relaunched.confirmDeletion(account.id) + #expect(!relaunched.pendingDeletions.contains(account.id)) + } + + @Test("Deleting the selected account resets the context") + func deletingSelectedAccountResetsContext() { + let manager = makeManager() + let account = manager.addAccount(instanceID: UUID()) + + manager.setContext(.account(account.id)) + #expect(manager.currentContext == .account(account.id)) + + manager.removeAccount(account.id) + #expect(manager.currentContext == .local) + } + + @Test("A newer synced context wins, an older one is ignored") + func syncedContextResolvesByTimestamp() { + let manager = makeManager() + let accountID = UUID() + + manager.setContext(.local) + let localChangedAt = manager.storedContext.changedAt + + // Older than the local choice — must not take effect. + manager.applySyncedContext( + StoredDataContext(context: .account(accountID), changedAt: localChangedAt.addingTimeInterval(-60)) + ) + #expect(manager.currentContext == .local) + + // Newer — wins. + manager.applySyncedContext( + StoredDataContext(context: .account(accountID), changedAt: localChangedAt.addingTimeInterval(60)) + ) + #expect(manager.currentContext == .account(accountID)) + } + + @Test("A migrated context loses to any explicit choice") + func migratedContextLosesToExplicitChoice() { + let manager = makeManager() + let accountID = UUID() + + // A migration is a guess at intent, stamped at the epoch so an explicit + // choice on any device outranks it regardless of arrival order. + let migrated = StoredDataContext.migrated(.account(accountID)) + #expect(migrated.isMigrated) + + manager.setContext(.local) + manager.applySyncedContext(migrated) + #expect(manager.currentContext == .local) + } +} + +@Suite("Account capabilities") +@MainActor +struct AccountCapabilitiesTests { + @Test("Backend capabilities match the documented API surface") + func capabilitiesPerBackend() { + let invidious = InstanceType.invidious.accountCapabilities + #expect(invidious.supportsWatchHistory) + #expect(invidious.supportsPlaylistPrivacy) + #expect(invidious.supportsPlaylistWrites) + + // Piped has no history endpoint at all and no playlist privacy. + let piped = InstanceType.piped.accountCapabilities + #expect(piped.supportsPlaylistWrites) + #expect(!piped.supportsWatchHistory) + #expect(!piped.supportsPlaylistPrivacy) + + // No account concept on either of these today. + #expect(!InstanceType.peertube.accountCapabilities.isSelectableContext) + #expect(!InstanceType.yatteeServer.accountCapabilities.isSelectableContext) + } +} + +@Suite("DataContext legacy migration") +@MainActor +struct DataContextMigrationTests { + @Test("Legacy subscription accounts map onto contexts") + func legacyMappingIsIdentity() { + let instanceID = UUID() + + #expect(DataContext(legacy: .local) == .local) + + // Migrated accounts reuse their instance UUID, so this is an identity + // mapping rather than a lookup that could fail. + #expect(DataContext(legacy: .invidious(instanceID)) == .account(instanceID)) + #expect(DataContext(legacy: .piped(instanceID)) == .account(instanceID)) + } + + @Test("A legacy account without an instance falls back to local") + func legacyWithoutInstanceIsLocal() { + let malformed = SubscriptionAccount(type: .invidious, instanceID: nil) + #expect(DataContext(legacy: malformed) == .local) + } +}