Files
Outpost/Outpost/Root/APIFailureCenter.swift
T
Puranjay Savar Mattas 5d5cda9cea fix: RetryPolicy.withRetry closure label, missing Foundation import
RetryPolicy.withRetry's operation param wasn't anonymous (_), so the
14 Outpost call sites that pass the closure in parens - RetryPolicy.
withRetry({ ... }) - rather than as a trailing closure failed to
compile ("Missing argument label 'operation:'" cascading into
nonsense errors about maxAttempts). OutlineKit's own internal call
sites all happened to use trailing-closure syntax, so this only
showed up once the app target actually got compiled. One-line fix at
the declaration (_ operation:) instead of touching every call site -
trailing-closure calls are unaffected either way.

APIFailureCenter.swift used Date/TimeInterval/URL/URLComponents/
URLQueryItem while only importing Observation and OutlineKit -
missing import Foundation. Other new files in the same commit escaped
this because they import SwiftUI, which re-exports Foundation
transitively; this one didn't.

OutlineKit: 92/92 still passing.
2026-08-21 00:48:17 +01:00

68 lines
2.9 KiB
Swift

import Foundation
import Observation
import OutlineKit
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
/// the user can actually see and act on, instead of a silently-swallowed
/// `try?` — see the pins bug this whole mechanism exists to catch a repeat
/// of. `RootView` polls the client periodically and feeds results in via
/// `update(with:)`; nothing here talks to the network directly.
@MainActor
@Observable
final class APIFailureCenter {
/// The single most-relevant category to show right now, or nil if
/// nothing's currently past the threshold (or everything past it has
/// been dismissed and is still in its cooldown).
private(set) var activeBanner: RepeatedFailure?
/// Categories the user's already dismissed, and when — suppressed from
/// reappearing until `dismissCooldown` passes, so a still-flaky
/// operation doesn't pop the same banner right back up a few seconds
/// after being told to go away.
private var dismissedAt: [String: Date] = [:]
private let dismissCooldown: TimeInterval = 900
/// Called from `RootView`'s poll loop with the latest snapshot from
/// `CachingOutlineAPIClient`. Picks the worst-offending category
/// (highest failure count) that isn't in cooldown; clears the banner
/// entirely once nothing qualifies (e.g. the user went back online and
/// everything recovered).
func update(with summaries: [RepeatedFailure]) {
let now = Date()
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
let eligible = summaries
.filter { dismissedAt[$0.category] == nil }
.sorted { $0.count > $1.count }
activeBanner = eligible.first
}
/// Dismiss without reporting — starts that category's cooldown so it
/// won't immediately reappear on the next poll if it's still failing.
func dismiss() {
guard let category = activeBanner?.category else { return }
dismissedAt[category] = Date()
activeBanner = nil
}
/// Everything folded into the report is safe to paste into a public bug
/// tracker as-is: a category name, a generic error description, and
/// version numbers — no document content, no server URL, no token.
func reportURL(appVersion: String, osVersion: String) -> URL? {
guard let banner = activeBanner else { return nil }
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
let body = """
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
Error: \(banner.message)
App version: \(appVersion)
macOS: \(osVersion)
<!-- Anything else you can add about what you were doing when this started would help. -->
"""
components?.queryItems = [URLQueryItem(name: "body", value: body)]
return components?.url
}
}