Files
jarvis/Jervis/Connectivity/ConnectivityManager.swift
Kutesir bd632a1592
Some checks are pending
CI / Build · Debug (push) Waiting to run
CI / Build · Release (push) Waiting to run
rename: Jarvis -> Jervis target, scheme, and folder throughout
The bundle-ID and display-name renames weren't enough — the actual
Xcode target/product name was still "Jarvis", which drives CFBundleName,
Xcode's Organizer archive list, the .xcodeproj filename, and the scheme
name. Renamed all the way through:

- project.yml: top-level name, target key, PRODUCT_NAME, source/info paths
- Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content
  diffs on the moved files)
- .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj /
  scheme Jarvis -> Jervis.xcodeproj / scheme Jervis

Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app,
CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis".
CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior
decision — bundle ID isn't user-facing anywhere including Organizer).

Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo
name/URL are unchanged — pure internal source identifiers, not user or
developer-facing product identity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 03:49:31 +03:00

114 lines
3.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 25
// that may have arrived since last session.
await StoryStore.shared.loadFeedFull()
}
}