diff --git a/apps/native/host/ios/src/bridge.rs b/apps/native/host/ios/src/bridge.rs index df486038b..b9cd163af 100644 --- a/apps/native/host/ios/src/bridge.rs +++ b/apps/native/host/ios/src/bridge.rs @@ -123,6 +123,16 @@ fn to_worker(text: &str) { }); } +/// A host reply to one of the worker's requests (an object, not a protocol line). +pub(crate) fn reply(message: &serde_json::Value) { + let script = format!("__hazel.reply({message})"); + WEB.with_borrow(|web| { + if let Some(web) = web { + evaluate(web, &script); + } + }); +} + fn json_string(text: &str) -> String { serde_json::Value::String(text.to_string()).to_string() } @@ -203,6 +213,8 @@ fn request(mtm: MainThreadMarker, body: &NSDictionary) { let app = UIApplication::sharedApplication(mtm); unsafe { app.openURL_options_completionHandler(&url, &NSDictionary::new(), None) }; } + } else if let Some(text) = field("fetch") { + crate::http::send(&text); } else if let Some(text) = field("copy") { unsafe { UIPasteboard::generalPasteboard().setString(Some(&NSString::from_str(&text))) }; } diff --git a/apps/native/host/ios/src/http.rs b/apps/native/host/ios/src/http.rs new file mode 100644 index 000000000..9400087c7 --- /dev/null +++ b/apps/native/host/ios/src/http.rs @@ -0,0 +1,99 @@ +//! Requests the runtime sends as the native app (`x-auth-mode: native`: sign-in and the session), +//! made with the system's URL loading instead of the page's `fetch`. The API admits a native +//! request only without `Origin`, `Sec-Fetch-Mode` and cookies, which WebKit adds to every fetch +//! from a page and its workers and no script can remove; `NSURLSession` adds none of them. +use dispatch2::DispatchQueue; +use objc2::rc::Retained; +use objc2_foundation::{ + NSData, NSError, NSHTTPURLResponse, NSMutableURLRequest, NSString, NSURL, NSURLResponse, + NSURLSession, NSURLSessionConfiguration, +}; +use serde_json::{Map, Value, json}; + +use crate::bridge::{log, reply}; + +/// Sends request `text` (`{ id, url, method, headers, body }` from `src/ios.ts`); the response, +/// or why there is none, goes back to the worker as `{ id, status, headers, body }` or +/// `{ id, error }`. +pub fn send(text: &str) { + let Ok(request) = serde_json::from_str::(text) else { + return log("[host] fetch: not a request"); + }; + let Some(id) = request["id"].as_u64() else { + return log("[host] fetch: a request without an id"); + }; + let answer = move |message: Value| { + DispatchQueue::main().exec_async(move || reply(&message)); + }; + let Some(url) = request["url"] + .as_str() + .and_then(|url| NSURL::URLWithString(&NSString::from_str(url))) + else { + return answer(json!({ "id": id, "error": "not a URL" })); + }; + + let outgoing = NSMutableURLRequest::requestWithURL(&url); + outgoing.setHTTPMethod(&NSString::from_str( + request["method"].as_str().unwrap_or("GET"), + )); + outgoing.setHTTPShouldHandleCookies(false); + if let Some(headers) = request["headers"].as_object() { + for (name, value) in headers { + if let Some(value) = value.as_str() { + outgoing.setValue_forHTTPHeaderField( + Some(&NSString::from_str(value)), + &NSString::from_str(name), + ); + } + } + } + if let Some(body) = request["body"].as_str() { + outgoing.setHTTPBody(Some(&NSData::with_bytes(body.as_bytes()))); + } + + // Ephemeral and cookieless: nothing the request did not carry itself. + let configuration = NSURLSessionConfiguration::ephemeralSessionConfiguration(); + configuration.setHTTPShouldSetCookies(false); + configuration.setHTTPCookieStorage(None); + let session: Retained = NSURLSession::sessionWithConfiguration(&configuration); + + let handler = block2::RcBlock::new( + move |data: *mut NSData, response: *mut NSURLResponse, error: *mut NSError| { + // SAFETY: the session passes valid objects or null, alive for the call. + let (data, response, error) = + unsafe { (data.as_ref(), response.as_ref(), error.as_ref()) }; + let http = response.and_then(|r| r.downcast_ref::()); + answer(match (error, http) { + (Some(error), _) => { + json!({ "id": id, "error": error.localizedDescription().to_string() }) + } + (None, None) => json!({ "id": id, "error": "not an HTTP response" }), + (None, Some(http)) => { + let fields = http.allHeaderFields(); + let mut headers = Map::new(); + for key in fields.allKeys() { + let value = fields.objectForKey(&key); + if let (Some(name), Some(value)) = ( + key.downcast_ref::(), + value.as_deref().and_then(|v| v.downcast_ref::()), + ) { + headers.insert(name.to_string(), Value::String(value.to_string())); + } + } + let body = data.map(|d| String::from_utf8_lossy(&d.to_vec()).into_owned()); + json!({ + "id": id, + "status": http.statusCode(), + "headers": headers, + "body": body.unwrap_or_default(), + }) + } + }); + }, + ); + // SAFETY: the session copies the handler, which owns everything it captures. + let task = unsafe { session.dataTaskWithRequest_completionHandler(&outgoing, &handler) }; + task.resume(); + // The session lets its one task finish, then lets go of its delegate queue. + session.finishTasksAndInvalidate(); +} diff --git a/apps/native/host/ios/src/main.rs b/apps/native/host/ios/src/main.rs index ccbeccac6..d83931595 100644 --- a/apps/native/host/ios/src/main.rs +++ b/apps/native/host/ios/src/main.rs @@ -3,6 +3,8 @@ #[cfg(target_os = "ios")] mod bridge; #[cfg(target_os = "ios")] +mod http; +#[cfg(target_os = "ios")] mod keyboard; #[cfg(any(test, target_os = "ios"))] mod page; diff --git a/apps/native/host/ios/src/page.rs b/apps/native/host/ios/src/page.rs index c0e19b9d1..256696f11 100644 --- a/apps/native/host/ios/src/page.rs +++ b/apps/native/host/ios/src/page.rs @@ -97,6 +97,9 @@ window.__hazel = {{ host.postMessage({{ log: `[worker] ${{event.message}} ${{event.filename}}:${{event.lineno}}` }}) for (const line of early.splice(0)) worker.postMessage(line) }}, + reply(message) {{ + worker?.postMessage(message) + }}, toWorker(text) {{ for (const line of text.split("\n")) if (line !== "") worker === undefined ? early.push(line) : worker.postMessage(line) diff --git a/apps/native/src/ios.ts b/apps/native/src/ios.ts index 50e5a6908..66430bd84 100644 --- a/apps/native/src/ios.ts +++ b/apps/native/src/ios.ts @@ -3,7 +3,7 @@ * WebKit's JIT), and its page relays between the worker and the Rune host (`host/ios`). Protocol * messages cross as JSON lines (strings); the platform's own requests as objects. */ -import { nativeClient } from "@hazel/auth/contract" +import { nativeClient, nativeHeaders } from "@hazel/auth/contract" import type { PrecompiledStyles } from "@hazel/foldkit-rune" import { parseHostLine } from "@hazel/foldkit-rune" import type { NativePlatform } from "./platform" @@ -19,6 +19,18 @@ export type IosRequest = | { readonly open: string } | { readonly copy: string } | { readonly signIn: string; readonly callbackScheme: string } + /** A request the host sends for the worker (`host/ios/src/http.rs`), as JSON. */ + | { readonly fetch: string } + +/** The host's answer to a `fetch` request. */ +type FetchReply = + | { readonly id: number; readonly error: string } + | { + readonly id: number + readonly status: number + readonly headers: Record + readonly body: string + } /** The scheme of the link sign-in hands back to the native app (`com.hazel.native`). */ const CALLBACK_SCHEME = new URL(nativeClient.returnUrl).protocol.slice(0, -1) @@ -28,9 +40,40 @@ const post = globalThis.postMessage.bind(globalThis) const request = (message: IosRequest) => post(message) const early: Array = [] let deliver: ((line: string) => void) | undefined -globalThis.onmessage = (event: MessageEvent) => { - if (deliver === undefined) early.push(event.data) - else deliver(event.data) +const replies = new Map void>() +globalThis.onmessage = (event: MessageEvent) => { + const data = event.data + if (typeof data !== "string") { + replies.get(data.id)?.(data) + replies.delete(data.id) + } else if (deliver === undefined) early.push(data) + else deliver(data) +} + +/** + * The app's requests as the native app (`x-auth-mode: native`: sign-in, the session) go out + * through the host: the API admits them only without `Origin`, `Sec-Fetch-Mode` and cookies, + * which WebKit puts on every fetch from here. Everything else is the page's own fetch. + */ +const webFetch = globalThis.fetch.bind(globalThis) +let nextFetch = 0 +globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const outgoing = new Request(input, init) + if (outgoing.headers.get(nativeHeaders.modeHeader) !== "native") return webFetch(outgoing) + const id = ++nextFetch + const body = ["GET", "HEAD"].includes(outgoing.method) ? undefined : await outgoing.text() + const reply = await new Promise((resolve, reject) => { + replies.set(id, resolve) + outgoing.signal.addEventListener("abort", () => { + replies.delete(id) + reject(outgoing.signal.reason) + }) + const headers = Object.fromEntries(outgoing.headers) + request({ fetch: JSON.stringify({ id, url: outgoing.url, method: outgoing.method, headers, body }) }) + }) + if ("error" in reply) throw new TypeError(`Load failed: ${reply.error}`) + const empty = [101, 204, 205, 304].includes(reply.status) + return new Response(empty ? null : reply.body, { status: reply.status, headers: reply.headers }) } // Bun's, which some of the app's dependencies schedule with.