collective

Browse / github.com/storytold/designcraft / patch

designcraft-web: postMessage host bridge (?host=parent)

designcraft-web: postMessage host bridge (?host=parent) Lets a same-origin page embed DesignCraft in an `<iframe>` (a file manager, say) and own its files, as the other craft web apps do: - `designcraft:ready {version}` when listening; `designcraft:open {name, bytes}` (ArrayBuffer or Uint8Array) opens like File › Open. - Save and Save As post `designcraft:save {name, bytes}` (transferred ArrayBuffer, `.designcraft`) instead of downloading and mark the document saved; exports post the same with `export: true`. - `designcraft:dirty {dirty}` when unsaved changes appear or go away in any open document; `designcraft:failed {error}` when the app can't s

Status
open
Score
0.00
Adopters
0 other operators
Author
halldave (imported from pull request #1538, open)
Base
935b817b8972f274fab5131a9372541643acbbc1
Size
+492 −6 in 5 files
Review
pending
Shared
2026-10-28
Id
a5dd203be38286d16339e06b

How it went

No other operator has reported using it yet.

Diff

raw · apps/designcraft-web/Cargo.toml apps/designcraft-web/src/bridge.rs apps/designcraft-web/src/host.rs apps/designcraft-web/src/main.rs apps/designcraft-web/src/web.rs

diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml--- a/apps/designcraft-web/Cargo.toml+++ b/apps/designcraft-web/Cargo.toml@@ -30,4 +30,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3"-web-sys = { version = "0.3", features = ["Event", "EventTarget", "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }+web-sys = { version = "0.3", features = ["Event", "EventTarget", "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent"] }diff --git a/apps/designcraft-web/src/bridge.rs b/apps/designcraft-web/src/bridge.rsnew file mode 100644--- /dev/null+++ b/apps/designcraft-web/src/bridge.rs@@ -0,0 +1,245 @@+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent+//! origin the bridge may talk to, and which saves the parent has confirmed.++use std::collections::BTreeMap;++/// How long the parent has to answer `designcraft:save` with `designcraft:saved` (milliseconds).+pub const SAVE_ACK_TIMEOUT_MS: f64 = 30_000.0;++/// The origin the bridge posts to and hears from, or `None` for no bridge (saves download).+///+/// - `own`: this page's origin.+/// - `parent`: the parent window's origin, `None` when reading it threw (a cross-origin parent).+/// - `allowed`: the `?host_origin=` query value, an origin the embedding page vouches for.+///+/// A same-origin parent is accepted. A cross-origin parent is accepted only when `allowed` is a+/// valid URL origin (`scheme://host[:port]`, nothing else); messages then go to that exact origin,+/// so the browser drops them if the parent is anything else. Opaque origins (`"null"`) never match.+pub fn target_origin(own: &str, parent: Option<&str>, allowed: Option<&str>) -> Option<String> {+    if let Some(parent) = parent+        && parent == own+        && is_origin(own)+    {+        return Some(own.to_string());+    }+    canonical_origin(allowed?.trim())+}++/// `s` as a browser serialises an origin (lowercase scheme and host, no default port), or `None`+/// when it isn't one [`is_origin`] accepts. `MessageEvent.origin` is compared with this string+/// exactly, so `https://Files.Example:443` must become `https://files.example` to ever match.+pub fn canonical_origin(s: &str) -> Option<String> {+    if !is_origin(s) {+        return None;+    }+    let (scheme, rest) = s.split_once("://")?;+    let scheme = scheme.to_ascii_lowercase();+    let (host, port) = match rest.split_once(':') {+        Some((h, p)) => (h, Some(p.parse::<u16>().ok()?)),+        None => (rest, None),+    };+    let host = host.to_ascii_lowercase();+    match (scheme.as_str(), port) {+        ("https", Some(443)) | ("http", Some(80)) | (_, None) => Some(format!("{scheme}://{host}")),+        (_, Some(port)) => Some(format!("{scheme}://{host}:{port}")),+    }+}++/// True for a URL origin: `http(s)://host[:port]` with no path, query, fragment or credentials,+/// and only in forms whose browser serialisation [`canonical_origin`] can produce: an ASCII host+/// (an IDN must be given in punycode, `xn--…`), and a numeric host only as a dotted-decimal IPv4.+pub fn is_origin(s: &str) -> bool {+    let Some((scheme, rest)) = s.split_once("://") else { return false };+    if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) || rest.is_empty() {+        return false;+    }+    // IPv6 literals aren't accepted; name the host instead.+    if rest.contains(['/', '?', '#', '@', ' ', '\\', '[', ']']) {+        return false;+    }+    let (host, port) = match rest.split_once(':') {+        Some((h, p)) => (h, Some(p)),+        None => (rest, None),+    };+    if host.is_empty() || !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) {+        return false;+    }+    // Browsers rewrite numeric hosts (`0x7f.1`, `127.1`, `010.0.0.1`) to dotted decimal; accept+    // only the form they would produce.+    let last = host.trim_end_matches('.').rsplit('.').next().unwrap_or("");+    if last.bytes().all(|b| b.is_ascii_digit()) || last.to_ascii_lowercase().starts_with("0x") {+        let parts: Vec<&str> = host.split('.').collect();+        let decimal = |p: &&str| !p.is_empty() && (*p == "0" || !p.starts_with('0')) && p.parse::<u8>().is_ok();+        if parts.len() != 4 || !parts.iter().all(decimal) {+            return false;+        }+    }+    match port {+        Some(p) => p.bytes().all(|b| b.is_ascii_digit()) && p.parse::<u16>().is_ok(),+        None => true,+    }+}++/// Saves posted to the parent and not yet confirmed.+#[derive(Debug, Default)]+pub struct SaveAcks {+    next_id: u64,+    /// Request id → (file name, deadline in ms, whether it is the session itself).+    pending: BTreeMap<u64, (String, f64, bool)>,+    /// A session save failed or went unanswered: the session stays dirty until one is confirmed.+    unconfirmed: bool,+}++impl SaveAcks {+    /// Record a save posted at `now`; returns its request id.+    pub fn posted(&mut self, name: &str, session: bool, now: f64) -> u64 {+        self.next_id = self.next_id.wrapping_add(1);+        self.pending.insert(self.next_id, (name.to_string(), now + SAVE_ACK_TIMEOUT_MS, session));+        self.next_id+    }++    /// The parent confirmed request `id`. False when no such save is waiting.+    pub fn saved(&mut self, id: u64) -> bool {+        match self.pending.remove(&id) {+            Some((_, _, session)) => {+                if session {+                    self.unconfirmed = false;+                }+                true+            }+            None => false,+        }+    }++    /// The parent reported request `id` failed; returns its name.+    pub fn failed(&mut self, id: u64) -> Option<String> {+        let (name, _, session) = self.pending.remove(&id)?;+        self.unconfirmed |= session;+        Some(name)+    }++    /// Saves past their deadline at `now`, removed and returned as `(id, name)`.+    pub fn expire(&mut self, now: f64) -> Vec<(u64, String)> {+        let late: Vec<u64> = self.pending.iter().filter(|(_, (_, deadline, _))| now >= *deadline).map(|(id, _)| *id).collect();+        late.into_iter()+            .filter_map(|id| {+                let (name, _, session) = self.pending.remove(&id)?;+                self.unconfirmed |= session;+                Some((id, name))+            })+            .collect()+    }++    /// Whether any save is still waiting for the parent.+    pub fn waiting(&self) -> bool {+        !self.pending.is_empty()+    }++    /// The unsaved-changes state to report: the engine's, held dirty while a session save is+    /// unconfirmed.+    pub fn dirty(&self, engine_dirty: bool) -> bool {+        engine_dirty || self.unconfirmed || self.pending.values().any(|(_, _, session)| *session)+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    const OWN: &str = "https://app.example";++    #[test]+    fn same_origin_parent_enables_the_bridge() {+        assert_eq!(target_origin(OWN, Some(OWN), None).as_deref(), Some(OWN));+    }++    #[test]+    fn cross_origin_parent_disables_the_bridge() {+        // Reading a cross-origin parent's origin throws (None) or names another origin.+        assert_eq!(target_origin(OWN, None, None), None);+        assert_eq!(target_origin(OWN, Some("https://evil.example"), None), None);+        assert_eq!(target_origin(OWN, Some("null"), None), None);+        assert_eq!(target_origin("null", Some("null"), None), None);+    }++    #[test]+    fn host_origin_parameter_must_be_an_exact_origin() {+        let files = "https://files.example:8443";+        assert_eq!(target_origin(OWN, None, Some(files)).as_deref(), Some(files));+        for bad in+            ["*", "null", "files.example", "https://files.example/", "https://a@files.example", "javascript://x", "https://", "https://x:99999"]+        {+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");+        }+    }++    #[test]+    fn host_origin_is_stored_as_the_browser_serialises_it() {+        // `MessageEvent.origin` is lowercase with no default port; the stored origin must match it.+        for (given, stored) in [+            ("https://Files.Example", "https://files.example"),+            ("HTTPS://FILES.EXAMPLE", "https://files.example"),+            ("https://files.example:443", "https://files.example"),+            ("http://files.example:80", "http://files.example"),+            ("http://files.example:443", "http://files.example:443"),+            ("https://files.example:80", "https://files.example:80"),+            ("https://files.example:08443", "https://files.example:8443"),+            ("https://xn--bcher-kva.example", "https://xn--bcher-kva.example"),+            ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),+            ("  https://Files.Example:443  ", "https://files.example"),+        ] {+            assert_eq!(target_origin(OWN, None, Some(given)).as_deref(), Some(stored), "{given}");+        }+    }++    #[test]+    fn host_origin_a_browser_would_serialise_differently_is_refused() {+        for bad in [+            "https://b\u{fc}cher.example",+            "https://files.example:",+            "https://0x7f.0.0.1",+            "https://127.1",+            "https://010.0.0.1",+            "https://256.0.0.1",+            "https://files%2eexample",+            "https://[::1]",+        ] {+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");+        }+    }++    #[test]+    fn save_stays_dirty_until_the_parent_confirms() {+        let mut acks = SaveAcks::default();+        let id = acks.posted("a.designcraft", true, 0.0);+        // The engine marked the session saved, but the parent hasn't confirmed.+        assert!(acks.dirty(false));+        assert!(!acks.saved(id + 1), "an unknown id confirms nothing");+        assert!(acks.dirty(false));+        assert!(acks.saved(id));+        assert!(!acks.dirty(false));+        assert!(!acks.waiting());+    }++    #[test]+    fn unanswered_save_times_out_as_failed_and_stays_dirty() {+        let mut acks = SaveAcks::default();+        let id = acks.posted("a.designcraft", true, 1_000.0);+        assert!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS - 1.0).is_empty());+        assert_eq!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS), vec![(id, "a.designcraft".to_string())]);+        assert!(acks.dirty(false));+        // A later confirmed save clears it.+        let next = acks.posted("a.designcraft", true, 50_000.0);+        assert!(acks.saved(next));+        assert!(!acks.dirty(false));+    }++    #[test]+    fn export_acks_do_not_hold_the_session_dirty() {+        let mut acks = SaveAcks::default();+        let id = acks.posted("page.pdf", false, 0.0);+        assert!(!acks.dirty(false));+        assert_eq!(acks.failed(id).as_deref(), Some("page.pdf"));+        assert!(!acks.dirty(false));+    }+}diff --git a/apps/designcraft-web/src/host.rs b/apps/designcraft-web/src/host.rsnew file mode 100644--- /dev/null+++ b/apps/designcraft-web/src/host.rs@@ -0,0 +1,203 @@+//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed+//! DesignCraft in an `<iframe>` (such as a file manager). It mirrors the other craft apps:+//!+//! - `designcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.+//! - `designcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or+//!   `Uint8Array`. It opens like a file chosen with File › Open (`.designcraft`, `.idml`…).+//! - `designcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every+//!   file DesignCraft would otherwise download. Save and Save As send the `.designcraft` document+//!   and mark it saved; exports (PDF, PNG, IDML…) add `export: true`. Each carries an `id`.+//! - `designcraft:saved` (from the parent): `{ id }`, that save is stored. Until it arrives a+//!   document save keeps the app reported dirty; after 30 s without it the app posts+//!   `designcraft:failed { id, error }` and stays dirty.+//! - `designcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away+//!   in any open document.+//! - `designcraft:failed` (to the parent): `{ error }` when the app couldn't start, or when an+//!   opened file is larger than [`MAX_OPEN_BYTES`] (it isn't copied or opened), or a save went+//!   unconfirmed.+//!+//! The bridge is installed only for a same-origin parent, or for a parent on the origin named by+//! `?host_origin=` (a bare `scheme://host[:port]` origin); messages go to that exact origin and only+//! the parent window on it is heard. Any other parent gets no bridge (saves download as usual). In host mode `?sample` is ignored.+//! DesignCraft has no user name to set, so `?author=` is ignored.++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use designcraft_ui_egui::{ImportRequest, ImportedFile, Inbox, Services};+use wasm_bindgen::JsCast as _;+use wasm_bindgen::JsValue;+use wasm_bindgen::closure::Closure;++use crate::bridge::SaveAcks;++/// Largest file the parent may send with `designcraft:open`: the same 256 MiB the engine allows+/// for the other files it reads (linked files, data-merge data and images).+const MAX_OPEN_BYTES: u64 = 256 * 1024 * 1024;++#[derive(Clone)]+pub struct Bridge {+    window: web_sys::Window,+    parent: web_sys::Window,+    origin: String,+    /// The unsaved-changes state last reported to the parent.+    reported: Rc<Cell<Option<bool>>>,+    /// Saves waiting for `designcraft:saved`.+    acks: Rc<RefCell<SaveAcks>>,+}++impl Bridge {+    /// `None` when the page isn't framed, or the parent is cross-origin and not the origin+    /// `host_origin` allows (see [`crate::bridge::target_origin`]).+    pub fn new(host_origin: Option<&str>) -> Option<Self> {+        let window = web_sys::window()?;+        let parent = window.parent().ok().flatten()?;+        if js_sys::Object::is(&parent, &window) {+            return None;+        }+        let own = window.location().origin().ok()?;+        // Reading another origin's location throws: treat that as cross-origin.+        let parent_origin = parent.location().origin().ok();+        let origin = crate::bridge::target_origin(&own, parent_origin.as_deref(), host_origin)?;+        Some(Self { window, parent, origin, reported: Rc::default(), acks: Rc::default() })+    }++    /// Send saves and exports to the parent instead of downloading them, and hear files from it.+    pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {+        self.listen(inbox, ctx);+        let bridge = self.clone();+        services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));+        let bridge = self.clone();+        services.write = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, true)));+    }++    pub fn post_ready(&self) {+        self.send(&[("type", "designcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);+    }++    pub fn post_failed(&self, error: &str) {+        self.send(&[("type", "designcraft:failed".into()), ("error", error.into())]);+    }++    /// Report unsaved changes (once a frame), held dirty while a document save is unconfirmed,+    /// and fail saves the parent didn't confirm within 30 s.+    pub fn post_dirty(&self, ctx: &egui::Context, engine_dirty: bool) {+        let expired = self.acks.borrow_mut().expire(js_sys::Date::now());+        for (id, name) in expired {+            self.send(&[+                ("type", "designcraft:failed".into()),+                ("id", JsValue::from_f64(id as f64)),+                ("error", format!("{name}: the host page didn't confirm the save").into()),+            ]);+        }+        let (dirty, waiting) = {+            let acks = self.acks.borrow();+            (acks.dirty(engine_dirty), acks.waiting())+        };+        if waiting {+            ctx.request_repaint_after(std::time::Duration::from_secs(1));+        }+        if self.reported.get() != Some(dirty) && self.send(&[("type", "designcraft:dirty".into()), ("dirty", dirty.into())]) {+            self.reported.set(Some(dirty));+        }+    }++    fn post_save(&self, path: &str, bytes: &[u8], export: bool) -> Result<(), String> {+        let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());+        let buffer = js_sys::Uint8Array::from(bytes).buffer();+        let document = !export && name.ends_with(&format!(".{}", designcraft_format::EXTENSION));+        let id = self.acks.borrow_mut().posted(&name, document, js_sys::Date::now());+        let mut fields = vec![+            ("type", "designcraft:save".into()),+            ("id", JsValue::from_f64(id as f64)),+            ("name", name.as_str().into()),+            ("bytes", JsValue::from(buffer.clone())),+        ];+        if export {+            fields.push(("export", true.into()));+        }+        let sent = message(&fields).ok_or_else(|| "couldn't build the message".to_string()).and_then(|m| {+            self.parent+                .post_message_with_transfer(&m, &self.origin, &js_sys::Array::of1(&buffer))+                .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+        });+        if sent.is_err() {+            self.acks.borrow_mut().failed(id);+        }+        sent+    }++    fn listen(&self, inbox: Inbox, ctx: egui::Context) {+        let bridge = self.clone();+        let parent = self.parent.clone();+        let origin = self.origin.clone();+        let on_message = Closure::<dyn FnMut(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();+            match string(&data, "type").as_deref() {+                Some("designcraft:open") => {}+                Some("designcraft:saved") => {+                    let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).ok().and_then(|v| v.as_f64());+                    if let Some(id) = id.filter(|id| id.is_finite() && *id >= 0.0 && id.fract() == 0.0) {+                        bridge.acks.borrow_mut().saved(id as u64);+                        ctx.request_repaint();+                    }+                    return;+                }+                _ => return,+            }+            let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Untitled.designcraft".to_string());+            let Some(array) = byte_array(&data) else { return };+            // Refuse oversize files before copying them out of the parent's buffer.+            if u64::from(array.byte_length()) > MAX_OPEN_BYTES {+                bridge.post_failed(&format!("{name} is {} bytes, larger than the {MAX_OPEN_BYTES}-byte limit for opened files", array.byte_length()));+                return;+            }+            let bytes = array.to_vec();+            let request = ImportRequest { purpose: "open".into(), target: None };+            inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });+            ctx.request_repaint();+        });+        if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {+            // The listener lives as long as the page.+            on_message.forget();+        }+    }++    /// Post a message to the parent; false if it couldn't be sent.+    fn send(&self, fields: &[(&str, JsValue)]) -> bool {+        message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())+    }+}++/// The value of `key` in the page's query string.+pub fn query_param(query: &str, key: &str) -> Option<String> {+    let query = query.strip_prefix('?').unwrap_or(query);+    form_urlencoded::parse(query.as_bytes()).find(|(k, _)| k == key).map(|(_, v)| v.into_owned())+}++fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {+    let m = js_sys::Object::new();+    for (key, value) in fields {+        js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;+    }+    Some(m)+}++fn string(data: &JsValue, key: &str) -> Option<String> {+    js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()+}++/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.+fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {+    let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;+    if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {+        Some(array.clone())+    } else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {+        Some(js_sys::Uint8Array::new(&bytes))+    } else {+        None+    }+}diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs--- a/apps/designcraft-web/src/main.rs+++ b/apps/designcraft-web/src/main.rs@@ -12,13 +12,18 @@ //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the-//! sample magazine on start. Flags match case-sensitive, percent-decoded query keys; values+//! sample magazine on start; `?host=parent` hands open and save to a same-origin parent page+//! (or the parent on the origin `?host_origin=` names; `host`). Flags match case-sensitive, percent-decoded query keys; values //! are ignored and repeated keys have the same presence semantics (`?sample=false` enables it). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]  #[cfg(any(target_arch = "wasm32", test))] mod browser_policy; +#[cfg(any(target_arch = "wasm32", test))]+mod bridge;+#[cfg(target_arch = "wasm32")]+mod host; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs--- a/apps/designcraft-web/src/web.rs+++ b/apps/designcraft-web/src/web.rs@@ -38,16 +38,30 @@ pub fn start() {             log::error!("no document");             return;         };+        // `?host=parent`: a same-origin page framing DesignCraft (or the parent on the origin+        // `?host_origin=` names) opens documents in it and stores what it saves (`host`). Made+        // first, so the parent hears about a startup that stops early.+        let bridge = if crate::host::query_param(&query(), "host").as_deref() == Some("parent") {+            crate::host::Bridge::new(crate::host::query_param(&query(), "host_origin").as_deref())+        } else {+            None+        };         if let Err(e) = create_error_panel(&document) {             log::error!("couldn't create graphics error panel: {e:?}");             if let Some(el) = document.get_element_by_id(LOADING_ID) {                 el.set_text_content(Some("DesignCraft couldn't start. Reload this page to try again."));             }+            if let Some(bridge) = &bridge {+                bridge.post_failed(&format!("couldn't create the error panel: {e:?}"));+            }             return;         }         let graphics = GraphicsState::default();         let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {             graphics_failed(&graphics, "DesignCraft failed to start", "Reload this page to try again.", &format!("Missing canvas {CANVAS_ID}."));+            if let Some(bridge) = &bridge {+                bridge.post_failed(&format!("Missing canvas {CANVAS_ID}."));+            }             return;         };         let mut options = eframe::WebOptions::default();@@ -56,6 +70,7 @@ pub fn start() {         {             create.instance_descriptor.backends = eframe::wgpu::Backends::GL;         }+        let app_bridge = bridge.clone();         let runner = eframe::WebRunner::new();         let creator_graphics = graphics.clone();         let result = runner@@ -72,16 +87,24 @@ pub fn start() {                         });                     }                     let inbox: Inbox = Inbox::default();-                    let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));-                    if has_query_flag(&query(), "sample") {+                    let mut services = services(inbox.clone(), cc.egui_ctx.clone());+                    if let Some(bridge) = &app_bridge {+                        bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());+                    }+                    let mut app = DesignApp::new(Session::new(), services);+                    if app_bridge.is_none() && has_query_flag(&query(), "sample") {                         let _ = app.run("file.newSample", serde_json::json!({}));                     }-                    Ok(Box::new(WebShell { app, inbox, graphics: creator_graphics }))+                    Ok(Box::new(WebShell { app, inbox, graphics: creator_graphics, bridge: app_bridge }))                 }),             )             .await;         match result {             Ok(()) => {+                // Ready only once the runner started.+                if let Some(bridge) = &bridge {+                    bridge.post_ready();+                }                 if let Err(e) = install_recovery_controls(&runner, &canvas, &graphics) {                     graphics_failed(&graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Couldn't install graphics recovery controls: {e:?}"));                     if let Some(shell) = runner.app_mut::<WebShell>()@@ -98,6 +121,9 @@ pub fn start() {                 }             }             Err(e) => {+                if let Some(bridge) = &bridge {+                    bridge.post_failed(&format!("{e:?}"));+                }                 graphics_failed(                     &graphics,                     "DesignCraft failed to start",@@ -345,12 +371,19 @@ struct WebShell {     app: DesignApp,     inbox: Inbox,     graphics: GraphicsState,+    /// The host page, with `?host=parent` (told about unsaved changes every frame).+    bridge: Option<crate::host::Bridge>, }  impl eframe::App for WebShell {     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {         let graphics = self.graphics.clone();-        let _ = graphics.run_if_healthy(|| self.app.logic(ctx));+        let _ = graphics.run_if_healthy(|| {+            self.app.logic(ctx);+            if let Some(bridge) = &self.bridge {+                bridge.post_dirty(ctx, self.app.session.documents().iter().any(|d| d.is_dirty()));+            }+        });     }      fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {