iOS new: ApiClient.swift

This commit is contained in:
efir369999 2026-05-05 17:16:12 +03:00
parent dfed314ac3
commit e025f23bcf

View File

@ -0,0 +1,229 @@
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 }
}
}