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>
112 lines
3.7 KiB
Swift
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)
|
|
}
|
|
}
|