import Foundation enum APIError: LocalizedError { case notConfigured case invalidURL case unauthorized case http(Int, String?) case decoding(Error) case transport(Error) var errorDescription: String? { switch self { case .notConfigured: return "Server is not configured." case .invalidURL: return "Invalid server URL." case .unauthorized: return "Invalid token (HTTP 401)." case .http(let code, let msg): if let msg, !msg.isEmpty { return "HTTP \(code): \(msg)" } return "HTTP \(code)" case .decoding(let err): return "Decoding error: \(err.localizedDescription)" case .transport(let err): return err.localizedDescription } } } @Observable final class APIClient { private let settings: Settings private let session: URLSession private let overrideConfig: ServerConfig? init(settings: Settings, config: ServerConfig? = nil) { self.settings = settings self.overrideConfig = config let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 15 cfg.waitsForConnectivity = false self.session = URLSession(configuration: cfg) } private var activeConfig: ServerConfig { overrideConfig ?? settings.config } private func makeRequest(_ method: String, path: String, query: [URLQueryItem] = [], body: Data? = nil) throws -> URLRequest { let conf = activeConfig guard conf.isConfigured else { throw APIError.notConfigured } guard let base = conf.baseURL, var comps = URLComponents(url: base, resolvingAgainstBaseURL: false) else { throw APIError.invalidURL } comps.path = path if !query.isEmpty { comps.queryItems = query } guard let url = comps.url else { throw APIError.invalidURL } var req = URLRequest(url: url) req.httpMethod = method req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("Bearer \(conf.token)", forHTTPHeaderField: "Authorization") if let body { req.httpBody = body } return req } private func send(_ req: URLRequest, as: T.Type) async throws -> T { let data: Data let response: URLResponse do { (data, response) = try await session.data(for: req) } catch { throw APIError.transport(error) } guard let http = response as? HTTPURLResponse else { throw APIError.http(0, nil) } if http.statusCode == 401 || http.statusCode == 403 { throw APIError.unauthorized } if !(200..<300).contains(http.statusCode) { let text = String(data: data, encoding: .utf8) throw APIError.http(http.statusCode, text) } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw APIError.decoding(error) } } // MARK: - Endpoints func fetchStatus() async throws -> ServerStatus { // MeshMonitor v4 split status into two endpoints: the legacy // /api/status (version, uptime, statistics) and /api/v1/status // (data.localNodeId / data.localNodeNum). Fetch both and merge so // the chat can identify self-messages even when the legacy reply // carries connection.localNode = null. async let v1: ServerStatus? = optionalStatus(path: "/api/v1/status") async let legacy: ServerStatus? = optionalStatus(path: "/api/status") let (a, b) = await (v1, legacy) if a == nil && b == nil { let req = try makeRequest("GET", path: "/api/v1/status") return try await send(req, as: ServerStatus.self) } return ServerStatus( version: a?.version ?? b?.version, uptime: a?.uptime ?? b?.uptime, connection: a?.connection ?? b?.connection, statistics: a?.statistics ?? b?.statistics, data: a?.data ?? b?.data ) } private func optionalStatus(path: String) async -> ServerStatus? { do { let req = try makeRequest("GET", path: path) return try await send(req, as: ServerStatus.self) } catch { return nil } } func fetchChannels() async throws -> [Channel] { let req = try makeRequest("GET", path: "/api/v1/channels") let r: ChannelListResponse = try await send(req, as: ChannelListResponse.self) return r.data } func fetchNodes() async throws -> [Node] { let req = try makeRequest("GET", path: "/api/v1/nodes") let r: NodeListResponse = try await send(req, as: NodeListResponse.self) return r.data } func fetchMessages(channel: Int, limit: Int = 50, since: Date? = nil) async throws -> [Message] { try await fetchMessages(extra: [.init(name: "channel", value: String(channel))], limit: limit, since: since) } func fetchDMMessages(nodeId: String, limit: Int = 50, since: Date? = nil) async throws -> [Message] { async let outgoing = fetchMessages(extra: [.init(name: "toNodeId", value: nodeId)], limit: limit, since: since) async let incoming = fetchMessages(extra: [.init(name: "fromNodeId", value: nodeId)], limit: limit, since: since) let (out, inc) = try await (outgoing, incoming) return out + inc } func fetchAllMessages(limit: Int = 200) async throws -> [Message] { try await fetchMessages(extra: [], limit: limit, since: nil) } private func fetchMessages(extra: [URLQueryItem], limit: Int, since: Date?) async throws -> [Message] { var q = extra q.append(.init(name: "limit", value: String(limit))) if let since { q.append(.init(name: "since", value: String(Int(since.timeIntervalSince1970) + 1))) } let req = try makeRequest("GET", path: "/api/v1/messages", query: q) let r: MessageListResponse = try await send(req, as: MessageListResponse.self) return r.data } @discardableResult func sendChannelMessage(text: String, channel: Int, replyId: Int? = nil) async throws -> SendMessageData? { var body: [String: Any] = ["text": text, "channel": channel] if let replyId { body["replyId"] = replyId } return try await postMessage(body) } @discardableResult func sendDM(text: String, toNodeId: String, replyId: Int? = nil) async throws -> SendMessageData? { var body: [String: Any] = ["text": text, "toNodeId": toNodeId] if let replyId { body["replyId"] = replyId } return try await postMessage(body) } private func postMessage(_ body: [String: Any]) async throws -> SendMessageData? { let data = try JSONSerialization.data(withJSONObject: body) let req = try makeRequest("POST", path: "/api/v1/messages", body: data) let r: SendMessageResponse = try await send(req, as: SendMessageResponse.self) return r.data } }