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] 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() +}