diff --git a/.github/workflows/web-api.yml b/.github/workflows/web-api.yml new file mode 100644 index 00000000..4592c071 --- /dev/null +++ b/.github/workflows/web-api.yml @@ -0,0 +1,38 @@ +name: Web API (page command API) + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/web-api/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-api.yml'] + pull_request: + paths: ['crates/web-api/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-api.yml'] + +permissions: + contents: read + +jobs: + web-api: + # One OS: the crate is platform-independent JSON handling plus wasm32; no platform code. + timeout-minutes: 30 + runs-on: ubuntu-latest + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - name: Install the matching WASM test runner + shell: bash + run: | + runner_version=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); print(next(p["version"] for p in d["package"] if p["name"] == "wasm-bindgen"))') + cargo install wasm-bindgen-cli --version "$runner_version" --locked + - run: cargo fmt --check + - run: cargo clippy -p craft-web-api --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-web-api --all-targets --locked --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p craft-web-api --locked + - run: cargo test -p craft-web-api --locked --target wasm32-unknown-unknown diff --git a/.github/workflows/web-host.yml b/.github/workflows/web-host.yml new file mode 100644 index 00000000..f308a1cf --- /dev/null +++ b/.github/workflows/web-host.yml @@ -0,0 +1,45 @@ +name: Shared web host + +on: + push: + branches: [main] + paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"] + pull_request: + paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: shared-web-host-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + timeout-minutes: 20 + name: Web host lint, tests, WASM tests + runs-on: ubuntu-latest + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-web-host + - run: cargo fmt -p craft-web-host --check + - run: cargo clippy -p craft-web-host --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-web-host --all-targets --locked --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p craft-web-host --locked + - name: Install the matching WASM test runner + shell: bash + run: | + runner_version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p') + cargo install wasm-bindgen-cli --version "$runner_version" --locked + - run: cargo test -p craft-web-host --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 4c12df4f..9cb7a068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1192,6 +1192,26 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", +] + +[[package]] +name = "craft-web-host" +version = "0.1.0" +dependencies = [ + "wasm-bindgen", + "wasm-bindgen-test", + "web-sys", +] + [[package]] name = "craft-window" version = "0.1.0" @@ -7421,7 +7441,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -7436,18 +7455,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 7ebee590..9c7b8ef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,9 @@ craft-text = { path = "crates/text" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } +craft-web-api = { path = "crates/web-api" } craft-window = { path = "crates/window" } +craft-web-host = { path = "crates/web-host" } egui = { version = "0.37", default-features = false } rayon = "1" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index e45d6e0f..2881e294 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's + JSON control channel. +- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app. - [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities and compatibility boundaries across the Craft suite. - [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small shared diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml index 4e074060..8d375275 100644 --- a/crates/update/Cargo.toml +++ b/crates/update/Cargo.toml @@ -47,6 +47,11 @@ plist = "1" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.55" dunce = "1" -zip = { version = "9", default-features = false, features = ["deflate"] } +# `deflate-flate2` with flate2's default miniz_oxide backend, not zip's `deflate` (zopfli and +# zlib-rs): Cargo unifies flate2's features across an app, and zlib-rs would switch the whole +# app's flate2 to it, where level 1 is a static-Huffman mode that can make output larger than +# its input (Lightcraft's 16-bit ZIP TIFFs on Windows). +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } +flate2 = "1" winsafe = { version = "0.0.29", features = ["advapi", "kernel", "shell", "version"] } verifysign = "1" diff --git a/crates/web-api/Cargo.toml b/crates/web-api/Cargo.toml new file mode 100644 index 00000000..8c8eb8dd --- /dev/null +++ b/crates/web-api/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "craft-web-api" +description = "The page command API for Craft web builds: one window. shape over the app's JSON control channel." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +serde_json = { workspace = true } + +# Already in every web build's Cargo.lock through eframe. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/crates/web-api/README.md b/crates/web-api/README.md new file mode 100644 index 00000000..d14bbfb1 --- /dev/null +++ b/crates/web-api/README.md @@ -0,0 +1,62 @@ +# craft-web-api + +The page command API for every Craft web build: one `window.` shape over the app's JSON +control channel. + +## The JS API + +`PageApi::install` puts this on `window.` (`"vectorcraft"` → `window.vectorcraft`), without +replacing an existing `window.`: missing functions are added, an app that already has its own +keeps them. Each function returns a Promise that resolves with the method's `result` or rejects with +an `Error` carrying `error`. Every call has a timeout (30 s by default, `PageApi::with_timeout`); +when the app does not answer in time, the error says so. + +Which functions are installed follows the app's `Methods` table: only the functions whose control +method the table provides are installed; `request` is always there. + +| Function | Call | Table entry | Default method | +|---|---|---|---| +| `request` | `request(method, params)` | — | — (as given) | +| `execute` | `execute(command, params)` | `execute` | `engine.execute {command, params}` | +| `commands` | `commands()` | `commands` | `engine.commands {}` | +| `docInspect` | `docInspect(params)` | `doc_inspect` | `document.inspect` | +| `uiInspect` | `uiInspect(params)` | `ui_inspect` | `ui.inspect` | +| `screenshot` | `screenshot(params)` | `screenshot` | `ui.screenshot` (with `data: true`) | + +`Methods::default()` is the desktop control-server method names; an app whose control channel +names a method differently, or lacks one, says so in its table (`Methods::NONE` is the empty table). + +## How an app wires it + +The app hands the crate its control channel as a `Request`; the crate never knows the app's +internals. Using VectorCraft's channel (the same one the desktop control server uses) as the example: + +```rust +use std::rc::Rc; + +use craft_web_api::methods::Methods; +use craft_web_api::page::{PageApi, Request}; + +// Send a ControlRequest to the app and await its reply object ({ok, result} / {ok: false, error}). +let request: Request = Rc::new(move |method: String, params: serde_json::Value| { + let (req, rx) = ControlRequest::new(method, params); + control_tx.send(req); + Box::pin(poll_reply(rx)) // resolves when the app answers the request +}); +let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), request)); +page.install()?; // window.vectorcraft +``` + +## Beside the host messages + +Opening a document, handing a saved or exported file to the host, the document name and the dirty +flag stay with the host messages in [`craft-web-host`](../web-host/README.md) (`:open`, +`:save`, `:dirty`, …) and the app's own control methods. A page reaches any control +method through `request(method, params)`. + +## Tests + +`src/methods.rs` carries the native unit tests (the default and empty tables). `tests/page.rs` runs +under `wasm-bindgen-test` in Node with a canned control channel: install and keeping an app's own +functions, the table deciding which functions exist, the method and params each function sends, +error replies as rejections, and the timeout. diff --git a/crates/web-api/src/lib.rs b/crates/web-api/src/lib.rs new file mode 100644 index 00000000..434d5fdb --- /dev/null +++ b/crates/web-api/src/lib.rs @@ -0,0 +1,40 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! One page command API for every Craft web build. +//! +//! Craft web apps (the `apps/-web` builds of VectorCraft, PhotoCraft, FilmCraft, …) accept +//! JSON control requests `{method, params}` from their page and answer `{ok, result}` or +//! `{ok: false, error}` — the same protocol as the desktop control server. Until now each app kept +//! its own copy of the code that exposes this to the page as `window.`, in a different shape. +//! This crate is the one shared shape. +//! +//! - [`page`] (wasm32 only) installs `window.` — `request(method, params)`, +//! `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and +//! `screenshot(params)`; each function other than `request` is installed only when the app's +//! [`methods::Methods`] table provides its control method. +//! - [`methods`] is the target-independent table naming the control method behind each function. +//! +//! The app hands the crate its request function (its control channel) and a [`methods::Methods`] +//! table; the crate never knows the app's internals. +//! +//! # How an app wires it +//! +//! ```ignore +//! use std::rc::Rc; +//! use craft_web_api::methods::Methods; +//! use craft_web_api::page::{PageApi, Request}; +//! +//! // The control channel: send a ControlRequest, await its reply object. +//! let request: Request = Rc::new(move |method: String, params: serde_json::Value| { +//! let (req, rx) = ControlRequest::new(method, params); +//! control_tx.send(req); // the same channel the desktop control server uses +//! Box::pin(receive_reply(rx)) // resolves with {ok, result} / {ok: false, error} +//! }); +//! let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), request)); +//! page.install()?; // window.vectorcraft +//! ``` + +pub mod methods; + +#[cfg(target_arch = "wasm32")] +pub mod page; diff --git a/crates/web-api/src/methods.rs b/crates/web-api/src/methods.rs new file mode 100644 index 00000000..d313b8e8 --- /dev/null +++ b/crates/web-api/src/methods.rs @@ -0,0 +1,58 @@ +//! The method table: which control method backs each `window.` function. Target-independent, +//! so it is natively tested and usable from any build. + +/// The method table one app hands the crate: the control method behind each `window.` +/// function. `None` means that function is not installed; `request` is always installed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Methods { + /// Behind `commands()`. + pub commands: Option<&'static str>, + /// Behind `execute(command, params)`, sent as `{command, params}`. + pub execute: Option<&'static str>, + /// Behind `docInspect(params)`. + pub doc_inspect: Option<&'static str>, + /// Behind `uiInspect(params)`. + pub ui_inspect: Option<&'static str>, + /// Behind `screenshot(params)`, sent with `data: true` unless the caller set `data`. + pub screenshot: Option<&'static str>, +} + +impl Methods { + /// A table with no methods: only `request` is installed. + pub const NONE: Self = Self { commands: None, execute: None, doc_inspect: None, ui_inspect: None, screenshot: None }; +} + +impl Default for Methods { + /// The desktop control-server method names every Craft web build shares. + fn default() -> Self { + Self { + commands: Some("engine.commands"), + execute: Some("engine.execute"), + doc_inspect: Some("document.inspect"), + ui_inspect: Some("ui.inspect"), + screenshot: Some("ui.screenshot"), + } + } +} + +#[cfg(test)] +mod tests { + use super::Methods; + + #[test] + fn default_is_the_desktop_control_methods() { + let m = Methods::default(); + assert_eq!(m.commands, Some("engine.commands")); + assert_eq!(m.execute, Some("engine.execute")); + assert_eq!(m.doc_inspect, Some("document.inspect")); + assert_eq!(m.ui_inspect, Some("ui.inspect")); + assert_eq!(m.screenshot, Some("ui.screenshot")); + } + + #[test] + fn none_has_no_methods() { + let none = Methods::NONE; + assert!([none.commands, none.execute, none.doc_inspect, none.ui_inspect, none.screenshot].iter().all(Option::is_none)); + assert_ne!(none, Methods::default()); + } +} diff --git a/crates/web-api/src/page.rs b/crates/web-api/src/page.rs new file mode 100644 index 00000000..538601f8 --- /dev/null +++ b/crates/web-api/src/page.rs @@ -0,0 +1,273 @@ +//! `window.`: 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::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use serde_json::{Value, json}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::{Closure, JsValue}; +use wasm_bindgen_futures::{JsFuture, future_to_promise}; + +use crate::methods::Methods; + +/// 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 Pin>>>; + +/// The page surface of one web build: the `window.` 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, +} + +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 } + } + + /// 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 + } + + /// Install `window.` = `{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.` 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) -> 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:: 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:: 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:: 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:: 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:: js_sys::Promise>::new(move |params: JsValue| { + let page = page.clone(); + let params = screenshot_params(from_js(¶ms)); + future_to_promise(async move { request_promise_outcome(&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. + async fn call(&self, method: &str, params: Value) -> Value { + let timeout = Timeout::new(self.timeout_ms); + let request = self.request.clone(); + let method = method.to_string(); + let sent = method.clone(); + let reply = future_to_promise(async move { + let reply = request(sent, params).await; + Ok(to_js(&reply)) + }); + let raced = js_sys::Promise::race(&js_sys::Array::of2(&reply, &timeout.promise)); + let outcome = JsFuture::from(raced).await; + drop(timeout); // clear the timer and free its callback once the race is over + match outcome { + Err(_) => 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 + ), + }), + Ok(value) => from_js(&value), + } + } +} + +/// `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 promise that rejects `ms` milliseconds from now. 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 { + promise: js_sys::Promise, + id: f64, + _fire: Option>, +} + +impl Timeout { + fn new(ms: f64) -> Self { + let mut fire = None; + let mut id = f64::INFINITY; + // `Promise::new` runs its executor before returning, so `fire` and `id` are set here. + let promise = js_sys::Promise::new(&mut |_resolve: js_sys::Function, reject: js_sys::Function| { + let callback = Closure::::new(move || { + let _ = reject.call1(&JsValue::NULL, &JsValue::NULL); + }); + id = set_timeout(callback.as_ref().clone(), ms); + fire = Some(callback); + }); + Self { promise, id, _fire: fire } + } +} + +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 id; a missing `setTimeout` yields an id of infinity, which [`clear_timeout`] ignores. +fn set_timeout(callback: JsValue, ms: f64) -> f64 { + 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() + }) + .and_then(|v| v.as_f64()) + .unwrap_or(f64::INFINITY) +} + +/// Clear a `setTimeout` id, if the environment has `clearTimeout`. +fn clear_timeout(id: f64) { + 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, &JsValue::from_f64(id)); + } +} + +/// The `Promise` outcome of a `window.` call: resolve with `result`, reject with an `Error` +/// carrying `error`. +fn request_promise_outcome(reply: &Value) -> Result { + 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.`, `setTimeout` +/// and `clearTimeout` live on it. +fn global() -> js_sys::Object { + js_sys::global() +} + +fn get_prop(target: &js_sys::Object, name: &str) -> Option { + 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!({})) +} diff --git a/crates/web-api/tests/page.rs b/crates/web-api/tests/page.rs new file mode 100644 index 00000000..092fa296 --- /dev/null +++ b/crates/web-api/tests/page.rs @@ -0,0 +1,161 @@ +#![cfg(target_arch = "wasm32")] +//! Node tests for the page part: a canned control channel that records what it is asked, and the +//! `window.` functions called the way a page calls them. No browser. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::{Closure, JsValue}; +use wasm_bindgen_futures::JsFuture; +use wasm_bindgen_test::wasm_bindgen_test; + +use craft_web_api::methods::Methods; +use craft_web_api::page::{PageApi, Request}; + +thread_local! { + /// (method, params) pairs recorded by the fake control channel. + static REQUESTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn reset() { + REQUESTS.with(|r| r.borrow_mut().clear()); + let _ = js_sys::Reflect::delete_property(&js_sys::global(), &"vectorcraft".into()); +} + +/// A control channel that always answers `reply`. +fn canned_request(reply: Value) -> Request { + Rc::new(move |_method: String, _params: Value| { + let reply = reply.clone(); + Box::pin(async move { reply }) + }) +} + +/// A control channel that records every call and always answers `reply`. +fn recording_request(reply: Value) -> Request { + Rc::new(move |method: String, params: Value| { + REQUESTS.with(|r| r.borrow_mut().push((method.clone(), params))); + let reply = reply.clone(); + Box::pin(async move { reply }) + }) +} + +fn to_value(v: &JsValue) -> Value { + js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null) +} + +fn api() -> js_sys::Object { + js_sys::Reflect::get(&js_sys::global(), &"vectorcraft".into()).expect("window.vectorcraft").unchecked_into() +} + +fn has(api: &js_sys::Object, name: &str) -> bool { + js_sys::Reflect::has(api, &JsValue::from_str(name)).unwrap_or(false) +} + +/// Call `window.vectorcraft.(args…)` and await its promise. +async fn call(name: &str, args: &[JsValue]) -> Result { + let api = api(); + let f: js_sys::Function = js_sys::Reflect::get(&api, &JsValue::from_str(name)).expect(name).unchecked_into(); + let promise: js_sys::Promise = f.apply(&api, &args.iter().collect::()).expect("call").unchecked_into(); + JsFuture::from(promise).await +} + +fn obj(json: &str) -> JsValue { + js_sys::JSON::parse(json).expect("valid JSON") +} + +#[wasm_bindgen_test] +async fn install_keeps_existing_functions() { + reset(); + let g = js_sys::global(); + let existing = js_sys::Object::new(); + let marker = Closure::::new(|| {}).into_js_value(); + js_sys::Reflect::set(&existing, &"execute".into(), &marker).expect("set execute"); + js_sys::Reflect::set(&g, &"vectorcraft".into(), &existing.into()).expect("set window.vectorcraft"); + + let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(json!({"ok": true, "result": {}})))); + page.install().expect("install"); + + let api = api(); + let kept = js_sys::Reflect::get(&api, &"execute".into()).expect("execute"); + assert_eq!(kept, marker, "an app's own execute is kept"); + for name in ["request", "execute", "commands", "docInspect", "uiInspect", "screenshot"] { + assert!(has(&api, name), "{name} must be added"); + } +} + +#[wasm_bindgen_test] +async fn methods_table_decides_which_functions_exist() { + reset(); + let only = Methods { commands: Some("c.commands"), ..Methods::NONE }; + Rc::new(PageApi::new("vectorcraft", only, canned_request(json!({"ok": true, "result": {}})))).install().expect("install"); + let api = api(); + assert!(has(&api, "request"), "request is always installed"); + assert!(has(&api, "commands")); + for name in ["execute", "docInspect", "uiInspect", "screenshot"] { + assert!(!has(&api, name), "{name} has no method and must not be installed"); + } +} + +#[wasm_bindgen_test] +async fn functions_send_their_methods() { + reset(); + let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), recording_request(json!({"ok": true, "result": {"done": 1}})))); + page.install().expect("install"); + + let out = call("execute", &["object.rectangle".into(), obj(r#"{"width": 100}"#)]).await.expect("execute resolves"); + assert_eq!(to_value(&out), json!({"done": 1}), "a success resolves with the result"); + call("commands", &[]).await.expect("commands"); + call("docInspect", &[obj(r#"{"depth": 2}"#)]).await.expect("docInspect"); + call("uiInspect", &[]).await.expect("uiInspect"); + call("screenshot", &[]).await.expect("screenshot"); + call("screenshot", &[obj(r#"{"data": false, "scale": 2}"#)]).await.expect("screenshot with data"); + call("request", &["custom.method".into(), obj(r#"{"x": 1}"#)]).await.expect("request"); + + let calls = REQUESTS.with(|r| r.borrow().clone()); + assert_eq!( + calls, + [ + ("engine.execute".to_string(), json!({"command": "object.rectangle", "params": {"width": 100}})), + ("engine.commands".to_string(), json!({})), + ("document.inspect".to_string(), json!({"depth": 2})), + ("ui.inspect".to_string(), json!({})), + ("ui.screenshot".to_string(), json!({"data": true})), + ("ui.screenshot".to_string(), json!({"data": false, "scale": 2})), + ("custom.method".to_string(), json!({"x": 1})), + ] + ); +} + +#[wasm_bindgen_test] +async fn error_replies_reject_with_the_error_text() { + reset(); + Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(json!({"ok": false, "error": "boom"})))).install().expect("install"); + let err = match call("commands", &[]).await { + Err(err) => err, + Ok(_) => panic!("an error reply must reject"), + }; + assert_eq!(String::from(err.unchecked_into::().message()), "boom"); +} + +#[wasm_bindgen_test] +async fn timeout_gives_an_error_saying_so() { + reset(); + // A control channel that never answers: it waits on a promise that never settles. (Looping on + // resolved promises instead would starve the timer queue, so the page's timeout could never fire.) + let pending: Request = Rc::new(|_method: String, _params: Value| { + Box::pin(async { + let _ = JsFuture::from(js_sys::Promise::new(&mut |_, _| {})).await; + Value::Null + }) + }); + let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), pending).with_timeout(10)); + page.install().expect("install"); + let err = match call("execute", &["object.rectangle".into(), js_sys::Object::new().into()]).await { + Err(err) => err, + Ok(_) => panic!("a never-answering app must time out"), + }; + let message = String::from(err.unchecked_into::().message()); + assert!(message.contains("vectorcraft did not answer engine.execute within 0.01 s"), "{message}"); +} diff --git a/crates/web-host/Cargo.toml b/crates/web-host/Cargo.toml new file mode 100644 index 00000000..1f50f570 --- /dev/null +++ b/crates/web-host/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "craft-web-host" +description = "The host messages between a Craft app's web build and the page that hosts it: one contract and one bridge for every app." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +# Already in every web build's Cargo.lock through eframe. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["Blob", "CustomEvent", "CustomEventInit", "Element", "Event", "EventTarget", "Location", "MessageEvent", "Window"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/crates/web-host/README.md b/crates/web-host/README.md new file mode 100644 index 00000000..fd5593ad --- /dev/null +++ b/crates/web-host/README.md @@ -0,0 +1,51 @@ +# craft-web-host + +The host messages between a Craft app's web build and the page that hosts it, as one contract and one bridge for every app (storytold/craft-libs#358). A page frames the app with `?host=parent`, or runs it directly with `data-host="page"` on its canvas; either way the same messages pass files in, saves out, and unsaved-changes state, so a page can host any of the apps the same way. + +The crate has two parts: + +- **The contract** (all targets): the message kinds and their `:` types, the contract version, how the hosting mode is read from the URL and the canvas, and the clean-up of file names a host passes in. Pure, tested natively. +- **The bridge** (`web`, wasm32 only): `Host::connect(app, canvas)` finds the host, `listen` hears its messages, and `post_ready`, `post_save`, `post_dirty`, `post_failed` and `post_handoff` answer it. Only the same-origin parent window is heard and answered; with a parent on another origin the app runs as if unframed. + +## Messages + +| Kind | Direction | Fields | Meaning | +|---|---|---|---| +| `ready` | app → host | `version`, `appVersion?`, `file?` | the app can take a document; `file: true` means it reads a `File`/`Blob` in slices | +| `host` | host → app | `version`, `handoff?` | what the host supports; `handoff: true` means it routes hand-offs | +| `open` | host → app | `name`, and `bytes` or `file` + `size` | open a document as File › Open would | +| `save` | app → host | `name`, `bytes`, `id?`, `saveAs?` | Save, Save As or Export: the host stores the file | +| `saved` | host → app | `id` | the save with that `id` is stored; the app clears dirty | +| `dirty` | app → host | `dirty` | unsaved changes appeared or went away | +| `failed` | app → host | `error`, `id?` | a document or a save could not be taken | +| `handoff` | app → host | `name`, and `bytes` or `file` + `size` | this file belongs to another app | +| `handoff-done` | host → app | `name`, `app` | the host gave it to the app that edits it | + +With `?host=parent` a message is an object with `type` (`pdfcraft:open`) and the fields, sent with same-origin `postMessage`; `bytes` go as a transferred `ArrayBuffer`. With `data-host="page"` it is a `CustomEvent` of that type on the canvas, with the fields in `detail`. + +Compatibility with the bridges shipped before this crate: + +- Fields marked `?` are optional. A host answers `saved` only for a `save` that carries an `id`; an app that sends none treats its save as done once it is posted. +- A host that never sends `host` gets the behaviour of the earlier bridges. +- `size` beside `file` is for hosts and older bridges; this crate takes a `Blob`'s length from the `Blob` itself. +- A page host may dispatch an event while the app's handler runs (a `saved` answering the `save` it just posted). The bridge holds it and calls the handler again once the running call returns, so messages arrive one at a time and in order. +- `version` is `"1"`. The earlier bridges put the app's own release in it (`"0.14.0"`), so a host treats a `version` it doesn't know as `"1"`. The crate sends the release as `appVersion`. + +## Use in an app's web shell + +```rust +#[cfg(target_arch = "wasm32")] +if let Some(host) = craft_web_host::web::Host::connect("pdfcraft", &canvas) { + host.listen(move |message| match message { + craft_web_host::web::Message::Open { name, data } => { + // Check data.size() against the app's limit before copying it in. + } + craft_web_host::web::Message::Saved { id } => { /* clear dirty for that save */ } + _ => {} + }); + let _ = host.post_ready(env!("CARGO_PKG_VERSION"), false); + // Saves: host.post_save(name, &bytes, None, save_as); unsaved state: host.post_dirty(dirty). +} +``` + +The page command API is [`craft-web-api`](../web-api/README.md) (storytold/craft-libs#413): `window..request` and the command functions. It sits beside these messages. Opening, saving, the document name and the dirty flag stay here. diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rs new file mode 100644 index 00000000..0d6c0bb1 --- /dev/null +++ b/crates/web-host/src/lib.rs @@ -0,0 +1,298 @@ +//! The host messages between a Craft app's web build and the page that hosts it: one contract for +//! the whole family (storytold/craft-libs#358), so each app's bridge is a few calls instead of its +//! own copy. +//! +//! Messages are plain objects with a `type` of `:` (`cadcraft:open`): +//! +//! | Kind | Direction | Fields | +//! |---|---|---| +//! | `ready` | app → host | `version`, `appVersion?`, `file?` (`true`: it reads a `File`/`Blob` in slices) | +//! | `host` | host → app | `version`, `handoff?` (`true`: the host routes hand-offs) | +//! | `open` | host → app | `name`, and `bytes` or `file` + `size` | +//! | `save` | app → host | `name`, `bytes`, `id?`, `saveAs?` | +//! | `saved` | host → app | `id` | +//! | `dirty` | app → host | `dirty` | +//! | `failed` | app → host | `error`, `id?` | +//! | `handoff` | app → host | `name`, and `bytes` or `file` + `size` | +//! | `handoff-done` | host → app | `name`, `app` | +//! +//! A host answers `saved` only for a `save` that carries an `id`; an app that sends none treats a +//! save as done once it is posted. Fields marked `?` are optional, so the bridges shipped before +//! this crate already speak version 1. Those bridges put the app's own release in `ready`'s +//! `version` (`"0.14.0"`): a host treats any `version` it doesn't know as `"1"`. +//! +//! The app is hosted when its URL has `?host=parent` (framed by a same-origin page: messages go +//! through `postMessage`) or its canvas has `data-host="page"` (the page runs it directly: +//! messages are `CustomEvent`s on the canvas, the fields in `detail`). This module is the contract +//! itself and builds everywhere; [`web`] (wasm32 only) is the bridge an app's web shell calls. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; + +#[cfg(target_arch = "wasm32")] +pub mod web; + +/// The contract version an app reports in `ready` and a host in `host`. It changes only when a +/// field changes meaning; new optional fields keep it. +pub const VERSION: &str = "1"; + +/// Longest file name taken from a host or put in a message, in characters. +pub const MAX_NAME_CHARS: usize = 255; + +/// How a page hosts the app. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// `?host=parent`: a same-origin page frames the app; messages go through `postMessage`. + Parent, + /// `data-host="page"` on the app's canvas: the page runs the app itself; messages are + /// `CustomEvent`s on the canvas. + Page, +} + +impl Mode { + /// The mode for a page URL's query (with or without its leading `?`) and the canvas's + /// `data-host` attribute. `?host=parent` wins; anything else is not hosted. + pub fn detect(query: &str, data_host: Option<&str>) -> Option { + let query = query.strip_prefix('?').unwrap_or(query); + if query.split('&').any(|pair| pair == "host=parent") { + Some(Self::Parent) + } else if data_host == Some("page") { + Some(Self::Page) + } else { + None + } + } +} + +/// What an app posts to its host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outgoing { + Ready, + Save, + Dirty, + Failed, + Handoff, +} + +/// What a host sends to an app. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Incoming { + Host, + Open, + Saved, + HandoffDone, +} + +impl Outgoing { + pub const fn kind(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::Save => "save", + Self::Dirty => "dirty", + Self::Failed => "failed", + Self::Handoff => "handoff", + } + } +} + +impl Incoming { + pub const ALL: [Self; 4] = [Self::Host, Self::Open, Self::Saved, Self::HandoffDone]; + + pub const fn kind(self) -> &'static str { + match self { + Self::Host => "host", + Self::Open => "open", + Self::Saved => "saved", + Self::HandoffDone => "handoff-done", + } + } + + /// The incoming kind a message `type` names for `app`, or `None` for another app's message or + /// one this contract doesn't send to apps. + pub fn parse(app: &str, message_type: &str) -> Option { + let kind = message_type.strip_prefix(app)?.strip_prefix(':')?; + Self::ALL.into_iter().find(|k| k.kind() == kind) + } +} + +/// The `type` of a message: `:`. +pub fn message_type(app: &str, kind: &str) -> String { + format!("{app}:{kind}") +} + +/// A file name from a host, made safe to show and save under: path separators and control +/// characters dropped, surrounding space trimmed, at most [`MAX_NAME_CHARS`] characters, and +/// `fallback` when nothing is left. +pub fn file_name(raw: Option<&str>, fallback: &str) -> String { + let base = raw.unwrap_or_default().rsplit(['/', '\\']).next().unwrap_or_default(); + let cleaned: String = base.chars().filter(|c| !c.is_control()).collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() || trimmed == "." || trimmed == ".." { + return fallback.to_string(); + } + trimmed.chars().take(MAX_NAME_CHARS).collect() +} + +/// Remember `next` before the send. A page host can, while that send is still +/// running, report a different state; that later report must win. A failed send +/// restores the previous state only when nothing else recorded one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) fn note_dirty(dirty: &Cell>, next: bool, post: impl FnOnce() -> bool) { + if dirty.get() == Some(next) { + return; + } + let previous = dirty.replace(Some(next)); + if !post() && dirty.get() == Some(next) { + dirty.set(previous); + } +} + +/// Hands the host's messages to the app's handler one at a time. A page host dispatches its events +/// synchronously, so it can answer while the handler runs (a `saved` for the `save` the handler +/// just posted): that message waits here and is handled as soon as the running call returns, never +/// dropped and never given to a second, overlapping call. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) struct Inbox { + handler: RefCell, + queue: RefCell>, +} + +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +impl Inbox { + pub(crate) fn new(handler: F) -> Self { + Self { handler: RefCell::new(handler), queue: RefCell::new(VecDeque::new()) } + } + + pub(crate) fn deliver(&self, message: M) { + self.queue.borrow_mut().push_back(message); + // Already running: the outer call drains the queue. + let Ok(mut handler) = self.handler.try_borrow_mut() else { return }; + loop { + let next = self.queue.borrow_mut().pop_front(); + let Some(message) = next else { break }; + handler(message); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_query_switch_wins_over_the_canvas_attribute() { + assert_eq!(Mode::detect("?host=parent", None), Some(Mode::Parent)); + assert_eq!(Mode::detect("file=a.pdf&host=parent", Some("page")), Some(Mode::Parent)); + assert_eq!(Mode::detect("", Some("page")), Some(Mode::Page)); + assert_eq!(Mode::detect("?host=parents", None), None); + assert_eq!(Mode::detect("?xhost=parent", None), None); + assert_eq!(Mode::detect("", Some("frame")), None); + } + + #[test] + fn only_this_apps_incoming_kinds_parse() { + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:open"), Some(Incoming::Open)); + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:handoff-done"), Some(Incoming::HandoffDone)); + assert_eq!(Incoming::parse("pdfcraft", "cadcraft:open"), None, "another app's message"); + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:save"), None, "app-to-host kinds are not incoming"); + assert_eq!(Incoming::parse("pdf", "pdfcraft:open"), None, "an app id that is only a prefix"); + assert_eq!(Incoming::parse("pdfcraft", "pdfcraftopen"), None); + } + + #[test] + fn every_kind_round_trips_through_its_type() { + for kind in Incoming::ALL { + assert_eq!(Incoming::parse("gridcraft", &message_type("gridcraft", kind.kind())), Some(kind)); + } + assert_eq!(message_type("cadcraft", Outgoing::Handoff.kind()), "cadcraft:handoff"); + } + + #[test] + fn host_file_names_are_cleaned() { + assert_eq!(file_name(Some("plan.dxf"), "Drawing.dxf"), "plan.dxf"); + assert_eq!(file_name(Some(" ../../etc/passwd "), "x"), "passwd"); + assert_eq!(file_name(Some("C:\\Users\\sam\\notes.pdf"), "x"), "notes.pdf"); + assert_eq!(file_name(Some("a\u{0}b\nc.txt"), "x"), "abc.txt"); + assert_eq!(file_name(Some("dir/"), "Drawing.dxf"), "Drawing.dxf"); + assert_eq!(file_name(Some(".."), "x"), "x"); + assert_eq!(file_name(None, "Document.pdf"), "Document.pdf"); + assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS); + } + + #[test] + fn a_dirty_report_during_the_send_wins_and_a_failed_send_is_forgotten() { + let dirty = Cell::new(Some(false)); + note_dirty(&dirty, true, || { + note_dirty(&dirty, false, || true); + true + }); + assert_eq!(dirty.get(), Some(false)); + + let dirty = Cell::new(None); + note_dirty(&dirty, true, || false); + assert_eq!(dirty.get(), None); + + let dirty = Cell::new(Some(true)); + let mut sent = false; + note_dirty(&dirty, true, || { + sent = true; + true + }); + assert!(!sent); + assert_eq!(dirty.get(), Some(true)); + } + + #[test] + fn malformed_message_types_name_no_kind() { + for bad in + ["", ":", "pdfcraft", "pdfcraft:", ":open", "pdfcraft:open:extra", "pdfcraft:OPEN", "pdfcraft: open", "pdfcraftx:open", "pdfcraft/open"] + { + assert_eq!(Incoming::parse("pdfcraft", bad), None, "{bad:?}"); + } + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:open"), Some(Incoming::Open)); + // The app's own outgoing kinds are not messages to it. + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:save"), None); + } + + #[test] + fn host_file_names_with_hostile_parts_are_cleaned() { + // Only control characters, or only separators: nothing is left, so the fallback. + assert_eq!(file_name(Some("\u{0}\u{7}\n\t"), "Drawing.dxf"), "Drawing.dxf"); + assert_eq!(file_name(Some("///"), "Drawing.dxf"), "Drawing.dxf"); + assert_eq!(file_name(Some(" "), "Drawing.dxf"), "Drawing.dxf"); + // Mixed separators and a control character inside the last part. + assert_eq!(file_name(Some("a/b\\c\u{1b}[31m.txt"), "x"), "c[31m.txt"); + // Exactly at the limit stays whole; one over is cut; the limit counts characters, not bytes. + assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS)), "x").chars().count(), MAX_NAME_CHARS); + assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS + 1)), "x").chars().count(), MAX_NAME_CHARS); + assert_eq!(file_name(Some(&"\u{1F4C4}".repeat(300)), "x"), "\u{1F4C4}".repeat(MAX_NAME_CHARS)); + } + + #[test] + fn messages_that_arrive_while_the_handler_runs_wait_their_turn() { + use std::rc::Rc; + type Shared = Rc>>; + let seen = Rc::new(RefCell::new(Vec::new())); + let inbox: Rc>> = Rc::new(RefCell::new(None)); + let (log, me) = (seen.clone(), inbox.clone()); + let handler: Box = Box::new(move |m: &'static str| { + log.borrow_mut().push(format!("start {m}")); + // The page answers inside the handler, as a synchronous dispatchEvent does. + if m == "open" + && let Some(inbox) = me.borrow().clone() + { + inbox.deliver("saved"); + inbox.deliver("handoff-done"); + } + log.borrow_mut().push(format!("end {m}")); + }); + let shared = Rc::new(Inbox::new(handler)); + *inbox.borrow_mut() = Some(shared.clone()); + shared.deliver("open"); + assert_eq!(*seen.borrow(), ["start open", "end open", "start saved", "end saved", "start handoff-done", "end handoff-done"]); + shared.deliver("host"); + assert_eq!(seen.borrow().len(), 8, "the inbox is usable again after draining"); + } +} diff --git a/crates/web-host/src/web.rs b/crates/web-host/src/web.rs new file mode 100644 index 00000000..bca9a9b1 --- /dev/null +++ b/crates/web-host/src/web.rs @@ -0,0 +1,355 @@ +//! The bridge an app's web shell calls: find the host, hear its messages, post the app's. +//! +//! ```ignore +//! if let Some(host) = craft_web_host::web::Host::connect("pdfcraft", &canvas) { +//! host.listen(move |message| inbox.push(message)); +//! host.post_ready(env!("CARGO_PKG_VERSION"), false); +//! } +//! ``` + +use std::cell::Cell; +use std::rc::Rc; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast as _, JsValue}; +use web_sys::js_sys; + +use crate::{Inbox, Incoming, Mode, Outgoing, VERSION, file_name, message_type}; + +/// A file the host passes in, not yet copied into the app's memory: check its size first. +pub enum FileData { + /// `bytes`: an `ArrayBuffer` or `Uint8Array`, viewed in place. + Bytes(js_sys::Uint8Array), + /// `file`: a `File` or `Blob`, for the app to read in slices. + Blob(web_sys::Blob), +} + +impl FileData { + /// The file's length in bytes, from the data itself (a host's `size` field is not trusted). + pub fn size(&self) -> f64 { + match self { + Self::Bytes(array) => f64::from(array.length()), + Self::Blob(blob) => blob.size(), + } + } +} + +/// A message from the host. +pub enum Message { + /// What the host supports; `handoff`: it routes files the app hands off. + Host { version: String, handoff: bool }, + /// Open a document as File › Open would. `name` is already cleaned ([`crate::file_name`]). + Open { name: String, data: FileData }, + /// The save with this `id` is stored. + Saved { id: String }, + /// The host gave a handed-off file to the app that edits it. + HandoffDone { name: String, app: String }, +} + +enum Transport { + /// The same-origin parent window; only it is heard and answered. + Parent { window: web_sys::Window, parent: web_sys::Window, origin: String }, + /// The app's canvas; messages are `CustomEvent`s on it. + Page { canvas: web_sys::EventTarget }, +} + +/// The connection to the page hosting the app. +pub struct Host { + app: &'static str, + transport: Transport, + dirty: Cell>, +} + +impl Host { + /// The host for `app` (its id, as in `:ready`) running on `canvas`, or `None` when the + /// app is not hosted: no `?host=parent` and no `data-host="page"`, no framing parent, or a + /// parent on another origin (messages only go to this origin, so the browser would drop them). + pub fn connect(app: &'static str, canvas: &web_sys::Element) -> Option { + let window = web_sys::window()?; + let query = window.location().search().unwrap_or_default(); + let transport = match Mode::detect(&query, canvas.get_attribute("data-host").as_deref())? { + Mode::Parent => { + let parent = window.parent().ok().flatten()?; + if js_sys::Object::is(&parent, &window) { + return None; + } + let origin = window.location().origin().ok()?; + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } + Transport::Parent { window, parent, origin } + } + Mode::Page => Transport::Page { canvas: canvas.clone().into() }, + }; + Some(Self { app, transport, dirty: Cell::new(None) }) + } + + pub fn mode(&self) -> Mode { + match self.transport { + Transport::Parent { .. } => Mode::Parent, + Transport::Page { .. } => Mode::Page, + } + } + + /// Hear the host's messages for this app. The listener lives as long as the page. The + /// listeners are `Fn`: a page host can dispatch an event while one is still running (a + /// `saved` for the `save` the handler just posted), and wasm-bindgen refuses to re-enter an + /// `FnMut` closure before its body runs, so the message would never reach the queue. + pub fn listen(&self, handler: impl FnMut(Message) + 'static) { + let app = self.app; + let inbox = Rc::new(Inbox::new(handler)); + match &self.transport { + Transport::Parent { window, parent, origin } => { + let (parent, origin, inbox) = (parent.clone(), origin.clone(), inbox.clone()); + let on_message = Closure::::new(move |event: web_sys::MessageEvent| { + if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { + return; + } + let data = event.data(); + let Some(kind) = string(&data, "type").and_then(|t| Incoming::parse(app, &t)) else { return }; + if let Some(message) = read(kind, &data) { + inbox.deliver(message); + } + }); + if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { + on_message.forget(); + } + } + Transport::Page { canvas } => { + for kind in Incoming::ALL { + let inbox = inbox.clone(); + let on_event = Closure::::new(move |event: web_sys::CustomEvent| { + if let Some(message) = read(kind, &event.detail()) { + inbox.deliver(message); + } + }); + let name = message_type(app, kind.kind()); + if canvas.add_event_listener_with_callback(&name, on_event.as_ref().unchecked_ref()).is_ok() { + on_event.forget(); + } + } + } + } + } + + /// The app is listening: send a document now. `app_version` is the app's own release; + /// `reads_files`: it takes a `File`/`Blob` and reads it in slices. + pub fn post_ready(&self, app_version: &str, reads_files: bool) -> Result<(), String> { + self.post(Outgoing::Ready, &[("version", VERSION.into()), ("appVersion", app_version.into()), ("file", reads_files.into())], None) + } + + /// Unsaved changes appeared or went away. Repeats of the last state reported are not sent. + /// The new state is recorded before the send: a page host can report another state while the + /// event is still dispatching, and that later report wins. A failed send is forgotten. + pub fn post_dirty(&self, dirty: bool) { + crate::note_dirty(&self.dirty, dirty, || self.post(Outgoing::Dirty, &[("dirty", dirty.into())], None).is_ok()); + } + + /// Hand the host a file the app would otherwise download: Save, Save As, Export. With an + /// `id`, the host answers [`Message::Saved`] once it is stored; without one the save is done + /// once it is posted. The bytes go as a transferred `ArrayBuffer`. + pub fn post_save(&self, name: &str, bytes: &[u8], id: Option<&str>, save_as: bool) -> Result<(), String> { + let buffer = js_sys::Uint8Array::from(bytes).buffer(); + let mut fields = + vec![("name", JsValue::from(file_name(Some(name), "document"))), ("bytes", buffer.clone().into()), ("saveAs", save_as.into())]; + if let Some(id) = id { + fields.push(("id", id.into())); + } + self.post(Outgoing::Save, &fields, Some(&buffer)) + } + + /// The app couldn't start, couldn't take a document, or a save went unconfirmed (`id`). + pub fn post_failed(&self, error: &str, id: Option<&str>) -> Result<(), String> { + let mut fields = vec![("error", JsValue::from(error))]; + if let Some(id) = id { + fields.push(("id", id.into())); + } + self.post(Outgoing::Failed, &fields, None) + } + + /// Pass the host a file another app edits; it answers [`Message::HandoffDone`] or, when no + /// app it hosts takes the type, `failed`. Without a host, tell the user which app the file + /// needs instead. + pub fn post_handoff(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + let buffer = js_sys::Uint8Array::from(bytes).buffer(); + self.post(Outgoing::Handoff, &[("name", file_name(Some(name), "document").into()), ("bytes", buffer.clone().into())], Some(&buffer)) + } + + fn post(&self, kind: Outgoing, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> { + let message_type = message_type(self.app, kind.kind()); + let error = |e: JsValue| e.as_string().unwrap_or_else(|| format!("{e:?}")); + match &self.transport { + Transport::Parent { parent, origin, .. } => { + let message = object(&[("type", message_type.as_str().into())], fields)?; + match transfer { + Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)), + None => parent.post_message(&message, origin), + } + .map_err(error) + } + Transport::Page { canvas } => { + let init = web_sys::CustomEventInit::new(); + init.set_detail(&object(&[], fields)?.into()); + let event = web_sys::CustomEvent::new_with_event_init_dict(&message_type, &init).map_err(error)?; + canvas.dispatch_event(&event).map(|_| ()).map_err(error) + } + } + } +} + +/// The message a host's object describes, or `None` when a required field is missing. +fn read(kind: Incoming, data: &JsValue) -> Option { + Some(match kind { + Incoming::Host => Message::Host { + version: string(data, "version").unwrap_or_else(|| VERSION.to_string()), + handoff: get(data, "handoff").as_bool() == Some(true), + }, + Incoming::Open => { + let name = file_name(string(data, "name").as_deref(), "document"); + Message::Open { name, data: file_data(data)? } + } + Incoming::Saved => Message::Saved { id: string(data, "id")? }, + Incoming::HandoffDone => { + Message::HandoffDone { name: string(data, "name").unwrap_or_default(), app: string(data, "app").unwrap_or_default() } + } + }) +} + +/// `file` (a `Blob`; its `size` field is ignored for the Blob's own) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied. +fn file_data(data: &JsValue) -> Option { + let file = get(data, "file"); + if let Some(blob) = file.dyn_ref::() { + return Some(FileData::Blob(blob.clone())); + } + let bytes = get(data, "bytes"); + if let Some(array) = bytes.dyn_ref::() { + Some(FileData::Bytes(array.clone())) + } else if bytes.is_instance_of::() { + Some(FileData::Bytes(js_sys::Uint8Array::new(&bytes))) + } else { + None + } +} + +fn object(head: &[(&str, JsValue)], fields: &[(&str, JsValue)]) -> Result { + let m = js_sys::Object::new(); + for (key, value) in head.iter().chain(fields) { + js_sys::Reflect::set(&m, &JsValue::from_str(key), value).map_err(|_| "couldn't build the message".to_string())?; + } + Ok(m) +} + +fn get(data: &JsValue, key: &str) -> JsValue { + js_sys::Reflect::get(data, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED) +} + +fn string(data: &JsValue, key: &str) -> Option { + get(data, key).as_string() +} + +/// Node tests for the parsing of a host's objects: wrong shapes, missing fields, and sizes the +/// host states against the data it sends. No browser; run with `wasm-bindgen-test-runner`. +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + fn obj(fields: &[(&str, JsValue)]) -> JsValue { + object(&[], fields).expect("an object").into() + } + + fn buffer(len: u32) -> js_sys::ArrayBuffer { + js_sys::ArrayBuffer::new(len) + } + + #[wasm_bindgen_test] + fn an_open_without_bytes_or_a_blob_is_dropped() { + for data in [ + obj(&[("name", "a.pdf".into())]), + obj(&[("name", "a.pdf".into()), ("bytes", "text".into())]), + obj(&[("name", "a.pdf".into()), ("bytes", 42.into())]), + obj(&[("name", "a.pdf".into()), ("file", "not a blob".into())]), + JsValue::NULL, + JsValue::UNDEFINED, + JsValue::from_str("a string"), + JsValue::from_f64(7.0), + ] { + assert!(read(Incoming::Open, &data).is_none()); + } + } + + #[wasm_bindgen_test] + fn an_open_takes_bytes_as_an_array_buffer_or_a_uint8_array() { + let ab = buffer(3); + let array = js_sys::Uint8Array::new_with_length(5); + for (bytes, len) in [(JsValue::from(ab), 3.0), (JsValue::from(array), 5.0)] { + let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("name", "a.pdf".into()), ("bytes", bytes)])) else { + panic!("an open with bytes is read"); + }; + assert_eq!(data.size(), len); + } + } + + #[wasm_bindgen_test] + fn an_open_without_a_name_or_with_a_hostile_one_gets_a_clean_name() { + let named = |name: Option<&str>| { + let mut fields = vec![("bytes", JsValue::from(buffer(1)))]; + if let Some(n) = name { + fields.push(("name", n.into())); + } + match read(Incoming::Open, &obj(&fields)) { + Some(Message::Open { name, .. }) => name, + _ => panic!("an open with bytes is read"), + } + }; + assert_eq!(named(None), "document"); + assert_eq!(named(Some("../../etc/passwd")), "passwd"); + assert_eq!(named(Some("a\u{0}b.txt")), "ab.txt"); + assert_eq!(named(Some(&"x".repeat(300))).chars().count(), crate::MAX_NAME_CHARS); + } + + #[wasm_bindgen_test] + fn the_size_is_the_data_s_own_not_the_hosts_claim() { + // An ArrayBuffer of 64 MiB with a `size` field of 1: the bytes are viewed in place, not + // copied, and the app sees their real length before it decides to read them. + let ab = buffer(64 << 20); + let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("bytes", ab.clone().into()), ("size", 1.into())])) else { + panic!("an open with bytes is read"); + }; + assert_eq!(data.size(), f64::from(64u32 << 20)); + let FileData::Bytes(view) = &data else { panic!("bytes stay bytes") }; + assert!(js_sys::Object::is(&view.buffer(), &ab), "a view of the host's buffer, not a copy"); + + // A Blob of 5 bytes with a `size` field of a terabyte. + let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&"hello".into())).expect("a Blob"); + let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("file", blob.into()), ("size", 1e12.into())])) else { + panic!("an open with a Blob is read"); + }; + assert_eq!(data.size(), 5.0); + } + + #[wasm_bindgen_test] + fn a_saved_needs_a_string_id() { + assert!(matches!(read(Incoming::Saved, &obj(&[("id", "s1".into())])), Some(Message::Saved { id }) if id == "s1")); + for data in [obj(&[]), obj(&[("id", 3.into())]), JsValue::NULL, JsValue::from_str("s1")] { + assert!(read(Incoming::Saved, &data).is_none()); + } + } + + #[wasm_bindgen_test] + fn a_host_message_and_a_handoff_done_read_whatever_fields_exist() { + // Nothing required: a missing version is the contract's, `handoff` is true only for `true`. + for data in [JsValue::NULL, obj(&[]), obj(&[("handoff", "yes".into())]), obj(&[("handoff", 1.into())])] { + let Some(Message::Host { version, handoff }) = read(Incoming::Host, &data) else { panic!("a host message is read") }; + assert_eq!(version, crate::VERSION); + assert!(!handoff); + } + assert!(matches!(read(Incoming::Host, &obj(&[("handoff", true.into())])), Some(Message::Host { handoff: true, .. }))); + assert!(matches!( + read(Incoming::HandoffDone, &obj(&[("name", "a.pdf".into()), ("app", "pdfcraft".into())])), + Some(Message::HandoffDone { name, app }) if name == "a.pdf" && app == "pdfcraft" + )); + assert!(matches!(read(Incoming::HandoffDone, &JsValue::NULL), Some(Message::HandoffDone { name, app }) if name.is_empty() && app.is_empty())); + } +}