Files
Yuvomi-App/ios/Sources/NativeAPI.swift
Yuvomi Mobile Contributors 97570b7fdf feat(ios): replace embedded website with initial native SwiftUI API client
Introduce real SwiftUI auth/dashboard/task/note/shopping screens, a bounded origin-bound URLSession transport and Keychain-backed native sessions. Archive the rejected WebKit renderer rather than embedding it for unfinished modules. Sixteen portable URL/contract/transport tests and Swift format/parsing pass on Linux; Apple SDK application and UI validation remains mandatory. This is an incomplete native slice, not full PWA parity or a release.
2026-10-21 08:57:31 +02:00

152 lines
6.9 KiB
Swift

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<Value: Decodable & Sendable>(
_ 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
}
}
}