Repository navigation
fix(native): send iOS native-mode auth requests through NSURLSession #475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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::<Value>(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> = 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::<NSHTTPURLResponse>()); | ||
| 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::<NSString>(), | ||
| value.as_deref().and_then(|v| v.downcast_ref::<NSString>()), | ||
| ) { | ||
| 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(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 @@ | |
| | { 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<string, string> | ||
| 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 request = (message: IosRequest) => post(message) | ||
| const early: Array<string> = [] | ||
| let deliver: ((line: string) => void) | undefined | ||
| globalThis.onmessage = (event: MessageEvent<string>) => { | ||
| if (deliver === undefined) early.push(event.data) | ||
| else deliver(event.data) | ||
| const replies = new Map<number, (reply: FetchReply) => void>() | ||
| globalThis.onmessage = (event: MessageEvent<string | FetchReply>) => { | ||
| 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 | ||
|
Check warning on line 63 in apps/native/src/ios.ts
|
||
| const body = ["GET", "HEAD"].includes(outgoing.method) ? undefined : await outgoing.text() | ||
| const reply = await new Promise<FetchReply>((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 }) }) | ||
|
Check warning on line 72 in apps/native/src/ios.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning Host path follows redirects the auth client forbids ( F1 · Warning · security The auth runtime builds its client with 🤖 Prompt to fix with an AI agent |
||
| }) | ||
| 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. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Warning
New
fetchrouting and reply handling are covered by no testF2 · Warning · tests
apps/native/srctests its other global shims (shims.test.ts, anddata-source.tsthroughoffline-backend.test.ts), but nothing exercises this branch: a native request reaching the host, itsidmatching a late reply, a non-native request on WebKit's fetch, or{ error }becoming aTypeError. The change's whole behavior is unverified in CI.🤖 Prompt to fix with an AI agent