mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-15 06:52:23 +00:00
* 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>
317 lines
13 KiB
Rust
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(¶ms)).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(¶ms)});
|
|
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(¶ms)).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(¶ms));
|
|
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!({}))
|
|
}
|