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
@@ -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"] }new file mode 100644@@ -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));+ }+}new file mode 100644@@ -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+ }+}@@ -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; @@ -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) {