import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif private struct NativeSavedCookie: Codable { let name: String let value: String let expires: Date } /// Bounded streaming requests. Redirects never replay native session cookies. private final class NativeTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable { private struct Pending { let continuation: CheckedContinuation<(Data, HTTPURLResponse), Error> var data = Data() var response: HTTPURLResponse? } private let lock = NSLock() private var requests: [Int: Pending] = [:] func register(_ task: URLSessionDataTask, continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>) { lock.lock() requests[task.taskIdentifier] = Pending(continuation: continuation) lock.unlock() } func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { completionHandler(nil) } func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { guard let http = response as? HTTPURLResponse, response.expectedContentLength <= 4 * 1024 * 1024 else { completionHandler(.cancel) return } lock.lock() requests[dataTask.taskIdentifier]?.response = http lock.unlock() completionHandler(.allow) } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { lock.lock() let allowed = (requests[dataTask.taskIdentifier]?.data.count ?? 0) + data.count <= 4 * 1024 * 1024 if allowed { requests[dataTask.taskIdentifier]?.data.append(data) } lock.unlock() if !allowed { dataTask.cancel() } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { lock.lock() let pending = requests.removeValue(forKey: task.taskIdentifier) lock.unlock() guard let pending else { return } if error != nil { pending.continuation.resume(throwing: NativeAPIError.unavailable) } else if let response = pending.response { pending.continuation.resume(returning: (pending.data, response)) } else { pending.continuation.resume(throwing: NativeAPIError.invalidResponse) } } } actor NativeAPI { let server: ServerAddress private let transport: NativeTransport private let storage: HTTPCookieStorage private let persist: @Sendable (Data) throws -> Void private let session: URLSession private var csrf = "" private var closed = false init(server: ServerAddress, saved: Data?, persist: @escaping @Sendable (Data) throws -> Void) throws { self.server = server self.persist = persist let transport = NativeTransport() self.transport = transport let configuration = URLSessionConfiguration.ephemeral guard let storage = configuration.httpCookieStorage else { throw NativeAPIError.unavailable } self.storage = storage configuration.timeoutIntervalForRequest = 15 configuration.timeoutIntervalForResource = 30 configuration.urlCache = nil if let saved, let cookies = try? JSONDecoder().decode([NativeSavedCookie].self, from: saved) { for cookie in cookies where cookie.expires > Date() && ["yuvomi.sid", "csrf-token"].contains(cookie.name) { if let restored = HTTPCookie(properties: [ .domain: server.url.host ?? "", .path: "/", .secure: "TRUE", .name: cookie.name, .value: cookie.value, .expires: cookie.expires, ]) { storage.setCookie(restored) } } } session = URLSession(configuration: configuration, delegate: transport, delegateQueue: nil) } func close() { closed = true csrf = "" session.invalidateAndCancel() for cookie in storage.cookies ?? [] { storage.deleteCookie(cookie) } } func request( _ path: String, method: String = "GET", body: [String: NativeValue]? = nil, as type: Value.Type, retry: Bool = true ) async throws -> Value { guard !closed else { throw NativeAPIError.closed } guard path.hasPrefix("/"), !path.hasPrefix("//"), !path.contains("#"), !path.contains("\\"), let url = URL(string: server.url.absoluteString + "/api/v1" + path), server.owns(url) else { throw NativeAPIError.invalidPath } var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Accept") let mutation = ["POST", "PUT", "PATCH", "DELETE"].contains(method) if mutation { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(csrf, forHTTPHeaderField: "X-CSRF-Token") request.httpBody = try JSONEncoder().encode(body ?? [:]) } let (data, response) = try await withCheckedThrowingContinuation { continuation in let task = session.dataTask(with: request) transport.register(task, continuation: continuation) task.resume() } guard !closed else { throw NativeAPIError.closed } guard !(300...399).contains(response.statusCode) else { throw NativeAPIError.response(response.statusCode) } let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] if let token = response.value(forHTTPHeaderField: "X-CSRF-Token") ?? root?["csrfToken"] as? String { csrf = token } let cookies = (storage.cookies(for: server.url) ?? []).filter { $0.isSecure && ["yuvomi.sid", "csrf-token"].contains($0.name) && $0.path == "/" && $0.expiresDate != nil }.map { NativeSavedCookie(name: $0.name, value: $0.value, expires: $0.expiresDate!) } try persist(JSONEncoder().encode(cookies)) if response.statusCode == 403 && mutation && retry && root?["reason"] as? String == "csrf_invalid" { let _: NativeIdentity = try await self.request("/auth/me", as: NativeIdentity.self, retry: false) return try await self.request(path, method: method, body: body, as: type, retry: false) } guard (200...299).contains(response.statusCode) else { throw NativeAPIError.response(response.statusCode) } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase do { return try decoder.decode(type, from: data.isEmpty ? Data("{}".utf8) : data) } catch { throw NativeAPIError.invalidResponse } } }