import Foundation import Combine /// Performs the Phase 0 backend reachability check: `GET {baseURL}/api/status`. /// /// This is a thin slice — the full `SparkControlClient` (label-merge, multipart, /// sequential queueing, retries) arrives in Phase 5. @MainActor final class SparkControlHealth: ObservableObject { enum Status: Equatable { case unknown case checking case online(String) case offline(String) } @Published private(set) var status: Status = .unknown @Published private(set) var lastChecked: Date? func check(baseURL: String, skipTLS: Bool) async { status = .checking let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed guard !base.isEmpty, let url = URL(string: base + "/api/status") else { status = .offline("Invalid host URL") return } let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 8 config.waitsForConnectivity = false let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate(allowedHost: url.host) : nil let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) defer { session.finishTasksAndInvalidate() } do { let (data, response) = try await session.data(from: url) lastChecked = Date() guard let http = response as? HTTPURLResponse else { status = .offline("No HTTP response") return } if (200..<300).contains(http.statusCode) { status = .online(Self.summarize(data) ?? "Reachable") } else { status = .offline("HTTP \(http.statusCode)") } } catch { lastChecked = Date() status = .offline(error.localizedDescription) } } /// Best-effort one-line summary of the `/api/status` body, if it's JSON. private static func summarize(_ data: Data) -> String? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } if let s = object["status"] as? String { return s } if let s = object["state"] as? String { return s } return "Reachable" } }