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.
68 lines
2.9 KiB
Swift
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
|
|
}
|
|
}
|