mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Round three of review. The subscription picker is fed by SubscriptionAccountValidator, which still decided availability from the instance-keyed credential managers. A login created after the revamp writes only an account-scoped key, so it never appeared in the picker at all - which meant the account resolver added last round could not even be reached. Availability now comes from AccountsManager and account status. SubscriptionFeedCache dispatched on the legacy setting and picked its instance from it, so an account waiting on its credential produced subscriptions from local data while the feed reported notAuthenticated, and deleting the selected account could leave the route pointing at its server. Both the route and the credential now come from the effective context, with the legacy lookup kept only as a fallback for state that predates accounts. Foreground activation re-resolved the identity scope but only recomputed statuses when the scope actually moved. An iCloud Keychain credential arriving after launch does not move it, so the account stayed credentialMissing and reads stayed on the local fallback until relaunch. Statuses are now recomputed on every activation in both scene-phase branches. Providers are bound to an account but never told it anything: a revoked SID left the account signedIn, so the validator kept treating it as valid and neither the banner nor the read fallback engaged. Server calls now record their outcome. Conservatively - a raw 401 marks the credential rejected only when no reverse-proxy basic auth is configured, since otherwise the 401 may have come from the proxy. The two-stage probe that disambiguates the rest is Phase 2; until then an ambiguous failure leaves the status alone rather than wrongly telling the user to sign in again.
242 lines
8.7 KiB
Swift
242 lines
8.7 KiB
Swift
//
|
|
// SubscriptionAccountValidator.swift
|
|
// Yattee
|
|
//
|
|
// Validates subscription account selection and handles auto-correction
|
|
// when the selected account becomes unavailable.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Validates subscription account selection and provides available accounts for the picker.
|
|
/// Automatically corrects to a valid account if the current selection becomes invalid.
|
|
@MainActor
|
|
@Observable
|
|
final class SubscriptionAccountValidator {
|
|
// MARK: - Dependencies
|
|
|
|
private let settingsManager: SettingsManager
|
|
private let instancesManager: InstancesManager
|
|
private let invidiousCredentialsManager: InvidiousCredentialsManager
|
|
private let pipedCredentialsManager: PipedCredentialsManager
|
|
private let toastManager: ToastManager
|
|
private let feedCache: SubscriptionFeedCache
|
|
/// Availability is decided by accounts now: a login created after the
|
|
/// revamp writes only an account-scoped Keychain key, so the instance-keyed
|
|
/// check this validator used would never see it.
|
|
private weak var accountsManager: AccountsManager?
|
|
|
|
// MARK: - Observation State
|
|
|
|
private var observationTask: Task<Void, Never>?
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
settingsManager: SettingsManager,
|
|
instancesManager: InstancesManager,
|
|
invidiousCredentialsManager: InvidiousCredentialsManager,
|
|
pipedCredentialsManager: PipedCredentialsManager,
|
|
toastManager: ToastManager,
|
|
feedCache: SubscriptionFeedCache
|
|
) {
|
|
self.settingsManager = settingsManager
|
|
self.instancesManager = instancesManager
|
|
self.invidiousCredentialsManager = invidiousCredentialsManager
|
|
self.pipedCredentialsManager = pipedCredentialsManager
|
|
self.toastManager = toastManager
|
|
self.feedCache = feedCache
|
|
|
|
startObserving()
|
|
}
|
|
|
|
func setAccountsManager(_ manager: AccountsManager) {
|
|
accountsManager = manager
|
|
}
|
|
|
|
/// Whether any account on this instance is usable.
|
|
private func hasSignedInAccount(for instance: Instance) -> Bool {
|
|
guard let accountsManager else { return false }
|
|
return accountsManager.accounts(forInstance: instance.id)
|
|
.contains { accountsManager.status(for: $0.id) == .signedIn }
|
|
}
|
|
|
|
/// Stops observation. Call before releasing the validator.
|
|
func stopObserving() {
|
|
observationTask?.cancel()
|
|
observationTask = nil
|
|
}
|
|
|
|
// MARK: - Available Accounts
|
|
|
|
/// Available subscription accounts based on current configuration.
|
|
/// Only includes accounts that are properly configured and authenticated.
|
|
var availableAccounts: [SubscriptionAccount] {
|
|
var accounts: [SubscriptionAccount] = []
|
|
|
|
// Add local if Yattee Server is configured and enabled
|
|
let hasYatteeServer = instancesManager.instances.contains {
|
|
$0.type == .yatteeServer && $0.isEnabled
|
|
}
|
|
if hasYatteeServer {
|
|
accounts.append(.local)
|
|
}
|
|
|
|
// Add logged-in and enabled Invidious instances
|
|
for instance in instancesManager.instances {
|
|
if instance.type == .invidious &&
|
|
instance.isEnabled &&
|
|
hasSignedInAccount(for: instance) {
|
|
accounts.append(.invidious(instance.id))
|
|
}
|
|
}
|
|
|
|
// Add logged-in and enabled Piped instances
|
|
for instance in instancesManager.instances {
|
|
if instance.type == .piped &&
|
|
instance.isEnabled &&
|
|
hasSignedInAccount(for: instance) {
|
|
accounts.append(.piped(instance.id))
|
|
}
|
|
}
|
|
|
|
return accounts
|
|
}
|
|
|
|
/// Whether any subscription accounts are available.
|
|
var hasAvailableAccounts: Bool {
|
|
!availableAccounts.isEmpty
|
|
}
|
|
|
|
// MARK: - Validation
|
|
|
|
/// Whether the current subscription account is valid.
|
|
var isCurrentAccountValid: Bool {
|
|
let current = settingsManager.subscriptionAccount
|
|
return availableAccounts.contains(current)
|
|
}
|
|
|
|
/// Validates the current account and auto-corrects if invalid.
|
|
/// Call this on app launch and when configuration changes.
|
|
func validateAndCorrectIfNeeded() {
|
|
guard !isCurrentAccountValid else {
|
|
LoggingService.shared.debug(
|
|
"Current subscription account is valid: \(settingsManager.subscriptionAccount.type)",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
let previousAccount = settingsManager.subscriptionAccount
|
|
|
|
// Find first available account to switch to
|
|
if let newAccount = availableAccounts.first {
|
|
settingsManager.subscriptionAccount = newAccount
|
|
feedCache.handleAccountChange()
|
|
|
|
let newName = displayName(for: newAccount)
|
|
LoggingService.shared.info(
|
|
"Auto-corrected subscription account from \(previousAccount.type) to \(newAccount.type)",
|
|
category: .general
|
|
)
|
|
|
|
toastManager.showInfo(
|
|
String(localized: "subscriptions.accountSwitched.title"),
|
|
subtitle: newName
|
|
)
|
|
} else {
|
|
// No valid accounts available - set to local anyway
|
|
// The feed will show an appropriate error state
|
|
settingsManager.subscriptionAccount = .local
|
|
feedCache.handleAccountChange()
|
|
|
|
LoggingService.shared.warning(
|
|
"No valid subscription accounts available, defaulting to local",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Display Names
|
|
|
|
/// Returns a display name for the given subscription account.
|
|
func displayName(for account: SubscriptionAccount) -> String {
|
|
switch account.type {
|
|
case .local:
|
|
return String(localized: "subscriptions.account.local")
|
|
|
|
case .invidious:
|
|
guard let instanceID = account.instanceID,
|
|
let instance = instancesManager.instances.first(where: { $0.id == instanceID }) else {
|
|
return String(localized: "subscriptions.account.invidious")
|
|
}
|
|
// Use instance name if available, otherwise use hostname
|
|
let instanceName: String
|
|
if let name = instance.name, !name.isEmpty {
|
|
instanceName = name
|
|
} else {
|
|
instanceName = instance.url.host ?? "Invidious"
|
|
}
|
|
return String(localized: "subscriptions.account.invidiousInstance \(instanceName)")
|
|
|
|
case .piped:
|
|
guard let instanceID = account.instanceID,
|
|
let instance = instancesManager.instances.first(where: { $0.id == instanceID }) else {
|
|
return String(localized: "subscriptions.account.piped")
|
|
}
|
|
// Use instance name if available, otherwise use hostname
|
|
let instanceName: String
|
|
if let name = instance.name, !name.isEmpty {
|
|
instanceName = name
|
|
} else {
|
|
instanceName = instance.url.host ?? "Piped"
|
|
}
|
|
return String(localized: "subscriptions.account.pipedInstance \(instanceName)")
|
|
}
|
|
}
|
|
|
|
/// Returns the instance for an Invidious or Piped account, if available.
|
|
func instance(for account: SubscriptionAccount) -> Instance? {
|
|
guard account.type == .invidious || account.type == .piped,
|
|
let instanceID = account.instanceID else {
|
|
return nil
|
|
}
|
|
return instancesManager.instances.first { $0.id == instanceID }
|
|
}
|
|
|
|
// MARK: - Observation
|
|
|
|
/// Starts observing changes to instances and credentials.
|
|
private func startObserving() {
|
|
observationTask?.cancel()
|
|
|
|
observationTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
|
|
while !Task.isCancelled {
|
|
// Wait for changes using withObservationTracking
|
|
await withCheckedContinuation { continuation in
|
|
withObservationTracking {
|
|
// Access the observed properties to register for tracking
|
|
_ = self.instancesManager.instances
|
|
_ = self.invidiousCredentialsManager.loggedInInstanceIDs
|
|
_ = self.pipedCredentialsManager.loggedInInstanceIDs
|
|
} onChange: {
|
|
// Resume when a change is detected
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
guard !Task.isCancelled else { break }
|
|
|
|
// Small delay to batch rapid changes
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
guard !Task.isCancelled else { break }
|
|
|
|
// Validate after changes
|
|
self.validateAndCorrectIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
}
|