Files
jarvis/Jarvis/Connectivity/ConnectivityManager.swift
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

112 lines
3.7 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)
await StoryStore.shared.loadStories(refresh: true)
}
}