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:
Robin Kutesa
2026-06-18 18:04:59 +03:00
commit 27d0e22767
45 changed files with 4913 additions and 0 deletions

View 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)
}
}

View 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 }
}

View 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)
}
}