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

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