rename: Jarvis -> Jervis target, scheme, and folder throughout
Some checks are pending
CI / Build · Debug (push) Waiting to run
CI / Build · Release (push) Waiting to run

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:
Kutesir
2026-07-13 03:49:31 +03:00
parent 3a6f1bbdd8
commit bd632a1592
46 changed files with 20 additions and 19 deletions

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