- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections - Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized - StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch - sectionSupplement background fetch populates sparse regional sections in All digest - SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5) - Theme: removed standalone Uganda pill, wired new sub-section layouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
3.8 KiB
Swift
114 lines
3.8 KiB
Swift
// ConnectivityManager.swift
|
||
// Jarvis — resolves which endpoint to use by probing /health, activates it on the
|
||
// APIClient + WebSocket, and re-resolves when the network path changes.
|
||
|
||
import Foundation
|
||
import Network
|
||
|
||
enum Reachability: Equatable {
|
||
case unknown, checking, reachable, unreachable
|
||
}
|
||
|
||
@MainActor
|
||
final class ConnectivityManager: ObservableObject {
|
||
static let shared = ConnectivityManager()
|
||
|
||
/// The endpoint currently in use (nil = nothing reachable).
|
||
@Published var activeHost: String?
|
||
/// Per-host probe results, for the settings UI badges.
|
||
@Published var status: [String: Reachability] = [:]
|
||
@Published var isResolving = false
|
||
|
||
private let settings = ConnectivitySettings.shared
|
||
private let probeSession: URLSession
|
||
private var monitor: NWPathMonitor?
|
||
private var lastActivated: String?
|
||
private var resolveTask: Task<Void, Never>?
|
||
|
||
private init() {
|
||
let cfg = URLSessionConfiguration.ephemeral
|
||
cfg.timeoutIntervalForRequest = 2.5
|
||
cfg.waitsForConnectivity = false
|
||
probeSession = URLSession(configuration: cfg)
|
||
}
|
||
|
||
/// Begin watching the network path; re-resolve (debounced) on changes.
|
||
func start() {
|
||
guard monitor == nil else { return }
|
||
let m = NWPathMonitor()
|
||
m.pathUpdateHandler = { [weak self] _ in
|
||
Task { @MainActor in self?.scheduleResolve() }
|
||
}
|
||
m.start(queue: DispatchQueue(label: "jarvis.connectivity"))
|
||
monitor = m
|
||
}
|
||
|
||
private func scheduleResolve() {
|
||
guard settings.autoConnect else { return } // only auto-switch when enabled
|
||
resolveTask?.cancel()
|
||
let delay = UInt64(max(1, settings.switchDelaySeconds)) * 1_000_000_000
|
||
resolveTask = Task { [weak self] in
|
||
try? await Task.sleep(nanoseconds: delay)
|
||
guard !Task.isCancelled else { return }
|
||
await self?.resolveAndActivate()
|
||
}
|
||
}
|
||
|
||
/// Probe candidates in priority order; activate the first that answers.
|
||
func resolveAndActivate() async {
|
||
guard !isResolving else { return }
|
||
let candidates = settings.candidates
|
||
guard !candidates.isEmpty else { return }
|
||
isResolving = true
|
||
defer { isResolving = false }
|
||
|
||
for host in candidates {
|
||
status[host] = .checking
|
||
if await probe(host) {
|
||
status[host] = .reachable
|
||
await activate(host)
|
||
return
|
||
} else {
|
||
status[host] = .unreachable
|
||
}
|
||
}
|
||
// Nothing reachable.
|
||
activeHost = nil
|
||
}
|
||
|
||
/// Probe a single host and record its status (used by the settings screen).
|
||
@discardableResult
|
||
func check(_ host: String) async -> Bool {
|
||
let h = host.trimmingCharacters(in: .whitespaces)
|
||
guard !h.isEmpty else { return false }
|
||
status[h] = .checking
|
||
let ok = await probe(h)
|
||
status[h] = ok ? .reachable : .unreachable
|
||
return ok
|
||
}
|
||
|
||
// MARK: - Private
|
||
|
||
private func probe(_ host: String) async -> Bool {
|
||
guard let url = URL(string: "http://\(host)/api/v1/health") else { return false }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "GET"
|
||
do {
|
||
let (_, response) = try await probeSession.data(for: req)
|
||
return (response as? HTTPURLResponse).map { (200...299).contains($0.statusCode) } ?? false
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func activate(_ host: String) async {
|
||
activeHost = host
|
||
guard host != lastActivated else { return } // avoid thrashing the WS
|
||
lastActivated = host
|
||
await ServerSettings.shared.activate(host: host)
|
||
// Full paginated load — page 1 only misses unread stories on pages 2–5
|
||
// that may have arrived since last session.
|
||
await StoryStore.shared.loadFeedFull()
|
||
}
|
||
}
|