| 1 | import Foundation |
| 2 | |
| 3 | public struct NativeSession: Codable, Sendable { |
| 4 | public let name: String |
| 5 | public let value: String |
| 6 | public init(name: String, value: String) { self.name = name; self.value = value } |
| 7 | } |
| 8 | |
| 9 | private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { |
| 10 | func urlSession(_ session: URLSession, task: URLSessionTask, |
| 11 | willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, |
| 12 | completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } |
| 13 | } |
| 14 | |
| 15 | /// Ephemeral transport: no HTTP cache, shared cookie jar, or off-origin redirects. |
| 16 | public final class NativeHTTP: NativeTransport, @unchecked Sendable { |
| 17 | public let origin: URL |
| 18 | public let credential: NativeSession? |
| 19 | private let session: URLSession |
| 20 | private let observer: (@Sendable (NativeTransportOutcome) -> Void)? |
| 21 | |
| 22 | /// `observer` hears every response that arrives and every offline-class |
| 23 | /// failure, so connectivity can be learned from real traffic instead of a probe. |
| 24 | public init(origin: URL, credential: NativeSession? = nil, |
| 25 | observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) { |
| 26 | self.origin = origin |
| 27 | self.credential = credential |
| 28 | self.observer = observer |
| 29 | let config = URLSessionConfiguration.ephemeral |
| 30 | config.httpShouldSetCookies = false |
| 31 | config.urlCache = nil |
| 32 | config.timeoutIntervalForRequest = 30 |
| 33 | session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil) |
| 34 | } |
| 35 | |
| 36 | public static func origin(_ text: String) throws -> URL { |
| 37 | guard let c = URLComponents(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), |
| 38 | let host = c.host, !host.isEmpty, c.user == nil, c.password == nil, |
| 39 | c.query == nil, c.fragment == nil, c.path.isEmpty || c.path == "/", |
| 40 | c.scheme == "https" || (c.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)), |
| 41 | let url = c.url else { |
| 42 | throw NativeClientError.message("Enter your server’s HTTPS address, without a page path.") |
| 43 | } |
| 44 | var clean = c |
| 45 | clean.path = "" |
| 46 | return clean.url ?? url |
| 47 | } |
| 48 | |
| 49 | private func makeRequest(_ path: String, method: String, body: Data?, contentType: String) throws -> URLRequest { |
| 50 | guard path.hasPrefix("/"), !path.hasPrefix("//"), |
| 51 | let url = URL(string: path, relativeTo: origin)?.absoluteURL, |
| 52 | url.scheme == origin.scheme, url.host == origin.host, url.port == origin.port else { |
| 53 | throw NativeClientError.message("Invalid server path.") |
| 54 | } |
| 55 | var request = URLRequest(url: url) |
| 56 | request.httpMethod = method |
| 57 | request.httpBody = body |
| 58 | request.setValue(origin.absoluteString, forHTTPHeaderField: "Origin") |
| 59 | request.setValue("application/json", forHTTPHeaderField: "Accept") |
| 60 | if body != nil { request.setValue(contentType, forHTTPHeaderField: "Content-Type") } |
| 61 | if let credential { request.setValue("\(credential.name)=\(credential.value)", forHTTPHeaderField: "Cookie") } |
| 62 | return request |
| 63 | } |
| 64 | |
| 65 | /// A raw URLError used to escape from here, so "offline" could not be told |
| 66 | /// apart from any other failure. Every transport failure is typed on its way out. |
| 67 | private func typed(_ error: Error) -> Error { |
| 68 | let mapped = NativeClientError.classify(error) |
| 69 | if case NativeClientError.offline(let code) = mapped { observer?(.offline(code)) } |
| 70 | return mapped |
| 71 | } |
| 72 | |
| 73 | private func arrived(_ response: URLResponse) throws -> HTTPURLResponse { |
| 74 | guard let response = response as? HTTPURLResponse else { throw NativeClientError.invalidResponse } |
| 75 | observer?(.responded(status: response.statusCode)) |
| 76 | return response |
| 77 | } |
| 78 | |
| 79 | private static func headers(_ response: HTTPURLResponse) -> [String: String] { |
| 80 | var headers: [String: String] = [:] |
| 81 | for (key, value) in response.allHeaderFields { |
| 82 | if let key = key as? String, let value = value as? String { headers[key.lowercased()] = value } |
| 83 | } |
| 84 | return headers |
| 85 | } |
| 86 | |
| 87 | /// Reads the next byte, typing a connection lost mid-body as offline. The |
| 88 | /// consumer's own callbacks run outside this, so their errors pass untouched. |
| 89 | private func next(_ iterator: inout URLSession.AsyncBytes.AsyncIterator) async throws -> UInt8? { |
| 90 | do { return try await iterator.next() } catch { throw typed(error) } |
| 91 | } |
| 92 | |
| 93 | /// An explicit export to a new file, streamed by URLSession without retaining |
| 94 | /// the workbook/document in memory. The caller owns deletion after export or |
| 95 | /// cancellation of its save panel. Existing destinations are never replaced. |
| 96 | public func download(_ path: String, to destination: URL) async throws { |
| 97 | guard destination.isFileURL else { throw NativeClientError.message("Invalid export destination.") } |
| 98 | let request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") |
| 99 | let temporary: URL, received: URLResponse |
| 100 | do { (temporary, received) = try await session.download(for: request) } catch { throw typed(error) } |
| 101 | defer { try? FileManager.default.removeItem(at: temporary) } |
| 102 | try Task.checkCancellation() |
| 103 | let response = try arrived(received) |
| 104 | if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure } |
| 105 | guard response.statusCode == 200 else { |
| 106 | throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") |
| 107 | } |
| 108 | try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) |
| 109 | try FileManager.default.moveItem(at: temporary, to: destination) |
| 110 | } |
| 111 | |
| 112 | /// Exposes protocol cursors (for example Docs-Seq) without sharing a cookie jar. |
| 113 | /// Returns any status; see `NativeResponse.requireSuccess()`. |
| 114 | public func responseDetails(_ path: String, method: String, body: Data?, |
| 115 | contentType: String, maxBytes: Int) async throws -> NativeResponse { |
| 116 | guard maxBytes > 0, maxBytes <= 256 * 1024 * 1024 else { |
| 117 | throw NativeClientError.message("Invalid response size limit.") |
| 118 | } |
| 119 | let request = try makeRequest(path, method: method, body: body, contentType: contentType) |
| 120 | let bytes: URLSession.AsyncBytes, received: URLResponse |
| 121 | do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) } |
| 122 | defer { bytes.task.cancel() } |
| 123 | let response = try arrived(received) |
| 124 | var data = Data() |
| 125 | var iterator = bytes.makeAsyncIterator() |
| 126 | while let byte = try await next(&iterator) { |
| 127 | try Task.checkCancellation() |
| 128 | guard data.count < maxBytes else { throw NativeClientError.message("The server response is too large.") } |
| 129 | data.append(byte) |
| 130 | } |
| 131 | return NativeResponse(data: data, status: response.statusCode, headers: Self.headers(response)) |
| 132 | } |
| 133 | |
| 134 | /// Delivers bounded UTF-8 SSE lines, including blank event delimiters. Callers |
| 135 | /// own event parsing and reconnect cursors; a clean EOF is never a saved edit. |
| 136 | public func stream(_ path: String, onOpen: @Sendable () async throws -> Void, |
| 137 | onLine: @Sendable (String) async throws -> Void) async throws { |
| 138 | var request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") |
| 139 | request.setValue("text/event-stream", forHTTPHeaderField: "Accept") |
| 140 | let bytes: URLSession.AsyncBytes, received: URLResponse |
| 141 | do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) } |
| 142 | defer { bytes.task.cancel() } |
| 143 | let response = try arrived(received) |
| 144 | // A 401 on the stream is an ended session, not a generic refusal: the |
| 145 | // app must not advise signing out while it holds unsent edits. |
| 146 | if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure } |
| 147 | guard response.statusCode == 200, |
| 148 | response.value(forHTTPHeaderField: "Content-Type")?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() == "text/event-stream" else { |
| 149 | throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.") |
| 150 | } |
| 151 | // A server that subscribes before flushing headers lets this callback fetch |
| 152 | // a snapshot without a subscribe-after-snapshot gap. Lines remain buffered |
| 153 | // until the callback completes; consumers reconcile their version cursors. |
| 154 | try Task.checkCancellation() |
| 155 | try await onOpen() |
| 156 | var parser = NativeStreamLines() |
| 157 | var iterator = bytes.makeAsyncIterator() |
| 158 | while let byte = try await next(&iterator) { |
| 159 | try Task.checkCancellation() |
| 160 | if let line = try parser.append(byte) { try await onLine(line) } |
| 161 | } |
| 162 | // An unterminated event is not delivered at EOF; the next snapshot is the |
| 163 | // authority after reconnection, rather than an incomplete network frame. |
| 164 | } |
| 165 | } |
| 166 | |