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:
Arkadiusz Fal
2026-08-25 22:40:47 +02:00
parent bef119f70e
commit defa6cf084
6 changed files with 144 additions and 16 deletions

View File

@@ -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