Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/native/host/ios/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -203,6 +213,8 @@ fn request(mtm: MainThreadMarker, body: &NSDictionary<NSString, AnyObject>) {
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))) };
}
Expand Down
99 changes: 99 additions & 0 deletions apps/native/host/ios/src/http.rs
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();
}
2 changes: 2 additions & 0 deletions apps/native/host/ios/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions apps/native/host/ios/src/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 47 additions & 4 deletions apps/native/src/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

New fetch routing and reply handling are covered by no test

F2 · Warning · tests

apps/native/src tests its other global shims (shims.test.ts, and data-source.ts through offline-backend.test.ts), but nothing exercises this branch: a native request reaching the host, its id matching a late reply, a non-native request on WebKit's fetch, or { error } becoming a TypeError. The change's whole behavior is unverified in CI.

Extract the routing into `src/native-fetch.ts` taking the web fetch, `request` and a reply source, and test it: native routed, others passed through, an error reply throws.
🤖 Prompt to fix with an AI agent
In `apps/native/src/ios.ts:62-63`: New `fetch` routing and reply handling are covered by no test.

`apps/native/src` tests its other global shims (`shims.test.ts`, and `data-source.ts` through `offline-backend.test.ts`), but nothing exercises this branch: a native request reaching the host, its `id` matching a late reply, a non-native request on WebKit's fetch, or `{ error }` becoming a `TypeError`. The change's whole behavior is unverified in CI.

Suggested fix: Extract the routing into `src/native-fetch.ts` taking the web fetch, `request` and a reply source, and test it: native routed, others passed through, an error reply throws.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

const id = ++nextFetch

Check warning on line 63 in apps/native/src/ios.ts

View check run for this annotation

Maple Review Bot / Maple / review

tests: New `fetch` routing and reply handling are covered by no test

`apps/native/src` tests its other global shims (`shims.test.ts`, and `data-source.ts` through `offline-backend.test.ts`), but nothing exercises this branch: a native request reaching the host, its `id` matching a late reply, a non-native request on WebKit's fetch, or `{ error }` becoming a `TypeError`. The change's whole behavior is unverified in CI.
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

View check run for this annotation

Maple Review Bot / Maple / review

security: Host path follows redirects the auth client forbids (`redirect: "error"`)

The auth runtime builds its client with `redirect: "error"` because the session travels in `x-auth-request-session` headers (`apps/web-foldkit/src/platform/desktop-auth.ts:159`). The payload sent to the host carries no `redirect`, and `NSURLSession` follows a redirect by default, so a 3xx re-sends those credential headers to the `Location` origin and the caller gets that response instead of a rejection.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Host path follows redirects the auth client forbids (redirect: "error")

F1 · Warning · security

The auth runtime builds its client with redirect: "error" because the session travels in x-auth-request-session headers (apps/web-foldkit/src/platform/desktop-auth.ts:159). The payload sent to the host carries no redirect, and NSURLSession follows a redirect by default, so a 3xx re-sends those credential headers to the Location origin and the caller gets that response instead of a rejection.

Put `redirect: outgoing.redirect` in the payload, and have `http.rs` refuse the redirect (a task delegate answering `None`) so a 3xx surfaces as the load failure the client asked for.
🤖 Prompt to fix with an AI agent
In `apps/native/src/ios.ts:72`: Host path follows redirects the auth client forbids (`redirect: "error"`).

The auth runtime builds its client with `redirect: "error"` because the session travels in `x-auth-request-session` headers (`apps/web-foldkit/src/platform/desktop-auth.ts:159`). The payload sent to the host carries no `redirect`, and `NSURLSession` follows a redirect by default, so a 3xx re-sends those credential headers to the `Location` origin and the caller gets that response instead of a rejection.

Suggested fix: Put `redirect: outgoing.redirect` in the payload, and have `http.rs` refuse the redirect (a task delegate answering `None`) so a 3xx surfaces as the load failure the client asked for.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

})
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.
Expand Down
Loading