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

@@ -910,9 +910,12 @@ final class AccountsManager {
// A server that cannot be reached says nothing about the credential.
// Recorded all the same: the account has to stop reporting itself as
// usable, or reads never fall back and the user is shown failures
// instead of local data.
// instead of local data. A host that *refuses* the connection counts:
// it is as unreachable as one that never answers, and being told so
// instantly rather than after a timeout does not make it a different
// verdict.
switch error {
case APIError.timeout, APIError.noConnection:
case APIError.timeout, APIError.noConnection, APIError.connectionFailed:
setStatus(.unreachable, for: account.id)
case APIError.cancelled:
return

View File

@@ -332,7 +332,7 @@ final class InstancesManager {
switch apiError {
case .unauthorized:
updateStatus(.authFailed, for: instance)
case .noConnection, .timeout:
case .noConnection, .timeout, .connectionFailed:
updateStatus(.offline, for: instance)
default:
// Don't change status for other errors

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

View File

@@ -1292,7 +1292,11 @@ struct ChannelView: View {
}
}
} label: {
Image(systemName: channelMenuIcon)
// A bare Image leaves the menu's accessibility title as the raw SF
// Symbol name; a Label carries the localized title to the AX bridge
// while `.iconOnly` keeps the toolbar rendering icon-only.
Label(String(localized: "channel.menu.title"), systemImage: channelMenuIcon)
.labelStyle(.iconOnly)
}
.accessibilityLabel(Text(String(localized: "channel.menu.title")))
}

View File

@@ -22,7 +22,7 @@ struct DataContextSwitcherRows: View {
private var validator: DataContextValidator? { appEnvironment?.dataContextValidator }
var body: some View {
ForEach(validator?.availableContexts ?? [.local], id: \.self) { context in
ForEach(validator?.listedContexts ?? [.local], id: \.self) { context in
row(for: context)
}
}
@@ -31,7 +31,10 @@ struct DataContextSwitcherRows: View {
private func row(for context: DataContext) -> some View {
// A menu row is a single label — a `VStack` inside one renders as
// nothing on macOS — so the status travels in the title rather than as
// a second line.
// a second line. A context that cannot be switched to says so there
// too: `title(for:in:)` appends the *display* status, which is what
// turns a login on a switched-off source into "… — Source switched
// off" rather than a row that looks ordinary and does nothing.
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
#if os(macOS)
@@ -48,6 +51,7 @@ struct DataContextSwitcherRows: View {
)) {
Text(title)
}
.disabled(isRefused(context))
#else
Button {
switchTo(context)
@@ -58,6 +62,7 @@ struct DataContextSwitcherRows: View {
Text(title)
}
}
.disabled(isRefused(context))
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
@@ -65,6 +70,10 @@ struct DataContextSwitcherRows: View {
}
private func switchTo(_ context: DataContext) {
// The row is disabled as well; this is what makes the refusal true on
// any presentation where a disabled control can still be activated.
guard !isRefused(context) else { return }
appEnvironment?.switchDataContext(to: context)
onSwitch?()
}
@@ -72,6 +81,18 @@ struct DataContextSwitcherRows: View {
private func isCurrent(_ context: DataContext) -> Bool {
appEnvironment?.accountsManager.currentContext == context
}
/// Listed, but not something the user may pick.
///
/// The current context is never refused, whatever its source is doing: it
/// is already the selection, picking it changes nothing, and a disabled
/// macOS menu item is not the place to hang the app's only checkmark. It
/// carries its status in the title instead, which is the part that has to
/// survive.
private func isRefused(_ context: DataContext) -> Bool {
guard let validator, !isCurrent(context) else { return false }
return !validator.isSwitchable(context)
}
}
// MARK: - Labels
@@ -352,23 +373,13 @@ struct DataContextSwitcherSettingsRow: View {
// trailing name was dropped at every width, leaving the row silent
// about which account is selected. So the name is the menu's *own*
// label, and the row carries only what names the control.
LabeledContent {
Menu {
DataContextSwitcherRows()
} label: {
Text(contextTitle)
.lineLimit(1)
}
.menuStyle(.borderlessButton)
.help(contextTitle)
.accessibilityLabel(Text(String(localized: "accounts.switcher.title")))
.accessibilityValue(Text(contextTitle))
} label: {
Label {
Text(String(localized: "accounts.switcher.title"))
} icon: {
DataContextSwitcherIcon()
}
// `LabeledContent` overflows rather than compress its label, so at the
// sidebar's 140 pt minimum a captioned row (caption + spacing + the
// name's floor ≈ 158 pt) juts past both edges. The caption is the one
// part the icon already says, so it is the part that goes.
ViewThatFits(in: .horizontal) {
macOSRow(captioned: true)
macOSRow(captioned: false)
}
#else
Menu {
@@ -379,11 +390,58 @@ struct DataContextSwitcherSettingsRow: View {
#endif
}
#if os(macOS)
@ViewBuilder private func macOSRow(captioned: Bool) -> some View {
LabeledContent {
Menu {
DataContextSwitcherRows()
} label: {
Text(contextTitle)
.lineLimit(1)
.truncationMode(.tail)
}
.menuStyle(.borderlessButton)
// The floor must sit on the menu itself: a borderless menu button
// lays out its label alone, so a frame inside it never reaches the
// row's layout, and the caption would keep its ideal width while
// the name shrank to an ellipsis. Out here the menu claims the
// width and the caption is what gives way.
.frame(minWidth: Self.minimumNameWidth, alignment: .trailing)
.layoutPriority(1)
.help(contextTitle)
.accessibilityLabel(Text(String(localized: "accounts.switcher.title")))
.accessibilityValue(Text(contextTitle))
} label: {
Label {
if captioned {
Text(String(localized: "accounts.switcher.title"))
.lineLimit(1)
.truncationMode(.tail)
}
} icon: {
DataContextSwitcherIcon()
}
}
}
#endif
/// What the trailing name may never be squeezed below.
///
/// A row this narrow — the macOS sidebar's footer at a dragged-in column
/// width — hands the leading label its ideal width first and leaves the
/// name whatever is left, and a `Text` will shrink all the way to a lone
/// ellipsis before it complains. A floor turns that around: the name keeps
/// enough characters to be recognised, and the word naming the control —
/// which the icon beside it already says — is what truncates instead.
private static let minimumNameWidth: CGFloat = 80
#if !os(macOS)
private var rowLabel: some View {
HStack {
Label {
Text(String(localized: "accounts.switcher.title"))
.lineLimit(1)
.truncationMode(.tail)
} icon: {
DataContextSwitcherIcon()
}
@@ -391,6 +449,8 @@ struct DataContextSwitcherSettingsRow: View {
Text(contextTitle)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(minWidth: Self.minimumNameWidth, alignment: .trailing)
}
}
#endif

View File

@@ -26,8 +26,8 @@ struct SubscriptionsView: View {
/// The effective context the visible subscriptions were loaded for, so a
/// status change that does not move it does not force a reload.
@State private var loadedContext: DataContext?
/// How many times the feed has been tried again since it last failed on the
/// network, so each attempt restarts the wait for the next one.
/// How many times the feed has been tried again since it last stopped on a
/// failure that can lift, so each attempt restarts the wait for the next one.
@State private var feedRetryAttempt = 0
@State private var selectedChannelID: String? = nil
@State private var errorMessage: String?
@@ -195,8 +195,16 @@ struct SubscriptionsView: View {
/// The failure the feed is currently sitting on, if it is one that trying
/// again could answer differently.
///
/// A network error always is. So is the Yattee Server the local feed asks
/// for while it is only standing in for an account whose server could not
/// be reached — that request is not the real answer, and the one that is
/// comes back on a status expiry nothing here is watching. An account that
/// genuinely has no Yattee Server never arms this: the unavailability it
/// gates on is `nil` for a context the user selected themselves.
private var feedRetryToken: Int? {
guard case .error(.networkError(_)) = feedCache.feedLoadState else { return nil }
guard feedCache.feedLoadState.warrantsPeriodicRetry(unavailability: contextUnavailability)
else { return nil }
return feedRetryAttempt
}
@@ -622,6 +630,11 @@ struct SubscriptionsView: View {
// a feed that failed on the way to it is not watching anything
// that would notice — so it asks again on its own, for as long
// as the failure is on screen.
//
// One attempt in flight at a time, and none at all outside the
// states above: the id going nil, the fence dropping the state
// back to idle, and the view disappearing all cancel this task,
// and the next attempt is only armed by the last one finishing.
.task(id: feedRetryToken) {
guard feedRetryToken != nil else { return }
try? await Task.sleep(for: .seconds(Self.feedRetryInterval))