diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 2463942f..5e53eace 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -68,6 +68,7 @@ final class AppEnvironment { private(set) var activeControlsCenterSettings: CenterSectionSettings = .default @ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = [] + @ObservationIgnored private var contextMirrorObserver: NSObjectProtocol? // MARK: - Shared Instance @@ -379,6 +380,19 @@ final class AppEnvironment { subscriptionService.setContextValidator(dataContextValidator) subscriptionAccountValidator.setAccountsManager(accounts) + // The context can now change without anyone touching this device — a + // choice made on another one, or an account deleted there. The legacy + // setting several views still read has to follow it, and the feed cache + // is global rather than account-keyed, so it would otherwise keep + // serving the previous account's videos. + contextMirrorObserver = NotificationCenter.default.addObserver( + forName: .dataContextDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.mirrorContextToLegacySelection() } + } + // A deletion that arrives from another device takes the credential with // it. Only a deletion does this — an account merely missing from a // fetch keeps everything, because a fetch is not an enumeration. @@ -503,6 +517,66 @@ final class AppEnvironment { // MARK: - Accounts + /// Keeps the pre-revamp `subscriptionAccount` selection, and the feed cache + /// keyed to it, in step with the current data context. + /// + /// The two stores are written together everywhere the user changes the + /// context by hand; this covers the changes nobody on this device made — a + /// selection synced from another device, or the selected account being + /// deleted there. Phase 3's switcher removes the last readers of the legacy + /// setting and this mirror with them. + private func mirrorContextToLegacySelection() { + let legacy: SubscriptionAccount + + switch accountsManager.currentContext { + case .local: + legacy = .local + + case let .account(id): + // An account whose instance has not arrived yet cannot be mapped + // onto the legacy shape. Leaving the old value alone is right: the + // context is preserved, and the validator reports it unavailable + // rather than pointing the UI at something else. + guard let account = accountsManager.account(id: id), + let instance = accountsManager.instance(for: account) else { return } + + switch instance.type { + case .invidious: legacy = .invidious(instance.id) + case .piped: legacy = .piped(instance.id) + default: return + } + } + + guard settingsManager.subscriptionAccount != legacy else { return } + + settingsManager.subscriptionAccount = legacy + SubscriptionFeedCache.shared.handleAccountChange() + + LoggingService.shared.info( + "Data context changed — cleared the feed cache and updated the legacy selection", + category: .general + ) + } + + /// Deletes an instance together with the accounts that live on it. + /// + /// The cascade has to happen here, while the instance is still resolvable: + /// once it is gone, an account's credential manager cannot be determined + /// from its type, and the credential would be stranded with no way to reach + /// it. Only an explicit deletion cascades — an instance disappearing from + /// iCloud leaves its accounts orphaned rather than deleted, because absence + /// is not a deletion. + func removeInstance(_ instance: Instance) { + for account in accountsManager.accounts(forInstance: instance.id) { + let credentials = accountCredentialsManager(for: account) + credentials?.deleteCredential(for: account) + credentials?.deleteLegacyCredentialIfOwned(for: account) + accountsManager.removeAccount(account.id) + } + + instancesManager.remove(instance) + } + /// 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)? { diff --git a/Yattee/Core/InstancesManager.swift b/Yattee/Core/InstancesManager.swift index 1d26607a..b0d4d418 100644 --- a/Yattee/Core/InstancesManager.swift +++ b/Yattee/Core/InstancesManager.swift @@ -95,6 +95,12 @@ final class InstancesManager { // Update sync time settingsManager?.updateLastSyncTime() + + // Instances arriving from iCloud are as much a change as a local edit. + // Without this, a legacy authenticated instance delivered here never + // reaches account migration — the account, and the action to adopt its + // saved login, would appear only after an unrelated edit or a relaunch. + NotificationCenter.default.post(name: .instancesDidChange, object: nil) } /// Syncs local data to iCloud (called when enabling iCloud sync). @@ -126,6 +132,8 @@ final class InstancesManager { instances = iCloudInstances localDefaults.set(iCloudData, forKey: instancesKey) settingsManager?.updateLastSyncTime() + + NotificationCenter.default.post(name: .instancesDidChange, object: nil) } // MARK: - Public Methods diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift index b7f776d8..001f3b37 100644 --- a/Yattee/Services/Subscriptions/DataContextValidator.swift +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -11,6 +11,10 @@ import Foundation enum DataContextUnavailability: Hashable, Sendable { /// 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 /// 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. @@ -87,10 +91,18 @@ final class DataContextValidator { return .temporarilyUnavailable(.instancePending) } - guard accountsManager.instance(for: account) != nil else { + 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 `availableContexts` already hides disabled + // instances — 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) + } + switch accountsManager.status(for: id) { case .signedIn: return .valid case .credentialMissing: return .temporarilyUnavailable(.credentialMissing) diff --git a/Yattee/Views/MediaBrowser/MediaSourcesView.swift b/Yattee/Views/MediaBrowser/MediaSourcesView.swift index e7de6ead..ac0facbb 100644 --- a/Yattee/Views/MediaBrowser/MediaSourcesView.swift +++ b/Yattee/Views/MediaBrowser/MediaSourcesView.swift @@ -165,7 +165,7 @@ struct MediaSourcesView: View { private func confirmDelete() { if let instance = pendingDeleteInstance { - instancesManager?.remove(instance) + appEnvironment?.removeInstance(instance) pendingDeleteInstance = nil } if let source = pendingDeleteSource { diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 9795a298..419d1a02 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -308,7 +308,7 @@ private struct EditRemoteServerContent: View { titleVisibility: .visible ) { Button(String(localized: "common.delete"), role: .destructive) { - appEnvironment?.instancesManager.remove(instance) + appEnvironment?.removeInstance(instance) dismiss() } Button(String(localized: "common.cancel"), role: .cancel) {} diff --git a/YatteeTests/AccountsManagerTests.swift b/YatteeTests/AccountsManagerTests.swift index 675f7e9b..9c056aed 100644 --- a/YatteeTests/AccountsManagerTests.swift +++ b/YatteeTests/AccountsManagerTests.swift @@ -202,6 +202,54 @@ struct AccountsManagerTests { } } +@Suite("DataContext validation", .serialized) +@MainActor +struct DataContextValidatorTests { + private func makeValidator( + instances: InstancesManager + ) -> (AccountsManager, DataContextValidator) { + let accounts = AccountsManager( + identityScope: CredentialIdentityScope(fixedScope: "identity-A"), + instancesManager: instances, + defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")! + ) + let validator = DataContextValidator( + accountsManager: accounts, + instancesManager: instances, + toastManager: ToastManager() + ) + return (accounts, validator) + } + + @Test("A switched-off instance makes its context unavailable, not removed") + func disabledInstanceIsUnavailable() { + let instances = InstancesManager() + var instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!) + instances.add(instance) + + let (accounts, validator) = makeValidator(instances: instances) + let account = accounts.addAccount(instanceID: instance.id) + accounts.setStatus(.signedIn, for: account.id) + accounts.setContext(.account(account.id)) + + #expect(validator.validity == .valid) + + // Disabling the instance leaves the account signed in, so nothing about + // the account itself says the server may no longer be used. + instance.isEnabled = false + instances.update(instance) + + #expect(validator.validity == .temporarilyUnavailable(.instanceDisabled)) + // The user's selection is kept — turning the instance back on restores + // it without them having to choose again. + #expect(accounts.currentContext == .account(account.id)) + #expect(validator.effectiveContext == .local) + #expect(!validator.allowsServerMutations) + + instances.remove(instance) + } +} + @Suite("Account capabilities") @MainActor struct AccountCapabilitiesTests {