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>
This commit is contained in:
113
Jervis/Connectivity/ConnectivityManager.swift
Normal file
113
Jervis/Connectivity/ConnectivityManager.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
// 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 2–5
|
||||
// that may have arrived since last session.
|
||||
await StoryStore.shared.loadFeedFull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user