Files
craft-libs/crates/web-api/src/page.rs
T
Helen Kwok 2bfc45c493 web-api: refuse calls beyond an in-flight limit, and say who can call window.<app>
* web-api: refuse calls beyond an in-flight limit, and say who can call window.<app>

At most 64 calls (PageApi::with_max_in_flight) may wait for the app at once. A call made while that many wait rejects at once with a busy error and never reaches the app, so a page script that calls in a loop without awaiting cannot grow the app queue without bound, as the desktop control server already answers busy when its queue is full. The README also says the API is a property of the page global with no key: any script in the page origin has the same command control as the desktop control server.

* web-api: turn a busy call away before converting its params, and say a same-origin parent or opener can call window.<app>

A call over the in-flight limit used to stringify and convert its params before the busy check, so a large payload still cost the work it was being refused for. The params are now built by a closure the call runs only once it holds a slot.

The README said a parent, a child or an opener cannot reach window.<app>. That holds across origins only: a same-origin parent, child or opener with a window reference calls it directly, and the craft-web-host origin and source checks do not apply to that call.

---------

Co-authored-by: Helen Kwok <107752876+helenkwok@users.noreply.github.com>
2026-11-12 10:06:13 -05:00

317 lines
13 KiB
Rust

//! `window.<app>`: the page's command API. wasm32 only. The app hands this crate its control channel
//! as a [`Request`]; the crate never knows the app's internals.
//!
//! Every function goes through one path, [`PageApi::call`], with the page's timeout; replies
//! resolve with the method's `result` or reject with an `Error` carrying `error`.
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Poll, Waker};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::{Closure, JsValue};
use wasm_bindgen_futures::future_to_promise;
use crate::methods::Methods;
use crate::reply::screenshot_data;
/// One control request: method and params in, the protocol's reply object out (`{ok, result}` or
/// `{ok: false, error}`). The app builds this from its control channel — send a `ControlRequest`,
/// await its reply.
pub type Request = Rc<dyn Fn(String, Value) -> Pin<Box<dyn Future<Output = Value>>>>;
/// The page surface of one web build: the `window.<app>` object.
pub struct PageApi {
app: String,
/// The control method behind each function: the desktop control-server method names this app uses.
methods: Methods,
request: Request,
/// Each control request gets a timeout: if the app does not answer within this many
/// milliseconds, the error says so (a hidden tab answers when it is shown again).
timeout_ms: f64,
/// Calls waiting for their reply right now.
in_flight: Cell<usize>,
/// The most calls that may wait at once; one more is refused with a `busy` error.
max_in_flight: usize,
}
/// How many calls may wait for the app at once by default, as many as the desktop control server
/// queues.
const DEFAULT_MAX_IN_FLIGHT: usize = 64;
impl PageApi {
/// `app` is the global name to install (`"vectorcraft"` → `window.vectorcraft`); `methods` is
/// the control method behind each function ([`Methods::default`] is the desktop control-server
/// method names).
pub fn new(app: &str, methods: Methods, request: Request) -> Self {
Self { app: app.to_string(), methods, request, timeout_ms: 30_000.0, in_flight: Cell::new(0), max_in_flight: DEFAULT_MAX_IN_FLIGHT }
}
/// Set the timeout for every control request, in milliseconds (default 30 s).
pub fn with_timeout(mut self, timeout_ms: u32) -> Self {
self.timeout_ms = timeout_ms as f64;
self
}
/// Set how many calls may wait for the app at once (default 64). A call made while that many are
/// waiting is refused with a `busy` error before it reaches the app, so a page script that calls
/// in a loop without awaiting cannot grow the app's queue without bound.
pub fn with_max_in_flight(mut self, max: usize) -> Self {
self.max_in_flight = max;
self
}
/// Install `window.<app>` = `{request, execute, commands, docInspect, uiInspect, screenshot}` —
/// each a Promise-returning function that resolves with the method's `result` or rejects with
/// an `Error` carrying `error`. Only the functions whose control method the app's [`Methods`]
/// table provides are installed; `request` is always there.
///
/// An existing `window.<app>` is never replaced: the missing functions are added to it, and an
/// app that already has its own keeps them.
pub fn install(self: Rc<Self>) -> Result<(), JsValue> {
let g = global();
let name = JsValue::from_str(&self.app);
let existing = get_prop(&g, &self.app);
let existed = existing.as_ref().is_some_and(|v| v.is_object());
let target: js_sys::Object = match existing {
Some(v) if v.is_object() => v.unchecked_into(),
_ => js_sys::Object::new(),
};
let add = |name: &str, f: JsValue| -> Result<(), JsValue> {
if js_sys::Reflect::has(&target, &JsValue::from_str(name)).unwrap_or(true) {
return Ok(()); // an app that already has its own function keeps it
}
js_sys::Reflect::set(&target, &JsValue::from_str(name), &f).map(|_| ())
};
let page = self.clone();
add(
"request",
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |method: String, params: JsValue| {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(&method, || from_js(&params)).await) })
})
.into_js_value(),
)?;
if let Some(method) = self.methods.execute {
let page = self.clone();
add(
"execute",
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |command: String, params: JsValue| {
let page = page.clone();
future_to_promise(async move {
let params = || json!({"command": command, "params": from_js(&params)});
request_promise_outcome(&page.call(method, params).await)
})
})
.into_js_value(),
)?;
}
if let Some(method) = self.methods.commands {
let page = self.clone();
add(
"commands",
Closure::<dyn FnMut() -> js_sys::Promise>::new(move || {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(method, || json!({})).await) })
})
.into_js_value(),
)?;
}
for (name, method) in [("docInspect", self.methods.doc_inspect), ("uiInspect", self.methods.ui_inspect)] {
if let Some(method) = method {
let page = self.clone();
add(
name,
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(method, || from_js(&params)).await) })
})
.into_js_value(),
)?;
}
}
if let Some(method) = self.methods.screenshot {
let page = self.clone();
add(
"screenshot",
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
let page = page.clone();
let params = move || screenshot_params(from_js(&params));
future_to_promise(async move { request_promise_outcome(&screenshot_data(page.call(method, params).await)) })
})
.into_js_value(),
)?;
}
if existed { Ok(()) } else { js_sys::Reflect::set(&g, &name, &target).map(|_| ()) }
}
/// One control request: the single path through the app's channel that every function takes,
/// with the page's timeout. If the app does not answer within the timeout, the error says so
/// (a hidden tab answers when it is shown again) and the app's request future is dropped: an app
/// that releases a request when its future goes away never runs, late, a call the page was told
/// had failed. `params` is built after the in-flight check, so a call that is turned away costs no conversion.
async fn call(&self, method: &str, params: impl FnOnce() -> Value) -> Value {
if self.in_flight.get() >= self.max_in_flight {
return json!({
"ok": false,
"error": format!("{} is busy: {} calls are already waiting for an answer", self.app, self.max_in_flight),
});
}
self.in_flight.set(self.in_flight.get() + 1);
let _slot = InFlight(&self.in_flight);
let timeout = Timeout::new(self.timeout_ms);
let mut work = (self.request)(method.to_string(), params()); // params are built only once a slot is held
let reply = std::future::poll_fn(|cx| {
if let Poll::Ready(reply) = work.as_mut().poll(cx) {
return Poll::Ready(Some(reply));
}
if timeout.fired.get() {
return Poll::Ready(None);
}
*timeout.waker.borrow_mut() = Some(cx.waker().clone());
Poll::Pending
})
.await;
drop(work); // an unanswered request is released here, before the error goes back
drop(timeout); // clear the timer and free its callback once the race is over
reply.unwrap_or_else(|| {
json!({
"ok": false,
"error": format!(
"{} did not answer {} within {} s (a hidden tab answers when it is shown again)",
self.app, method, self.timeout_ms / 1000.0
),
})
})
}
}
/// One waiting call's place in the in-flight count, given back when the call ends (answered, timed out
/// or dropped).
struct InFlight<'a>(&'a Cell<usize>);
impl Drop for InFlight<'_> {
fn drop(&mut self) {
self.0.set(self.0.get() - 1);
}
}
/// `screenshot` params: `data: true` is added so the reply carries the PNG, unless the caller set
/// `data` itself.
fn screenshot_params(mut params: Value) -> Value {
match params.as_object_mut() {
Some(o) => {
o.entry("data").or_insert(json!(true));
params
}
None => json!({"data": true}),
}
}
/// A timer for one call: `fired` turns true `ms` milliseconds from now and wakes the call. Dropping
/// it clears the timer (harmless once fired) and frees the timer's callback, so a call leaves
/// nothing behind. The timer comes from `setTimeout` on the global object, reached through
/// `Reflect` — no web-sys, so the crate keeps working in any JS realm.
struct Timeout {
fired: Rc<Cell<bool>>,
waker: Rc<RefCell<Option<Waker>>>,
id: JsValue,
_fire: Closure<dyn FnMut()>,
}
impl Timeout {
fn new(ms: f64) -> Self {
let fired = Rc::new(Cell::new(false));
let waker: Rc<RefCell<Option<Waker>>> = Rc::default();
let (set, wake) = (fired.clone(), waker.clone());
let callback = Closure::<dyn FnMut()>::new(move || {
set.set(true);
if let Some(waker) = wake.borrow_mut().take() {
waker.wake();
}
});
let id = set_timeout(callback.as_ref().clone(), ms);
Self { fired, waker, id, _fire: callback }
}
}
impl Drop for Timeout {
fn drop(&mut self) {
clear_timeout(&self.id);
}
}
/// The `setTimeout` global, reached through the global object + `Reflect` (no web-sys). Returns the
/// timer handle as the environment gave it, to hand back to [`clear_timeout`]: a number in a
/// browser, an object in Node. A missing `setTimeout` yields `undefined`, which `clearTimeout` ignores.
fn set_timeout(callback: JsValue, ms: f64) -> JsValue {
let g = global();
get_prop(&g, "setTimeout")
.filter(|v| v.is_function())
.and_then(|f| {
let f: js_sys::Function = f.unchecked_into();
f.call2(&g, &callback, &JsValue::from_f64(ms)).ok()
})
.unwrap_or(JsValue::UNDEFINED)
}
/// Clear a timer by the handle [`set_timeout`] returned, if the environment has `clearTimeout`.
fn clear_timeout(id: &JsValue) {
let g = global();
if let Some(f) = get_prop(&g, "clearTimeout").filter(|v| v.is_function()) {
let f: js_sys::Function = f.unchecked_into();
let _ = f.call1(&g, id);
}
}
/// The `Promise` outcome of a `window.<app>` call: resolve with `result`, reject with an `Error`
/// carrying `error`.
fn request_promise_outcome(reply: &Value) -> Result<JsValue, JsValue> {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
} else {
Err(js_sys::Error::new(&error_text(reply)).into())
}
}
/// A reply's `error` as displayable text.
fn error_text(reply: &Value) -> String {
match reply.get("error") {
Some(Value::String(s)) => s.clone(),
Some(other) => serde_json::to_string(other).unwrap_or_else(|_| "error".to_string()),
None => "error".to_string(),
}
}
/// The global object (`window` in a browser, `globalThis` in Node): `window.<app>`, `setTimeout`
/// and `clearTimeout` live on it.
fn global() -> js_sys::Object {
js_sys::global()
}
fn get_prop(target: &js_sys::Object, name: &str) -> Option<JsValue> {
js_sys::Reflect::get(target, &JsValue::from_str(name)).ok()
}
/// JSON → JS. VectorCraft's `to_js`.
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
/// JS → JSON. VectorCraft's `from_js`; absent values become an empty object.
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}