SHA256
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.
152 lines
6.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|