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".
This commit is contained in:
Arkadiusz Fal
2026-09-12 17:10:58 +02:00
parent 40d6f4e71f
commit 03037e6769
14 changed files with 440 additions and 36 deletions

View File

@@ -222,7 +222,10 @@ final class CredentialProbeService {
return .rejected
case let APIError.httpError(statusCode, _) where statusCode == 401 || statusCode == 403:
return .rejected
case APIError.timeout, APIError.noConnection:
case APIError.timeout, APIError.noConnection, APIError.connectionFailed:
// A refused connection or an unresolvable host answers the probe no
// better than silence does: the question never reached anything
// that could judge the credential.
return .unreachable
default:
// Cancellation included: a probe that was torn down never asked the

View File

@@ -24,6 +24,16 @@ enum APIError: Error, LocalizedError, Equatable, Sendable {
/// No network connection available.
case noConnection
/// The device is on a network, but the server could not be reached at all:
/// the connection was refused, or the host does not resolve.
///
/// Distinct from `noConnection`, which says the *device* is offline. A
/// refusing or unresolvable host is one server's problem, and everything
/// that reacts to it — falling back to local data, retrying later — has to
/// stay scoped to that server. Carries the system's own description so the
/// user is told which of the two it was.
case connectionFailed(String)
/// The request was cancelled.
case cancelled
@@ -72,6 +82,12 @@ enum APIError: Error, LocalizedError, Equatable, Sendable {
return "Request timed out"
case .noConnection:
return "No network connection"
case .connectionFailed(let message):
// Already the system's localized sentence ("Could not connect to
// the server.", "A server with the specified hostname could not be
// found."), which names the failure more precisely than a single
// fixed string could.
return message
case .cancelled:
return "Request was cancelled"
case .serverError(let message):
@@ -106,7 +122,11 @@ enum APIError: Error, LocalizedError, Equatable, Sendable {
/// Whether this error is likely recoverable by retrying.
var isRetryable: Bool {
switch self {
case .timeout, .noConnection, .rateLimited:
// A refused connection or a failed lookup is the same kind of answer as
// a timeout: nothing about the request was wrong, the server simply was
// not there for that moment. Restarts and DNS hiccups are exactly what
// a second attempt exists for.
case .timeout, .noConnection, .connectionFailed, .rateLimited:
return true
case .httpError(let statusCode, _):
return statusCode >= 500 || statusCode == 429
@@ -136,6 +156,8 @@ enum APIError: Error, LocalizedError, Equatable, Sendable {
return lMsg == rMsg
case (.serverError(let lMsg), .serverError(let rMsg)):
return lMsg == rMsg
case (.connectionFailed(let lMsg), .connectionFailed(let rMsg)):
return lMsg == rMsg
case (.rateLimited(let lRetry), .rateLimited(let rRetry)):
return lRetry == rRetry
case (.unknown(let lMsg), .unknown(let rMsg)):

View File

@@ -280,6 +280,14 @@ actor HTTPClient {
return .timeout
case .notConnectedToInternet, .networkConnectionLost:
return .noConnection
case .cannotConnectToHost, .cannotFindHost, .dnsLookupFailed:
// A server that refuses the connection, or a name that does not
// resolve, used to arrive as `unknown` — which nothing classifies,
// so an account pointed at a downed instance never went
// `unreachable`, never fell back to local data and never got its
// retry. It is not the same failure as a timeout, but it deserves
// the same treatment.
return .connectionFailed(error.localizedDescription)
case .cancelled:
return .cancelled
default:

View File

@@ -37,6 +37,31 @@ enum FeedLoadState: Equatable {
return error
}
/// Whether a feed left in this state should keep asking again on its own.
///
/// A network failure is its own reason to try again. `yatteeServerRequired`
/// usually is not — an account that genuinely needs a Yattee Server goes on
/// needing one, and a timer there would never end and never change
/// anything. The exception is the local fallback standing in for a server
/// that merely could not be reached: the feed is asking for a Yattee Server
/// only because reads moved to local data, and they move back the moment
/// the server answers again — silently, on a status that expires with
/// nothing on this screen watching for it.
///
/// - Parameter unavailability: why the selected context is not the one
/// answering, or `nil` when it is — local data selected by the user is
/// never unavailable, so its Yattee Server request is the real answer.
func warrantsPeriodicRetry(unavailability: DataContextUnavailability?) -> Bool {
switch self {
case .error(.networkError):
return true
case .error(.yatteeServerRequired):
return unavailability == .unreachable
default:
return false
}
}
/// Specific error types for feed loading.
enum FeedLoadError: Equatable {
case yatteeServerRequired

View File

@@ -100,9 +100,10 @@ final class DataContextValidator {
}
// 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.
// 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)
}
@@ -145,17 +146,62 @@ final class DataContextValidator {
// 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.
/// 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] {
let accountContexts = accountsManager.selectableAccounts
.filter { accountsManager.instance(for: $0)?.isEnabled ?? false }
.map { DataContext.account($0.id) }
listedContexts.filter { isSwitchable($0) }
}
return [.local] + accountContexts
/// 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