d8a6299600
Recognise own messages by id (prefix and case insensitive) or by node number, so chat bubbles align right with the accent colour even when the message only carries fromNodeNum. Fetch /api/v1/status and /api/status in parallel and merge them, since MeshMonitor 4 dropped localNode from the legacy endpoint. Add a "How to create a token" link, plus Source code and Report a bug links to the Settings screen. Default the server port to 8080 (MeshMonitor's Docker default). Add PRIVACY.md, refresh the README and ignore CLAUDE.md.
191 lines
6.3 KiB
Swift
191 lines
6.3 KiB
Swift
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<T: Decodable>(_ 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
|
|
}
|
|
}
|