230 lines
8.2 KiB
Swift
230 lines
8.2 KiB
Swift
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 }
|
||
}
|
||
}
|