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/5] 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/5] 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/5] 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/5] 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 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 5/5] 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