Files
andros d8a6299600 Identify own messages reliably and tidy the Setup screen
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.
2026-05-07 10:01:15 +02:00

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
}
}