import Foundation public struct NativeSession: Codable, Sendable { public let name: String public let value: String public init(name: String, value: String) { self.name = name; self.value = value } } private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } } /// Ephemeral transport: no HTTP cache, shared cookie jar, or off-origin redirects. public final class NativeHTTP: NativeTransport, @unchecked Sendable { public let origin: URL public let credential: NativeSession? private let session: URLSession private let observer: (@Sendable (NativeTransportOutcome) -> Void)? /// `observer` hears every response that arrives and every offline-class /// failure, so connectivity can be learned from real traffic instead of a probe. public init(origin: URL, credential: NativeSession? = nil, observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) { self.origin = origin self.credential = credential self.observer = observer let config = URLSessionConfiguration.ephemeral config.httpShouldSetCookies = false config.urlCache = nil config.timeoutIntervalForRequest = 30 session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil) } public static func origin(_ text: String) throws -> URL { guard let c = URLComponents(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), let host = c.host, !host.isEmpty, c.user == nil, c.password == nil, c.query == nil, c.fragment == nil, c.path.isEmpty || c.path == "/", c.scheme == "https" || (c.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)), let url = c.url else { throw NativeClientError.message("Enter your server’s HTTPS address, without a page path.") } var clean = c clean.path = "" return clean.url ?? url } private func makeRequest(_ path: String, method: String, body: Data?, contentType: String) throws -> URLRequest { guard path.hasPrefix("/"), !path.hasPrefix("//"), let url = URL(string: path, relativeTo: origin)?.absoluteURL, url.scheme == origin.scheme, url.host == origin.host, url.port == origin.port else { throw NativeClientError.message("Invalid server path.") } var request = URLRequest(url: url) request.httpMethod = method request.httpBody = body request.setValue(origin.absoluteString, forHTTPHeaderField: "Origin") request.setValue("application/json", forHTTPHeaderField: "Accept") if body != nil { request.setValue(contentType, forHTTPHeaderField: "Content-Type") } if let credential { request.setValue("\(credential.name)=\(credential.value)", forHTTPHeaderField: "Cookie") } return request } /// A raw URLError used to escape from here, so "offline" could not be told /// apart from any other failure. Every transport failure is typed on its way out. private func typed(_ error: Error) -> Error { let mapped = NativeClientError.classify(error) if case NativeClientError.offline(let code) = mapped { observer?(.offline(code)) } return mapped } private func arrived(_ response: URLResponse) throws -> HTTPURLResponse { guard let response = response as? HTTPURLResponse else { throw NativeClientError.invalidResponse } observer?(.responded(status: response.statusCode)) return response } private static func headers(_ response: HTTPURLResponse) -> [String: String] { var headers: [String: String] = [:] for (key, value) in response.allHeaderFields { if let key = key as? String, let value = value as? String { headers[key.lowercased()] = value } } return headers } /// Reads the next byte, typing a connection lost mid-body as offline. The /// consumer's own callbacks run outside this, so their errors pass untouched. private func next(_ iterator: inout URLSession.AsyncBytes.AsyncIterator) async throws -> UInt8? { do { return try await iterator.next() } catch { throw typed(error) } } /// An explicit export to a new file, streamed by URLSession without retaining /// the workbook/document in memory. The caller owns deletion after export or /// cancellation of its save panel. Existing destinations are never replaced. public func download(_ path: String, to destination: URL) async throws { guard destination.isFileURL else { throw NativeClientError.message("Invalid export destination.") } let request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") let temporary: URL, received: URLResponse do { (temporary, received) = try await session.download(for: request) } catch { throw typed(error) } defer { try? FileManager.default.removeItem(at: temporary) } try Task.checkCancellation() let response = try arrived(received) if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure } guard response.statusCode == 200 else { throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") } try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) try FileManager.default.moveItem(at: temporary, to: destination) } /// Exposes protocol cursors (for example Docs-Seq) without sharing a cookie jar. /// Returns any status; see `NativeResponse.requireSuccess()`. public func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse { guard maxBytes > 0, maxBytes <= 256 * 1024 * 1024 else { throw NativeClientError.message("Invalid response size limit.") } let request = try makeRequest(path, method: method, body: body, contentType: contentType) let bytes: URLSession.AsyncBytes, received: URLResponse do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) } defer { bytes.task.cancel() } let response = try arrived(received) var data = Data() var iterator = bytes.makeAsyncIterator() while let byte = try await next(&iterator) { try Task.checkCancellation() guard data.count < maxBytes else { throw NativeClientError.message("The server response is too large.") } data.append(byte) } return NativeResponse(data: data, status: response.statusCode, headers: Self.headers(response)) } /// Delivers bounded UTF-8 SSE lines, including blank event delimiters. Callers /// own event parsing and reconnect cursors; a clean EOF is never a saved edit. public func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws { var request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") let bytes: URLSession.AsyncBytes, received: URLResponse do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) } defer { bytes.task.cancel() } let response = try arrived(received) // A 401 on the stream is an ended session, not a generic refusal: the // app must not advise signing out while it holds unsent edits. if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure } guard response.statusCode == 200, response.value(forHTTPHeaderField: "Content-Type")?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() == "text/event-stream" else { throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.") } // A server that subscribes before flushing headers lets this callback fetch // a snapshot without a subscribe-after-snapshot gap. Lines remain buffered // until the callback completes; consumers reconcile their version cursors. try Task.checkCancellation() try await onOpen() var parser = NativeStreamLines() var iterator = bytes.makeAsyncIterator() while let byte = try await next(&iterator) { try Task.checkCancellation() if let line = try parser.append(byte) { try await onLine(line) } } // An unterminated event is not delivered at EOF; the next snapshot is the // authority after reconnection, rather than an incomplete network frame. } }