From da0b274a2824fd23834551d5dd35de9b657f6863 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:40:02 +1030 Subject: [PATCH 1/7] craft-web-host: the host messages as one contract and one bridge The messages between a Craft app's web build and the page that hosts it (ready, host, open, save, saved, dirty, failed, handoff, handoff-done), as proposed on #358. The contract part (message kinds, the version, the ?host=parent / data-host="page" switch, file-name clean-up) builds everywhere and is tested natively; the web module (wasm32) is the bridge an app's web shell calls: same-origin parent only, files as bytes or a Blob without copying, saves as a transferred ArrayBuffer, dirty repeats suppressed. Compatible with the bridges shipped before it: optional fields stay optional, and a host treats an unknown ready.version as 1. --- Cargo.lock | 8 ++ Cargo.toml | 1 + README.md | 1 + crates/web-host/Cargo.toml | 16 +++ crates/web-host/README.md | 49 ++++++++ crates/web-host/src/lib.rs | 176 ++++++++++++++++++++++++++ crates/web-host/src/web.rs | 247 +++++++++++++++++++++++++++++++++++++ 7 files changed, 498 insertions(+) create mode 100644 crates/web-host/Cargo.toml create mode 100644 crates/web-host/README.md create mode 100644 crates/web-host/src/lib.rs create mode 100644 crates/web-host/src/web.rs diff --git a/Cargo.lock b/Cargo.lock index 4c12df4f..476fbdb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1192,6 +1192,14 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-web-host" +version = "0.1.0" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "craft-window" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7ebee590..ff8e52aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } 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..7578b96f 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ":` 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. +- `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 side of the same contract is `@craft/app` (storytold/craft-libs#413), which mounts a web build and speaks these messages. diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rs new file mode 100644 index 00000000..c4aa7adb --- /dev/null +++ b/crates/web-host/src/lib.rs @@ -0,0 +1,176 @@ +//! 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. + +#[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() +} + +#[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); + } +} diff --git a/crates/web-host/src/web.rs b/crates/web-host/src/web.rs new file mode 100644 index 00000000..92f91b71 --- /dev/null +++ b/crates/web-host/src/web.rs @@ -0,0 +1,247 @@ +//! 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, RefCell}; +use std::rc::Rc; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast as _, JsValue}; +use web_sys::js_sys; + +use crate::{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` + `size`: a `File` or `Blob`, for the app to read in slices. + Blob { blob: web_sys::Blob, size: f64 }, +} + +impl FileData { + /// The file's length in bytes. + pub fn size(&self) -> f64 { + match self { + Self::Bytes(array) => f64::from(array.length()), + Self::Blob { size, .. } => *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. + pub fn listen(&self, handler: impl FnMut(Message) + 'static) { + let app = self.app; + let handler = Rc::new(RefCell::new(handler)); + match &self.transport { + Transport::Parent { window, parent, origin } => { + let (parent, origin, handler) = (parent.clone(), origin.clone(), handler.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) { + (handler.borrow_mut())(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 handler = handler.clone(); + let on_event = Closure::::new(move |event: web_sys::CustomEvent| { + if let Some(message) = read(kind, &event.detail()) { + (handler.borrow_mut())(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. + pub fn post_dirty(&self, dirty: bool) { + if self.dirty.get() != Some(dirty) && self.post(Outgoing::Dirty, &[("dirty", dirty.into())], None).is_ok() { + self.dirty.set(Some(dirty)); + } + } + + /// 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`, with `size`) 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::() { + let size = get(data, "size").as_f64().unwrap_or_else(|| blob.size()); + return Some(FileData::Blob { blob: blob.clone(), size }); + } + 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() +} From ae1fa48449d1ea9b7ca180f29709c9bbca127cad Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:50:46 +1030 Subject: [PATCH 2/7] craft-web-host: queue messages that arrive while the handler runs A page host dispatches its events synchronously, so it can answer (a saved for the save the handler just posted) while the handler is still running. The nested borrow_mut then panicked. Both transports now hand messages to an Inbox that queues each one and drains the queue one call at a time, in order; nothing is dropped. The new test fails with the old borrow ("already borrowed"). FileData::size() reads a Blob's length from the Blob; a host's size field is no longer trusted for an app's size check. lib.rs gets the crate-level deny of unwrap, expect, panic and the like. --- crates/web-host/README.md | 2 ++ crates/web-host/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++ crates/web-host/src/web.rs | 27 +++++++++-------- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/crates/web-host/README.md b/crates/web-host/README.md index 1b9712f7..bb12dd46 100644 --- a/crates/web-host/README.md +++ b/crates/web-host/README.md @@ -27,6 +27,8 @@ 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 diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rs index c4aa7adb..ad583171 100644 --- a/crates/web-host/src/lib.rs +++ b/crates/web-host/src/lib.rs @@ -26,6 +26,11 @@ //! 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::RefCell; +use std::collections::VecDeque; + #[cfg(target_arch = "wasm32")] pub mod web; @@ -130,6 +135,34 @@ pub fn file_name(raw: Option<&str>, fallback: &str) -> String { trimmed.chars().take(MAX_NAME_CHARS).collect() } +/// 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::*; @@ -173,4 +206,30 @@ mod tests { assert_eq!(file_name(None, "Document.pdf"), "Document.pdf"); assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), 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 index 92f91b71..e86df4de 100644 --- a/crates/web-host/src/web.rs +++ b/crates/web-host/src/web.rs @@ -7,29 +7,29 @@ //! } //! ``` -use std::cell::{Cell, RefCell}; +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::{Incoming, Mode, Outgoing, VERSION, file_name, message_type}; +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` + `size`: a `File` or `Blob`, for the app to read in slices. - Blob { blob: web_sys::Blob, size: f64 }, + /// `file`: a `File` or `Blob`, for the app to read in slices. + Blob(web_sys::Blob), } impl FileData { - /// The file's length in bytes. + /// 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 { size, .. } => *size, + Self::Blob(blob) => blob.size(), } } } @@ -95,10 +95,10 @@ impl Host { /// Hear the host's messages for this app. The listener lives as long as the page. pub fn listen(&self, handler: impl FnMut(Message) + 'static) { let app = self.app; - let handler = Rc::new(RefCell::new(handler)); + let inbox = Rc::new(Inbox::new(handler)); match &self.transport { Transport::Parent { window, parent, origin } => { - let (parent, origin, handler) = (parent.clone(), origin.clone(), handler.clone()); + 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; @@ -106,7 +106,7 @@ impl Host { 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) { - (handler.borrow_mut())(message); + inbox.deliver(message); } }); if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { @@ -115,10 +115,10 @@ impl Host { } Transport::Page { canvas } => { for kind in Incoming::ALL { - let handler = handler.clone(); + let inbox = inbox.clone(); let on_event = Closure::::new(move |event: web_sys::CustomEvent| { if let Some(message) = read(kind, &event.detail()) { - (handler.borrow_mut())(message); + inbox.deliver(message); } }); let name = message_type(app, kind.kind()); @@ -213,12 +213,11 @@ fn read(kind: Incoming, data: &JsValue) -> Option { }) } -/// `file` (a `Blob`, with `size`) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied. +/// `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::() { - let size = get(data, "size").as_f64().unwrap_or_else(|| blob.size()); - return Some(FileData::Blob { blob: blob.clone(), size }); + return Some(FileData::Blob(blob.clone())); } let bytes = get(data, "bytes"); if let Some(array) = bytes.dyn_ref::() { From 5fcc3923025dc1503f1e39a187057a164dbd2533 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:00:10 +1030 Subject: [PATCH 3/7] craft-web-host: Fn listeners, so a nested event reaches the queue wasm-bindgen throws "closure invoked recursively" when an FnMut closure is called while it runs, before its body can queue the message. The listeners only read their captures, so they are Fn; the handler's state stays behind the Inbox's RefCell. --- crates/web-host/src/web.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/web-host/src/web.rs b/crates/web-host/src/web.rs index e86df4de..969a79e5 100644 --- a/crates/web-host/src/web.rs +++ b/crates/web-host/src/web.rs @@ -92,14 +92,17 @@ impl Host { } } - /// Hear the host's messages for this app. The listener lives as long as the 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| { + 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; } @@ -116,7 +119,7 @@ impl Host { Transport::Page { canvas } => { for kind in Incoming::ALL { let inbox = inbox.clone(); - let on_event = Closure::::new(move |event: web_sys::CustomEvent| { + let on_event = Closure::::new(move |event: web_sys::CustomEvent| { if let Some(message) = read(kind, &event.detail()) { inbox.deliver(message); } From ce99c8d61c08e6c8e30434b85313f82a2dda2f5c Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:58:50 +0800 Subject: [PATCH 4/7] craft-web-host: tests for message types, file names and the host objects the bridge parses, and a CI workflow that runs them --- .github/workflows/web-host.yml | 45 ++++++++++++++ Cargo.lock | 1 + crates/web-host/Cargo.toml | 3 + crates/web-host/src/lib.rs | 26 ++++++++ crates/web-host/src/web.rs | 106 +++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 .github/workflows/web-host.yml 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 476fbdb1..d9c92377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,7 @@ name = "craft-web-host" version = "0.1.0" dependencies = [ "wasm-bindgen", + "wasm-bindgen-test", "web-sys", ] diff --git a/crates/web-host/Cargo.toml b/crates/web-host/Cargo.toml index fa9342b9..1f50f570 100644 --- a/crates/web-host/Cargo.toml +++ b/crates/web-host/Cargo.toml @@ -12,5 +12,8 @@ repository.workspace = true 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/src/lib.rs b/crates/web-host/src/lib.rs index ad583171..7908dcc2 100644 --- a/crates/web-host/src/lib.rs +++ b/crates/web-host/src/lib.rs @@ -207,6 +207,32 @@ mod tests { assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS); } + #[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; diff --git a/crates/web-host/src/web.rs b/crates/web-host/src/web.rs index 969a79e5..b899bb70 100644 --- a/crates/web-host/src/web.rs +++ b/crates/web-host/src/web.rs @@ -247,3 +247,109 @@ fn get(data: &JsValue, key: &str) -> JsValue { 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())); + } +} From a94d849df2db5fc2c0c8e9ab50bf0f327084bbf4 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:30:49 +0800 Subject: [PATCH 5/7] web-api: one window. command API for the web builds --- .github/workflows/web-api.yml | 38 +++++ Cargo.lock | 11 ++ Cargo.toml | 1 + README.md | 3 + crates/web-api/Cargo.toml | 23 +++ crates/web-api/README.md | 61 ++++++++ crates/web-api/src/lib.rs | 40 +++++ crates/web-api/src/methods.rs | 58 ++++++++ crates/web-api/src/page.rs | 273 ++++++++++++++++++++++++++++++++++ crates/web-api/tests/page.rs | 161 ++++++++++++++++++++ 10 files changed, 669 insertions(+) create mode 100644 .github/workflows/web-api.yml create mode 100644 crates/web-api/Cargo.toml create mode 100644 crates/web-api/README.md create mode 100644 crates/web-api/src/lib.rs create mode 100644 crates/web-api/src/methods.rs create mode 100644 crates/web-api/src/page.rs create mode 100644 crates/web-api/tests/page.rs 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/Cargo.lock b/Cargo.lock index 4c12df4f..e42d8a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1192,6 +1192,17 @@ 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-window" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7ebee590..a76f4305 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ 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" } egui = { version = "0.37", default-features = false } rayon = "1" diff --git a/README.md b/README.md index e45d6e0f..dafd3013 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's + JSON control channel. - [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/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..e78957ab --- /dev/null +++ b/crates/web-api/README.md @@ -0,0 +1,61 @@ +# 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 app's own host messages (`:open`, `:save`, `:dirty`, …) and its +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}"); +} From 50256ca66562558dbdb713a20ced588e14037793 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:44:52 +0100 Subject: [PATCH 6/7] fix(web-host): record dirty before a synchronous page send A page host can report a new dirty state while post_dirty's event is still dispatching. Remember the state first so that later report wins, and restore the previous state only when the send fails and nothing else recorded one. Co-authored-by: Cursor --- crates/web-host/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++++- crates/web-host/src/web.rs | 6 +++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rs index 7908dcc2..0d6c0bb1 100644 --- a/crates/web-host/src/lib.rs +++ b/crates/web-host/src/lib.rs @@ -28,7 +28,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::VecDeque; #[cfg(target_arch = "wasm32")] @@ -135,6 +135,20 @@ pub fn file_name(raw: Option<&str>, fallback: &str) -> 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 @@ -207,6 +221,29 @@ mod tests { 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 diff --git a/crates/web-host/src/web.rs b/crates/web-host/src/web.rs index b899bb70..bca9a9b1 100644 --- a/crates/web-host/src/web.rs +++ b/crates/web-host/src/web.rs @@ -140,10 +140,10 @@ impl Host { } /// 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) { - if self.dirty.get() != Some(dirty) && self.post(Outgoing::Dirty, &[("dirty", dirty.into())], None).is_ok() { - self.dirty.set(Some(dirty)); - } + 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 From a231e517c3b371e1bf0ee8af71ed807424a9a05d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:20:56 +0100 Subject: [PATCH 7/7] craft-update: zip without zlib-rs so apps keep flate2's miniz backend (#608) zip's `deflate` feature pulls in deflate-flate2-zlib-rs (and zopfli). Cargo unifies flate2's features across the whole app, so on Windows every flate2 user in an app depending on craft-update switched to zlib-rs, whose level 1 is a static-Huffman quick mode that can produce output larger than its input (Lightcraft's 16-bit ZIP TIFFs on Windows came out bigger than uncompressed). Use `deflate-flate2` with flate2's default miniz_oxide backend instead; the updater only reads Deflate zips, which that covers. --- Cargo.lock | 13 ------------- crates/update/Cargo.toml | 7 ++++++- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c9994c6..9cb7a068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7441,7 +7441,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -7456,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/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"