import Foundation import Combine import CryptoKit enum MontanaAPI { static let baseURL = URL(string: "https://mess.montana.quest:8443/api")! static let wsURL = URL(string: "wss://mess.montana.quest:8443/api/ws")! } struct ApiAccount: Decodable { let account_id: String let name: String let ed_pub: String let x_pub: String let created_at: Int } struct ApiEnvelope: Decodable, Identifiable { let id: Int let from: String let to: String let nonce: String let ciphertext: String let sent_at: Int } struct ApiInbox: Decodable { let messages: [ApiEnvelope] } enum ApiError: LocalizedError { case http(Int, String) case bad case noIdentity var errorDescription: String? { switch self { case .http(let c, let m): return "HTTP \(c): \(m)" case .bad: return "Bad response" case .noIdentity: return "Не создана идентичность" } } } @MainActor final class ApiClient { static let shared = ApiClient() private let session: URLSession init() { let cfg = URLSessionConfiguration.default cfg.waitsForConnectivity = true cfg.timeoutIntervalForRequest = 20 self.session = URLSession(configuration: cfg) } func register(name: String, identity: Identity) async throws { let body: [String: String] = [ "name": name, "ed_pub": identity.edPublicKeyB64, "x_pub": identity.xPublicKeyB64, ] let url = MontanaAPI.baseURL.appendingPathComponent("accounts") var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(body) let (_, resp) = try await session.data(for: req) try Self.checkOK(resp) } func getAccount(_ accountID: String) async throws -> ApiAccount { let url = MontanaAPI.baseURL.appendingPathComponent("accounts").appendingPathComponent(accountID) let (data, resp) = try await session.data(from: url) try Self.checkOK(resp) return try JSONDecoder().decode(ApiAccount.self, from: data) } func send(to: String, nonce: String, ciphertext: String, identity: Identity) async throws -> ApiEnvelope { let payload: [String: String] = ["to": to, "nonce": nonce, "ciphertext": ciphertext] let body = try Self.canonicalJSON(payload) let ts = String(Int(Date().timeIntervalSince1970)) let sig = try identity.signingKey.signature(for: Data((ts + "\n").utf8) + body) var req = URLRequest(url: MontanaAPI.baseURL.appendingPathComponent("messages/send")) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue(identity.accountID, forHTTPHeaderField: "X-Montana-Account") req.setValue("\(ts):\(sig.b64uNoPad)", forHTTPHeaderField: "X-Montana-Auth") req.httpBody = body let (data, resp) = try await session.data(for: req) try Self.checkOK(resp) return try JSONDecoder().decode(ApiEnvelope.self, from: data) } func inbox(since: Int, identity: Identity) async throws -> [ApiEnvelope] { let ts = String(Int(Date().timeIntervalSince1970)) let body = Data("since=\(since)".utf8) let sig = try identity.signingKey.signature(for: Data((ts + "\n").utf8) + body) var comps = URLComponents(url: MontanaAPI.baseURL.appendingPathComponent("messages/inbox"), resolvingAgainstBaseURL: false)! comps.queryItems = [URLQueryItem(name: "since", value: String(since))] var req = URLRequest(url: comps.url!) req.setValue(identity.accountID, forHTTPHeaderField: "X-Montana-Account") req.setValue("\(ts):\(sig.b64uNoPad)", forHTTPHeaderField: "X-Montana-Auth") let (data, resp) = try await session.data(for: req) try Self.checkOK(resp) return try JSONDecoder().decode(ApiInbox.self, from: data).messages } private static func checkOK(_ resp: URLResponse) throws { guard let h = resp as? HTTPURLResponse else { throw ApiError.bad } if !(200...299).contains(h.statusCode) { throw ApiError.http(h.statusCode, h.url?.absoluteString ?? "") } } static func canonicalJSON(_ dict: [String: String]) throws -> Data { let sorted = dict.sorted { $0.key < $1.key } var s = "{" for (i, (k, v)) in sorted.enumerated() { if i > 0 { s += "," } s += "\"\(k)\":\"\(escapeJSON(v))\"" } s += "}" return Data(s.utf8) } private static func escapeJSON(_ s: String) -> String { var out = "" for c in s { switch c { case "\\": out += "\\\\" case "\"": out += "\\\"" case "\n": out += "\\n" case "\r": out += "\\r" case "\t": out += "\\t" case "\u{08}": out += "\\b" case "\u{0C}": out += "\\f" default: if c.asciiValue != nil && c.asciiValue! < 0x20 { out += String(format: "\\u%04x", c.asciiValue!) } else { out.append(c) } } } return out } } @MainActor final class WebSocketLink: NSObject, ObservableObject, URLSessionWebSocketDelegate { static let shared = WebSocketLink() @Published private(set) var isConnected = false private var task: URLSessionWebSocketTask? private var pingTimer: Timer? var onEnvelope: ((ApiEnvelope) -> Void)? func connect(identity: Identity) { disconnect() let cfg = URLSessionConfiguration.default let session = URLSession(configuration: cfg, delegate: self, delegateQueue: .main) let task = session.webSocketTask(with: MontanaAPI.wsURL) self.task = task task.resume() let ts = String(Int(Date().timeIntervalSince1970)) do { let sig = try identity.signingKey.signature(for: Data((ts + "\nws").utf8)) let hello = ["account_id": identity.accountID, "ts": ts, "sig": sig.b64uNoPad] let helloData = try JSONSerialization.data(withJSONObject: hello) let helloStr = String(data: helloData, encoding: .utf8) ?? "" task.send(.string(helloStr)) { [weak self] err in if err != nil { self?.disconnect() } } receive() startPing() } catch { disconnect() } } func disconnect() { task?.cancel(with: .normalClosure, reason: nil) task = nil pingTimer?.invalidate() pingTimer = nil isConnected = false } private func receive() { task?.receive { [weak self] result in guard let self else { return } DispatchQueue.main.async { switch result { case .success(let m): if case .string(let s) = m, let data = s.data(using: .utf8) { if let env = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { self.onEnvelope?(env) } else if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], dict["hello"] != nil { self.isConnected = true } } self.receive() case .failure: self.isConnected = false } } } } private func startPing() { pingTimer?.invalidate() pingTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in self?.task?.send(.string("ping")) { _ in } } } nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { Task { @MainActor in self.isConnected = true } } nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { Task { @MainActor in self.isConnected = false } } }