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>
This commit is contained in:
111
Jarvis/Connectivity/ConnectivityManager.swift
Normal file
111
Jarvis/Connectivity/ConnectivityManager.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user