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,159 @@
// APIClient.swift
// Jarvis REST networking layer
import Foundation
enum APIError: Error, LocalizedError {
case invalidURL
case serverError(code: String, message: String, status: Int)
case decodingError(Error)
case networkError(Error)
case notConnected
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid server URL"
case .notConnected: return "Not connected to a Jarvis server"
case .serverError(_, let msg, _): return msg
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
case .networkError(let e): return e.localizedDescription
}
}
}
private struct APIErrorResponse: Decodable {
struct Inner: Decodable { let code, message: String; let status: Int }
let error: Inner
}
actor APIClient {
static let shared = APIClient()
private var baseURL: URL?
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
self.session = URLSession(configuration: config)
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func configure(host: String) throws {
guard let url = URL(string: "http://\(host)/api/v1") else {
throw APIError.invalidURL
}
self.baseURL = url
}
// MARK: - Health
func checkHealth() async throws -> ServerHealth {
try await get("/health")
}
// MARK: - Stories
func fetchStories(
cursor: String? = nil,
topic: String? = nil,
minSignal: Int? = nil,
limit: Int = 20
) async throws -> PaginatedStories {
var params: [String: String] = ["limit": "\(limit)"]
if let cursor { params["after"] = cursor }
if let topic { params["topic"] = topic }
if let minSignal { params["min_signal"] = "\(minSignal)" }
return try await get("/stories", params: params)
}
func fetchStory(id: String) async throws -> StoryDetail {
try await get("/stories/\(id)")
}
// MARK: - Articles
func fetchArticle(id: String) async throws -> Article {
try await get("/articles/\(id)")
}
// MARK: - Feeds
func fetchFeeds() async throws -> [Feed] {
struct FeedsResponse: Decodable { let data: [Feed] }
let response: FeedsResponse = try await get("/feeds")
return response.data
}
func addFeed(url: String, name: String, pollInterval: Int = 900) async throws -> Feed {
let body = ["url": url, "name": name, "pollIntervalSeconds": pollInterval] as [String: Any]
return try await post("/feeds", body: body)
}
func deleteFeed(id: String) async throws {
try await delete("/feeds/\(id)")
}
// MARK: - Private helpers
private func get<T: Decodable>(_ path: String, params: [String: String] = [:]) async throws -> T {
guard let base = baseURL else { throw APIError.notConnected }
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
if !params.isEmpty {
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
return try await execute(request)
}
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
guard let base = baseURL else { throw APIError.notConnected }
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await execute(request)
}
private func delete(_ path: String) async throws {
guard let base = baseURL else { throw APIError.notConnected }
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
}
}
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
if !(200...299).contains(http.statusCode) {
if let err = try? decoder.decode(APIErrorResponse.self, from: data) {
throw APIError.serverError(code: err.error.code, message: err.error.message, status: err.error.status)
}
throw APIError.serverError(code: "unknown", message: "HTTP \(http.statusCode)", status: http.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
} catch let error as APIError {
throw error
} catch {
throw APIError.networkError(error)
}
}
}

View File

@@ -0,0 +1,128 @@
// WebSocketManager.swift
// Jarvis WebSocket connection, event parsing, reconnect with backoff
import Foundation
import Combine
enum ConnectionState {
case disconnected
case connecting
case connected
case reconnecting(attempt: Int)
}
@MainActor
final class WebSocketManager: ObservableObject {
static let shared = WebSocketManager()
@Published var connectionState: ConnectionState = .disconnected
let events = PassthroughSubject<WSEvent, Never>()
private var task: URLSessionWebSocketTask?
private var pingTimer: Timer?
private var reconnectTask: Task<Void, Never>?
private var host: String?
private let maxBackoff: TimeInterval = 60
private var attempt = 0
private init() {}
func connect(host: String) {
self.host = host
self.attempt = 0
openConnection()
}
func disconnect() {
reconnectTask?.cancel()
pingTimer?.invalidate()
task?.cancel(with: .goingAway, reason: nil)
task = nil
connectionState = .disconnected
}
// MARK: - Private
private func openConnection() {
guard let host, let url = URL(string: "ws://\(host)/ws") else { return }
connectionState = attempt == 0 ? .connecting : .reconnecting(attempt: attempt)
let session = URLSession(configuration: .default)
task = session.webSocketTask(with: url)
task?.resume()
connectionState = .connected
attempt = 0
startPing()
listen()
}
private func listen() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure:
Task { @MainActor in self.handleDisconnect() }
case .success(let message):
Task { @MainActor in
self.handle(message: message)
self.listen()
}
}
}
}
private func handle(message: URLSessionWebSocketTask.Message) {
guard case .string(let text) = message,
let data = text.data(using: .utf8) else { return }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let event = try? decoder.decode(WSEvent.self, from: data) else { return }
if event.type == .ping {
sendPong()
return
}
events.send(event)
}
private func sendPong() {
task?.send(.string(#"{"type":"pong"}"#)) { _ in }
}
private func startPing() {
pingTimer?.invalidate()
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.task?.sendPing { error in
if error != nil {
Task { @MainActor in self?.handleDisconnect() }
}
}
}
}
private func handleDisconnect() {
pingTimer?.invalidate()
task = nil
connectionState = .disconnected
scheduleReconnect()
}
private func scheduleReconnect() {
attempt += 1
let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff)
connectionState = .reconnecting(attempt: attempt)
reconnectTask = Task {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
await MainActor.run { self.openConnection() }
}
}
}