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:
@@ -1,222 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// A benign URLSession cancellation (-999), e.g. a request superseded or
|
||||
/// dropped on a network-path change. Should not be shown or wipe state.
|
||||
var isCancelled: Bool {
|
||||
if case .networkError(let e) = self { return (e as? URLError)?.code == .cancelled }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private struct APIErrorResponse: Decodable {
|
||||
struct Inner: Decodable { let code, message: String; let status: Int }
|
||||
let error: Inner
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
actor APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
private var baseURL: URL?
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let deviceId: String
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 10
|
||||
self.session = URLSession(configuration: config)
|
||||
self.deviceId = APIClient.loadDeviceId()
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
}
|
||||
|
||||
private static func loadDeviceId() -> String {
|
||||
let key = "jarvis.device.id"
|
||||
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let value = UUID().uuidString.lowercased()
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
return value
|
||||
}
|
||||
|
||||
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,
|
||||
tags: Set<String> = [],
|
||||
minSignal: Int? = nil,
|
||||
limit: Int = 20,
|
||||
includeRead: Bool = false
|
||||
) async throws -> PaginatedStories {
|
||||
var params: [String: String] = ["limit": "\(limit)", "include_read": includeRead ? "true" : "false"]
|
||||
if let cursor { params["after"] = cursor }
|
||||
if let topic { params["topic"] = topic }
|
||||
if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") }
|
||||
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)")
|
||||
}
|
||||
|
||||
func markStoryRead(id: String) async throws {
|
||||
try await postVoid("/stories/\(id)/read", body: [
|
||||
"device_id": deviceId,
|
||||
"read_at": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "ios",
|
||||
])
|
||||
}
|
||||
|
||||
func markStoryUnread(id: String) async throws {
|
||||
try await delete("/stories/\(id)/read")
|
||||
}
|
||||
|
||||
func markAllRead(storyIds: [String], pill: String) async throws {
|
||||
try await postVoid("/me/read/bulk", body: [
|
||||
"storyIds": storyIds,
|
||||
"readAt": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "mark_all_as_read",
|
||||
"pill": pill,
|
||||
"deviceId": deviceId,
|
||||
])
|
||||
}
|
||||
|
||||
// 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"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
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(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func postVoid(_ path: String, body: [String: Any]) async throws {
|
||||
guard let base = baseURL else { throw APIError.notConnected }
|
||||
let url = base.appendingPathComponent(path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (_, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
throw APIError.serverError(code: "post_failed", message: "Post failed", status: 500)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// 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() {
|
||||
// Capture task locally so the Sendable receive closure doesn't reference
|
||||
// the @MainActor-isolated property directly.
|
||||
guard let t = task else { return }
|
||||
t.receive { [weak self] result in
|
||||
switch result {
|
||||
case .failure:
|
||||
Task { @MainActor [weak self] in self?.handleDisconnect() }
|
||||
case .success(let message):
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
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
|
||||
// Hop to MainActor before touching the isolated `task` property.
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.task?.sendPing { [weak self] error in
|
||||
if error != nil {
|
||||
Task { @MainActor [weak self] 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 { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { self?.openConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user