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::() {