Files
yattee/Yattee/Services/Subscriptions/DataContextValidator.swift
Arkadiusz Fal 03037e6769 Let a refused host count as unreachable and the switcher own every login
Residual round after the macOS fix verification, five closures:

A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.

Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.

The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.

The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.

The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
2026-09-12 17:10:58 +02:00

318 lines
13 KiB
Swift

//
// 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
/// 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)
}
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 .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) }
let current = accountsManager.currentContext
if case let .account(id) = current,
accountsManager.account(id: id) != nil,
!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.
///
/// Not the same question as `validity(of:)`: a rejected or unreachable
/// account is still a perfectly good thing to switch *to* — that is where
/// the user goes to fix it — while a login on a switched-off source is not.
func isSwitchable(_ context: DataContext) -> Bool {
switch context {
case .local:
return true
case let .account(id):
guard accountsManager.selectableAccounts.contains(where: { $0.id == id }),
let account = accountsManager.account(id: id) else {
return false
}
return accountsManager.instance(for: account)?.isEnabled ?? false
}
}
// 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 String(localized: "subscriptions.account.local")
case let .account(id):
guard let account = accountsManager.account(id: id) else {
return String(localized: "subscriptions.account.local")
}
return displayName(for: account)
}
}
/// 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.
///
/// 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 host = accountsManager.instance(for: account)?.url.host {
return "\(username)@\(host)"
}
return username
}
if let instance = accountsManager.instance(for: account) {
if let name = instance.name, !name.isEmpty { return name }
return instance.url.host ?? 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
}
}