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)
|
||||
}
|
||||
}
|
||||
73
Jarvis/Connectivity/ConnectivitySettings.swift
Normal file
73
Jarvis/Connectivity/ConnectivitySettings.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
// ConnectivitySettings.swift
|
||||
// Jarvis — persisted connection profiles: a Direct/LAN address and a Remote/VPN
|
||||
// address for the same server, the chosen provider, and the auto-switch policy.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ConnectivitySettings: ObservableObject {
|
||||
static let shared = ConnectivitySettings()
|
||||
|
||||
private let key = "jarvis_connectivity_v2"
|
||||
|
||||
@Published var provider: VPNProvider { didSet { persist() } }
|
||||
@Published var directHost: String { didSet { persist() } } // LAN, e.g. 192.168.30.50:8080
|
||||
@Published var remoteHost: String { didSet { persist() } } // Tailscale/VPN address
|
||||
/// "Use <provider> when remote" — when off, only the LAN address is ever used.
|
||||
@Published var remoteEnabled: Bool { didSet { persist() } }
|
||||
/// "Auto-connect" — re-resolve automatically when the network path changes.
|
||||
@Published var autoConnect: Bool { didSet { persist() } }
|
||||
/// Debounce before switching endpoints after a network change (seconds).
|
||||
@Published var switchDelaySeconds: Int { didSet { persist() } }
|
||||
|
||||
private struct Stored: Codable {
|
||||
var provider: VPNProvider
|
||||
var directHost: String
|
||||
var remoteHost: String
|
||||
var remoteEnabled: Bool?
|
||||
var autoConnect: Bool?
|
||||
var switchDelaySeconds: Int?
|
||||
}
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let s = try? JSONDecoder().decode(Stored.self, from: data) {
|
||||
provider = s.provider
|
||||
directHost = s.directHost
|
||||
remoteHost = s.remoteHost
|
||||
remoteEnabled = s.remoteEnabled ?? true
|
||||
autoConnect = s.autoConnect ?? true
|
||||
switchDelaySeconds = s.switchDelaySeconds ?? 5
|
||||
} else {
|
||||
provider = .tailscale
|
||||
directHost = UserDefaults.standard.string(forKey: "jarvis_server_host") ?? ""
|
||||
remoteHost = ""
|
||||
remoteEnabled = true
|
||||
autoConnect = true
|
||||
switchDelaySeconds = 5
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let s = Stored(provider: provider, directHost: directHost, remoteHost: remoteHost,
|
||||
remoteEnabled: remoteEnabled, autoConnect: autoConnect,
|
||||
switchDelaySeconds: switchDelaySeconds)
|
||||
if let data = try? JSONEncoder().encode(s) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the remote endpoint participates in resolution right now.
|
||||
var remoteActiveInPolicy: Bool { provider.needsRemote && remoteEnabled }
|
||||
|
||||
/// Hosts to probe, in priority order (LAN first), de-duplicated and trimmed.
|
||||
var candidates: [String] {
|
||||
let d = directHost.trimmingCharacters(in: .whitespaces)
|
||||
let r = remoteActiveInPolicy ? remoteHost.trimmingCharacters(in: .whitespaces) : ""
|
||||
var seen = Set<String>(); var out: [String] = []
|
||||
for h in [d, r] where !h.isEmpty && !seen.contains(h) { seen.insert(h); out.append(h) }
|
||||
return out
|
||||
}
|
||||
|
||||
var isConfigured: Bool { !candidates.isEmpty }
|
||||
}
|
||||
101
Jarvis/Connectivity/VPNProvider.swift
Normal file
101
Jarvis/Connectivity/VPNProvider.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
// VPNProvider.swift
|
||||
// Jarvis — remote-access providers. The app can't start another app's VPN tunnel
|
||||
// (iOS sandboxing), so the provider only tailors hints, help text, and an
|
||||
// "open the app" jump. Reachability + endpoint selection is provider-agnostic.
|
||||
|
||||
import UIKit
|
||||
|
||||
enum VPNProvider: String, CaseIterable, Codable, Identifiable {
|
||||
case direct // LAN only, no VPN
|
||||
case tailscale
|
||||
case headscale // self-hosted control plane, Tailscale clients
|
||||
case netbird
|
||||
case zerotier
|
||||
case netmaker
|
||||
case wireguard
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .direct: return "Direct (LAN)"
|
||||
case .tailscale: return "Tailscale"
|
||||
case .headscale: return "Headscale"
|
||||
case .netbird: return "NetBird"
|
||||
case .zerotier: return "ZeroTier"
|
||||
case .netmaker: return "Netmaker"
|
||||
case .wireguard: return "WireGuard"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a separate remote/VPN address is meaningful for this provider.
|
||||
var needsRemote: Bool { self != .direct }
|
||||
|
||||
/// Placeholder shown in the remote-address field.
|
||||
var remoteHint: String {
|
||||
switch self {
|
||||
case .direct: return "—"
|
||||
case .tailscale: return "jarvis.tailnet.ts.net:8080 or 100.x.y.z:8080"
|
||||
case .headscale: return "100.x.y.z:8080 (MagicDNS or tailnet IP)"
|
||||
case .netbird: return "100.x.y.z:8080"
|
||||
case .zerotier: return "10.x.x.x:8080 (managed IP)"
|
||||
case .netmaker: return "10.x.x.x:8080"
|
||||
case .wireguard: return "10.0.0.x:8080 (peer IP)"
|
||||
}
|
||||
}
|
||||
|
||||
var blurb: String {
|
||||
switch self {
|
||||
case .direct:
|
||||
return "Use the LAN address only. Best when the phone is always on the home network."
|
||||
case .tailscale:
|
||||
return "On your home wifi Jarvis uses the LAN address directly; away, it uses your Tailscale address. Make sure Tailscale is connected when remote."
|
||||
case .headscale:
|
||||
return "Self-hosted Tailscale control plane. Same client behaviour as Tailscale — keep the client connected when away."
|
||||
case .netbird:
|
||||
return "Open-source WireGuard mesh. Keep the NetBird app connected when off the LAN."
|
||||
case .zerotier:
|
||||
return "Managed network via ZeroTier. Keep the ZeroTier app joined to the network when remote."
|
||||
case .netmaker:
|
||||
return "Self-hosted WireGuard mesh. Keep your Netmaker client up when off the LAN."
|
||||
case .wireguard:
|
||||
return "Plain WireGuard tunnel. Enable the tunnel in the WireGuard app when remote."
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort URL scheme + App Store fallback used by "Open <provider>".
|
||||
/// iOS can't start the tunnel for us — this just brings the app forward.
|
||||
private var urlScheme: URL? {
|
||||
switch self {
|
||||
case .tailscale, .headscale: return URL(string: "tailscale://")
|
||||
case .zerotier: return URL(string: "zerotier://")
|
||||
case .wireguard: return URL(string: "wireguard://")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var appStoreURL: URL? {
|
||||
switch self {
|
||||
case .tailscale, .headscale: return URL(string: "https://apps.apple.com/app/id1470499037")
|
||||
case .zerotier: return URL(string: "https://apps.apple.com/app/id1084101492")
|
||||
case .wireguard: return URL(string: "https://apps.apple.com/app/id1441195209")
|
||||
case .netbird: return URL(string: "https://apps.apple.com/app/id6443155627")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var canOpenApp: Bool { urlScheme != nil || appStoreURL != nil }
|
||||
|
||||
/// Try the app's scheme; fall back to its App Store page.
|
||||
@MainActor
|
||||
func openApp() {
|
||||
let candidates = [urlScheme, appStoreURL].compactMap { $0 }
|
||||
func attempt(_ i: Int) {
|
||||
guard i < candidates.count else { return }
|
||||
UIApplication.shared.open(candidates[i]) { ok in
|
||||
if !ok { attempt(i + 1) }
|
||||
}
|
||||
}
|
||||
attempt(0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user