// 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? 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() } }