Files
craft-libs/crates/web-host/src/web.rs
T
FredAppsandCursor 50256ca665 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 <cursoragent@cursor.com>
2026-11-05 09:44:52 +01:00

356 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The bridge an app's web shell calls: find the host, hear its messages, post the app's.
//!
//! ```ignore
//! if let Some(host) = craft_web_host::web::Host::connect("pdfcraft", &canvas) {
//! host.listen(move |message| inbox.push(message));
//! host.post_ready(env!("CARGO_PKG_VERSION"), false);
//! }
//! ```
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast as _, JsValue};
use web_sys::js_sys;
use crate::{Inbox, Incoming, Mode, Outgoing, VERSION, file_name, message_type};
/// A file the host passes in, not yet copied into the app's memory: check its size first.
pub enum FileData {
/// `bytes`: an `ArrayBuffer` or `Uint8Array`, viewed in place.
Bytes(js_sys::Uint8Array),
/// `file`: a `File` or `Blob`, for the app to read in slices.
Blob(web_sys::Blob),
}
impl FileData {
/// The file's length in bytes, from the data itself (a host's `size` field is not trusted).
pub fn size(&self) -> f64 {
match self {
Self::Bytes(array) => f64::from(array.length()),
Self::Blob(blob) => blob.size(),
}
}
}
/// A message from the host.
pub enum Message {
/// What the host supports; `handoff`: it routes files the app hands off.
Host { version: String, handoff: bool },
/// Open a document as File › Open would. `name` is already cleaned ([`crate::file_name`]).
Open { name: String, data: FileData },
/// The save with this `id` is stored.
Saved { id: String },
/// The host gave a handed-off file to the app that edits it.
HandoffDone { name: String, app: String },
}
enum Transport {
/// The same-origin parent window; only it is heard and answered.
Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },
/// The app's canvas; messages are `CustomEvent`s on it.
Page { canvas: web_sys::EventTarget },
}
/// The connection to the page hosting the app.
pub struct Host {
app: &'static str,
transport: Transport,
dirty: Cell<Option<bool>>,
}
impl Host {
/// The host for `app` (its id, as in `<app>: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<Self> {
let window = web_sys::window()?;
let query = window.location().search().unwrap_or_default();
let transport = match Mode::detect(&query, canvas.get_attribute("data-host").as_deref())? {
Mode::Parent => {
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Transport::Parent { window, parent, origin }
}
Mode::Page => Transport::Page { canvas: canvas.clone().into() },
};
Some(Self { app, transport, dirty: Cell::new(None) })
}
pub fn mode(&self) -> Mode {
match self.transport {
Transport::Parent { .. } => Mode::Parent,
Transport::Page { .. } => Mode::Page,
}
}
/// Hear the host's messages for this app. The listener lives as long as the page. The
/// listeners are `Fn`: a page host can dispatch an event while one is still running (a
/// `saved` for the `save` the handler just posted), and wasm-bindgen refuses to re-enter an
/// `FnMut` closure before its body runs, so the message would never reach the queue.
pub fn listen(&self, handler: impl FnMut(Message) + 'static) {
let app = self.app;
let inbox = Rc::new(Inbox::new(handler));
match &self.transport {
Transport::Parent { window, parent, origin } => {
let (parent, origin, inbox) = (parent.clone(), origin.clone(), inbox.clone());
let on_message = Closure::<dyn Fn(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
let Some(kind) = string(&data, "type").and_then(|t| Incoming::parse(app, &t)) else { return };
if let Some(message) = read(kind, &data) {
inbox.deliver(message);
}
});
if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
on_message.forget();
}
}
Transport::Page { canvas } => {
for kind in Incoming::ALL {
let inbox = inbox.clone();
let on_event = Closure::<dyn Fn(web_sys::CustomEvent)>::new(move |event: web_sys::CustomEvent| {
if let Some(message) = read(kind, &event.detail()) {
inbox.deliver(message);
}
});
let name = message_type(app, kind.kind());
if canvas.add_event_listener_with_callback(&name, on_event.as_ref().unchecked_ref()).is_ok() {
on_event.forget();
}
}
}
}
}
/// The app is listening: send a document now. `app_version` is the app's own release;
/// `reads_files`: it takes a `File`/`Blob` and reads it in slices.
pub fn post_ready(&self, app_version: &str, reads_files: bool) -> Result<(), String> {
self.post(Outgoing::Ready, &[("version", VERSION.into()), ("appVersion", app_version.into()), ("file", reads_files.into())], None)
}
/// Unsaved changes appeared or went away. Repeats of the last state reported are not sent.
/// The new state is recorded before the send: a page host can report another state while the
/// event is still dispatching, and that later report wins. A failed send is forgotten.
pub fn post_dirty(&self, dirty: bool) {
crate::note_dirty(&self.dirty, dirty, || self.post(Outgoing::Dirty, &[("dirty", dirty.into())], None).is_ok());
}
/// Hand the host a file the app would otherwise download: Save, Save As, Export. With an
/// `id`, the host answers [`Message::Saved`] once it is stored; without one the save is done
/// once it is posted. The bytes go as a transferred `ArrayBuffer`.
pub fn post_save(&self, name: &str, bytes: &[u8], id: Option<&str>, save_as: bool) -> Result<(), String> {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let mut fields =
vec![("name", JsValue::from(file_name(Some(name), "document"))), ("bytes", buffer.clone().into()), ("saveAs", save_as.into())];
if let Some(id) = id {
fields.push(("id", id.into()));
}
self.post(Outgoing::Save, &fields, Some(&buffer))
}
/// The app couldn't start, couldn't take a document, or a save went unconfirmed (`id`).
pub fn post_failed(&self, error: &str, id: Option<&str>) -> Result<(), String> {
let mut fields = vec![("error", JsValue::from(error))];
if let Some(id) = id {
fields.push(("id", id.into()));
}
self.post(Outgoing::Failed, &fields, None)
}
/// Pass the host a file another app edits; it answers [`Message::HandoffDone`] or, when no
/// app it hosts takes the type, `failed`. Without a host, tell the user which app the file
/// needs instead.
pub fn post_handoff(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
self.post(Outgoing::Handoff, &[("name", file_name(Some(name), "document").into()), ("bytes", buffer.clone().into())], Some(&buffer))
}
fn post(&self, kind: Outgoing, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {
let message_type = message_type(self.app, kind.kind());
let error = |e: JsValue| e.as_string().unwrap_or_else(|| format!("{e:?}"));
match &self.transport {
Transport::Parent { parent, origin, .. } => {
let message = object(&[("type", message_type.as_str().into())], fields)?;
match transfer {
Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),
None => parent.post_message(&message, origin),
}
.map_err(error)
}
Transport::Page { canvas } => {
let init = web_sys::CustomEventInit::new();
init.set_detail(&object(&[], fields)?.into());
let event = web_sys::CustomEvent::new_with_event_init_dict(&message_type, &init).map_err(error)?;
canvas.dispatch_event(&event).map(|_| ()).map_err(error)
}
}
}
}
/// The message a host's object describes, or `None` when a required field is missing.
fn read(kind: Incoming, data: &JsValue) -> Option<Message> {
Some(match kind {
Incoming::Host => Message::Host {
version: string(data, "version").unwrap_or_else(|| VERSION.to_string()),
handoff: get(data, "handoff").as_bool() == Some(true),
},
Incoming::Open => {
let name = file_name(string(data, "name").as_deref(), "document");
Message::Open { name, data: file_data(data)? }
}
Incoming::Saved => Message::Saved { id: string(data, "id")? },
Incoming::HandoffDone => {
Message::HandoffDone { name: string(data, "name").unwrap_or_default(), app: string(data, "app").unwrap_or_default() }
}
})
}
/// `file` (a `Blob`; its `size` field is ignored for the Blob's own) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied.
fn file_data(data: &JsValue) -> Option<FileData> {
let file = get(data, "file");
if let Some(blob) = file.dyn_ref::<web_sys::Blob>() {
return Some(FileData::Blob(blob.clone()));
}
let bytes = get(data, "bytes");
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(FileData::Bytes(array.clone()))
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(FileData::Bytes(js_sys::Uint8Array::new(&bytes)))
} else {
None
}
}
fn object(head: &[(&str, JsValue)], fields: &[(&str, JsValue)]) -> Result<js_sys::Object, String> {
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<String> {
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()));
}
}