mirror of
https://github.com/yattee/yattee.git
synced 2026-10-30 16:37:17 +00:00
Make the account picker and feed route see account logins
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.
This commit is contained in:
@@ -23,6 +23,26 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
|
||||
private let invidiousAPI: InvidiousAPI
|
||||
private let credentialsManager: InvidiousCredentialsManager
|
||||
private let accountsManager: AccountsManager
|
||||
|
||||
/// Records the outcome of a server call against the account.
|
||||
///
|
||||
/// Deliberately conservative: a raw 401 marks the credential rejected only
|
||||
/// when no reverse-proxy basic auth is configured, because otherwise the
|
||||
/// 401 may well have come from the proxy rather than the account. The
|
||||
/// two-stage probe that disambiguates the remaining case is Phase 2 work;
|
||||
/// until then an ambiguous failure leaves the status untouched rather than
|
||||
/// wrongly telling the user to sign in again.
|
||||
private func recordOutcome(_ error: Error?) {
|
||||
guard let error else {
|
||||
accountsManager.setStatus(.signedIn, for: account.id)
|
||||
return
|
||||
}
|
||||
|
||||
guard case APIError.unauthorized = error else { return }
|
||||
guard accountsManager.instance(for: account)?.usesBasicAuth == false else { return }
|
||||
|
||||
accountsManager.setStatus(.credentialRejected, for: account.id)
|
||||
}
|
||||
private let settingsManager: SettingsManager
|
||||
|
||||
/// In-memory cache of subscribed channel IDs for fast lookup.
|
||||
@@ -56,7 +76,9 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
|
||||
let (instance, sid) = try await getAuthenticatedInstance()
|
||||
|
||||
// Fetch subscriptions from Invidious
|
||||
let subscriptions = try await invidiousAPI.subscriptions(instance: instance, sid: sid)
|
||||
let subscriptions = try await tracking {
|
||||
try await invidiousAPI.subscriptions(instance: instance, sid: sid)
|
||||
}
|
||||
|
||||
// Update in-memory cache
|
||||
cachedChannelIDs = Set(subscriptions.map(\.authorId))
|
||||
@@ -72,7 +94,9 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
|
||||
let (instance, sid) = try await getAuthenticatedInstance()
|
||||
|
||||
// Subscribe on Invidious server
|
||||
try await invidiousAPI.subscribe(to: channel.id.channelID, instance: instance, sid: sid)
|
||||
try await tracking {
|
||||
try await invidiousAPI.subscribe(to: channel.id.channelID, instance: instance, sid: sid)
|
||||
}
|
||||
|
||||
// Update in-memory cache only
|
||||
cachedChannelIDs.insert(channel.id.channelID)
|
||||
@@ -121,6 +145,18 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Gets the authenticated Invidious instance and session ID from account settings.
|
||||
/// Runs a server call and records what it implies about the credential.
|
||||
private func tracking<T>(_ operation: () async throws -> T) async rethrows -> T {
|
||||
do {
|
||||
let value = try await operation()
|
||||
recordOutcome(nil)
|
||||
return value
|
||||
} catch {
|
||||
recordOutcome(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func getAuthenticatedInstance() async throws -> (Instance, String) {
|
||||
// The account names its instance directly - no falling back to
|
||||
// "some enabled Invidious instance", which could silently send one
|
||||
|
||||
@@ -23,6 +23,26 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
|
||||
private let pipedAPI: PipedAPI
|
||||
private let credentialsManager: PipedCredentialsManager
|
||||
private let accountsManager: AccountsManager
|
||||
|
||||
/// Records the outcome of a server call against the account.
|
||||
///
|
||||
/// Deliberately conservative: a raw 401 marks the credential rejected only
|
||||
/// when no reverse-proxy basic auth is configured, because otherwise the
|
||||
/// 401 may well have come from the proxy rather than the account. The
|
||||
/// two-stage probe that disambiguates the remaining case is Phase 2 work;
|
||||
/// until then an ambiguous failure leaves the status untouched rather than
|
||||
/// wrongly telling the user to sign in again.
|
||||
private func recordOutcome(_ error: Error?) {
|
||||
guard let error else {
|
||||
accountsManager.setStatus(.signedIn, for: account.id)
|
||||
return
|
||||
}
|
||||
|
||||
guard case APIError.unauthorized = error else { return }
|
||||
guard accountsManager.instance(for: account)?.usesBasicAuth == false else { return }
|
||||
|
||||
accountsManager.setStatus(.credentialRejected, for: account.id)
|
||||
}
|
||||
private let settingsManager: SettingsManager
|
||||
|
||||
/// In-memory cache of subscribed channel IDs for fast lookup.
|
||||
@@ -56,7 +76,9 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
|
||||
let (instance, authToken) = try await getAuthenticatedInstance()
|
||||
|
||||
// Fetch subscriptions from Piped
|
||||
let subscriptions = try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
|
||||
let subscriptions = try await tracking {
|
||||
try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
|
||||
}
|
||||
|
||||
// Update in-memory cache
|
||||
cachedChannelIDs = Set(subscriptions.map(\.channelId))
|
||||
@@ -121,6 +143,18 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Gets the authenticated Piped instance and auth token from account settings.
|
||||
/// Runs a server call and records what it implies about the credential.
|
||||
private func tracking<T>(_ operation: () async throws -> T) async rethrows -> T {
|
||||
do {
|
||||
let value = try await operation()
|
||||
recordOutcome(nil)
|
||||
return value
|
||||
} catch {
|
||||
recordOutcome(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func getAuthenticatedInstance() async throws -> (Instance, String) {
|
||||
// The account names its instance directly - no falling back to
|
||||
// "some enabled Piped instance", which could silently send one
|
||||
|
||||
@@ -21,6 +21,10 @@ final class SubscriptionAccountValidator {
|
||||
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
|
||||
|
||||
@@ -46,6 +50,17 @@ final class SubscriptionAccountValidator {
|
||||
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()
|
||||
@@ -71,7 +86,7 @@ final class SubscriptionAccountValidator {
|
||||
for instance in instancesManager.instances {
|
||||
if instance.type == .invidious &&
|
||||
instance.isEnabled &&
|
||||
invidiousCredentialsManager.isLoggedIn(for: instance) {
|
||||
hasSignedInAccount(for: instance) {
|
||||
accounts.append(.invidious(instance.id))
|
||||
}
|
||||
}
|
||||
@@ -80,7 +95,7 @@ final class SubscriptionAccountValidator {
|
||||
for instance in instancesManager.instances {
|
||||
if instance.type == .piped &&
|
||||
instance.isEnabled &&
|
||||
pipedCredentialsManager.isLoggedIn(for: instance) {
|
||||
hasSignedInAccount(for: instance) {
|
||||
accounts.append(.piped(instance.id))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user