// // 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 { /// Nothing is known yet: the Apple account the credentials are keyed by is /// still being established, so the Keychain cannot be read under the right /// scope. Never render this as "sign in required". case checking /// The account exists but its instance hasn't arrived from iCloud yet. case instancePending /// The account's instance has been switched off. The selection is kept — /// the user can turn the instance back on — but nothing may be served from /// or written to a server they have disabled. case instanceDisabled /// The user set the account itself to inactive. Same shape as a disabled /// instance: the selection is kept, the credential is untouched, and /// nothing may be served from or written to an account they switched off. case accountInactive /// 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 let instance = accountsManager.instance(for: account) else { return .temporarilyUnavailable(.instancePending) } // The account's own status says nothing about the instance being // switched off, and the switcher only *lists* a disabled source's // contexts — it refuses to switch to them — so a context that // stayed valid here would keep sending requests to a server the // user has turned off. guard instance.isEnabled else { return .temporarilyUnavailable(.instanceDisabled) } // Like the disabled source above: not a credential state, and the // selection is kept so activating the account restores it. guard account.isActive else { return .temporarilyUnavailable(.accountInactive) } switch accountsManager.status(for: id) { case .signedIn: return .valid case .checking: return .temporarilyUnavailable(.checking) case .credentialMissing: return .temporarilyUnavailable(.credentialMissing) case .credentialRejected: return .temporarilyUnavailable(.credentialRejected) case .instanceDisabled: return .temporarilyUnavailable(.instanceDisabled) case .inactive: return .temporarilyUnavailable(.accountInactive) 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 switcher *lists*, usable or not. /// /// A source the user switched off does not make its logins disappear. The /// switcher listing only what can be switched to meant the badge on the /// toolbar icon pointed at a menu the account had dropped out of — the /// selected account included, so the one control that explains the badge /// stopped naming the thing it was badging for. The rows stay, carrying /// their status, and `isSwitchable(_:)` decides which of them can be picked. /// /// The selection is the row that may never be missing, so it is appended /// even when it is no longer among the selectable accounts — as long as the /// account still exists. A context whose account is gone is not listed: /// there is nothing to name it with, and correction handles deletions. var listedContexts: [DataContext] { var contexts: [DataContext] = [.local] contexts += accountsManager.selectableAccounts.map { DataContext.account($0.id) } // An inactive account is the exception to "the selection is always // listed": the user set it aside, and deactivating the selected // account switches to local anyway — this covers the window where // another device's deactivation has arrived ahead of its context // switch. let current = accountsManager.currentContext if case let .account(id) = current, let account = accountsManager.account(id: id), account.isActive, !contexts.contains(current) { contexts.append(current) } return 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. A disabled source is the one /// exception — nothing may be read from or written to a server the user has /// turned off — and it is expressed by refusing the switch, not by hiding /// the row. var availableContexts: [DataContext] { listedContexts.filter { isSwitchable($0) } } /// Whether picking this context would land the user somewhere usable. /// /// The line runs between states that heal on their own and states only the /// user can end. An unreachable account may be back in a minute — and /// while the device itself is offline *every* account is unreachable, so /// refusing those would lock the whole switcher — and a context still /// resolving is refused nothing it may turn out to deserve. But a rejected /// or missing credential and a switched-off source stay that way until the /// user acts, in Settings, not here: picking one only lands on the local /// fallback with a banner, so the row is listed — status in its title — /// and refused. func isSwitchable(_ context: DataContext) -> Bool { switch context { case .local: return true case .account(let id): guard accountsManager.selectableAccounts.contains(where: { $0.id == id }) else { return false } switch validity(of: context) { case .valid: return true case .removed: return false case let .temporarilyUnavailable(reason): switch reason { case .instanceDisabled, .accountInactive, .credentialRejected, .credentialMissing: return false case .checking, .instancePending, .unreachable: return true } } } } // MARK: - Correction /// Corrects the context only when the selected account is provably gone. /// /// Called after deletions and on foreground activation. A timeout, a failed /// fetch or a record that simply has not arrived must never move anything — /// those are all "unknown", and treating them as removal is how a user /// loses their selection during a slow sync. Only the deletion tombstone /// counts, and the repair it triggers is never published. func correctIfContextRemoved() { // An unresolved identity has not loaded anybody's accounts yet, so // neither the selection nor the tombstones read here are the ones this // device will end up with. guard accountsManager.isIdentityResolved else { return } // The manager owns the decision: it holds the tombstones, and it // repairs the same way an account deleted right now would be — onto a // signed-in login on the same server when there is one, keeping the // replaced choice's timestamp and publishing nothing. guard accountsManager.correctContextIfAccountDeleted() else { return } toastManager.showInfo( String(localized: "subscriptions.accountSwitched.title"), subtitle: displayName(for: accountsManager.currentContext) ) LoggingService.shared.info( "Selected account was deleted — data context repaired", category: .general ) } // MARK: - Display func displayName(for context: DataContext) -> String { switch context { case .local: return localContextName case let .account(id): guard let account = accountsManager.account(id: id) else { return localContextName } return displayName(for: account) } } /// "(iCloud)" only when the data actually syncs; otherwise it lives on /// this device alone and the label says so. var localContextName: String { if appEnvironment?.settingsManager.iCloudSyncEnabled == true { return String(localized: "subscriptions.account.local") } return String(localized: "subscriptions.account.local.device") } /// User-set label, else whatever the account falls back to. func displayName(for account: Account) -> String { if let name = account.displayName, !name.isEmpty { return name } return defaultDisplayName(for: account) } /// What the account is called with no user-set label: the stored login /// identifier, else the source's name, else its host. /// /// After the `@` comes the source's *title* when it has one — the host is /// the fallback, not the name: `arek@nerdvpn.de` reads, while /// `arek@invidious.nerdvpn.de` overflows every menu row it appears in. /// /// Device-local by nature. 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 — two devices holding the very same account can /// legitimately fall back to different names for it. func defaultDisplayName(for account: Account) -> String { if let username = appEnvironment? .accountCredentialsManager(for: account)? .username(for: account), !username.isEmpty { if let label = accountsManager.instance(for: account)?.shortLabel { return "\(username)@\(label)" } return username } if let instance = accountsManager.instance(for: account) { return instance.shortLabel ?? instance.type.displayName } return String(localized: "accounts.status.instanceMissing") } /// What a rename field's contents mean once the user is done typing. /// /// `nil` clears the user-set label and hands the account back to /// `defaultDisplayName`; anything else is stored as typed. /// /// The question the field cannot answer on its own is what an *untouched* /// save means. The field starts prefilled with the label the row shows, and /// on an account with no user-set label that label is device-local — the /// login identifier only exists where the credential does. Saving it /// unedited must not turn one device's fallback into a synced label every /// other device is then made to display, so that case clears instead. /// /// The test is editedness, never equality with the default: on a second /// device the string that device happens to fall back to is a perfectly /// ordinary name to *choose*, and typing it has to store and sync like any /// other. An unedited save on an account that already carries a label /// re-states that label, which the manager then drops as a no-op — the name /// stays, rather than being silently cleared by a tap that changed nothing. static func renamedDisplayName( _ input: String, prefilledWith prefill: String, hasUserSetLabel: Bool ) -> String? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let wasEdited = trimmed != prefill.trimmingCharacters(in: .whitespacesAndNewlines) guard wasEdited || hasUserSetLabel else { return nil } return trimmed } }