collective

Browse / github.com/storytold/deckcraft / patch

deckcraft-web: postMessage host bridge (`?host=parent`)

deckcraft-web: postMessage host bridge (`?host=parent`) **Depends on the wasm32 `instance_id` fix (upstream/deckcraft/web-fix): without it the web app panics at start.** Lets a same-origin page that frames DeckCraft in an `<iframe>` open presentations in it and receive what it saves, like the GridCraft and WordCraft web bridges. With `?host=parent` and a same-origin parent: - `deckcraft:ready {version}` once the app listens; - `deckcraft:open {name, bytes}` (ArrayBuffer or Uint8Array) opens like a dropped presentation (.deckcraft, .pptx, .potx, .ppsx, .txt, .md); - every Save, Save As, export and Print PDF that would download posts `deckcraft:sav

Status
open
Score
0.00
Adopters
0 other operators
Author
halldave (imported from pull request #1143, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+605 −6 in 7 files
Review
pending
Shared
2026-10-28
Id
aa73e96d9b7071b7d786f82a

How it went

No other operator has reported using it yet.

Diff

raw · apps/deckcraft-web/Cargo.toml apps/deckcraft-web/src/bridge.rs apps/deckcraft-web/src/host.rs apps/deckcraft-web/src/main.rs apps/deckcraft-web/src/web.rs crates/engine/src/lib.rs crates/engine/src/tests.rs

diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml--- a/apps/deckcraft-web/Cargo.toml+++ b/apps/deckcraft-web/Cargo.toml@@ -27,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "UrlSearchParams"] }diff --git a/apps/deckcraft-web/src/bridge.rs b/apps/deckcraft-web/src/bridge.rsnew file mode 100644--- /dev/null+++ b/apps/deckcraft-web/src/bridge.rs@@ -0,0 +1,236 @@+//! 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 `deckcraft:save` with `deckcraft: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, each carrying `T` (for a presentation+/// save: which presentation, as it was when posted).+#[derive(Debug)]+pub struct SaveAcks<T> {+    next_id: u64,+    /// Request id → (file name, deadline in ms, payload).+    pending: BTreeMap<u64, (String, f64, Option<T>)>,+}++impl<T> Default for SaveAcks<T> {+    fn default() -> Self {+        Self { next_id: 0, pending: BTreeMap::new() }+    }+}++impl<T> SaveAcks<T> {+    /// Record a save posted at `now`; returns its request id.+    pub fn posted(&mut self, name: &str, 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, None));+        self.next_id+    }++    /// Attach what a confirmation of `id` applies to.+    pub fn attach(&mut self, id: u64, payload: T) {+        if let Some(entry) = self.pending.get_mut(&id) {+            entry.2 = Some(payload);+        }+    }++    /// The parent confirmed `id`: its payload, or `None` when no such save is waiting (an+    /// unknown id, or one that already timed out).+    pub fn saved(&mut self, id: u64) -> Option<Option<T>> {+        self.pending.remove(&id).map(|(_, _, payload)| payload)+    }++    /// Forget `id` (its post failed); returns its name.+    pub fn failed(&mut self, id: u64) -> Option<String> {+        self.pending.remove(&id).map(|(name, _, _)| 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| self.pending.remove(&id).map(|(name, _, _)| (id, name))).collect()+    }++    /// Whether any save is still waiting for the parent.+    pub fn waiting(&self) -> bool {+        !self.pending.is_empty()+    }+}++/// A hash of saved bytes, to tell whether a snapshot taken later is the one that was posted.+pub fn bytes_hash(bytes: &[u8]) -> u64 {+    use std::hash::{Hash as _, Hasher as _};+    let mut h = std::collections::hash_map::DefaultHasher::new();+    bytes.hash(&mut h);+    h.finish()+}++#[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 a_save_waits_for_its_own_confirmation() {+        let mut acks = SaveAcks::<&str>::default();+        let id = acks.posted("Deck.deckcraft", 0.0);+        acks.attach(id, "snapshot");+        assert_eq!(acks.saved(id + 1), None, "an unknown id confirms nothing");+        assert!(acks.waiting());+        assert_eq!(acks.saved(id), Some(Some("snapshot")));+        assert!(!acks.waiting());+        assert_eq!(acks.saved(id), None, "a confirmation counts once");+    }++    #[test]+    fn an_unanswered_save_times_out() {+        let mut acks = SaveAcks::<()>::default();+        let id = acks.posted("Deck.deckcraft", 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, "Deck.deckcraft".to_string())]);+        assert_eq!(acks.saved(id), None, "a late confirmation is ignored: the save was reported failed");+    }++    #[test]+    fn bytes_hash_tells_saves_apart() {+        assert_eq!(bytes_hash(b"deck"), bytes_hash(b"deck"));+        assert_ne!(bytes_hash(b"deck"), bytes_hash(b"deck!"));+    }+}diff --git a/apps/deckcraft-web/src/host.rs b/apps/deckcraft-web/src/host.rsnew file mode 100644--- /dev/null+++ b/apps/deckcraft-web/src/host.rs@@ -0,0 +1,283 @@+//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed+//! DeckCraft in an `<iframe>` (such as a file manager). It follows GridCraft's and WordCraft's:+//!+//! - `deckcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.+//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or+//!   `Uint8Array`. It opens like a presentation dropped on the window.+//! - `deckcraft:save` (to the parent): `{ id, name, bytes }` (a transferred `ArrayBuffer`) for+//!   every file DeckCraft would otherwise download: Save, Save As, the exports and Print's PDF.+//!   Save writes `<title>.deckcraft`.+//! - `deckcraft:saved` (from the parent): `{ id }`, the file from that `deckcraft:save` is stored.+//!   A `.deckcraft` save marks its presentation saved (as it was when posted) only then. With no+//!   answer in 30 s the app posts `deckcraft:failed { id, error }` and the presentation stays+//!   unsaved.+//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `deckcraft:failed` (to the parent): `{ error }` when the app couldn't start, or a+//!   `deckcraft:open` was refused (it, plus the documents still waiting to open, is over+//!   `MAX_OPEN_BYTES`, 512 MiB), 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. A cross-origin parent without `host_origin` gets no bridge:+//! files download as usual. In host mode the page+//! starts on a blank presentation instead of the sample deck. `?author=` names the user+//! (comments and the author of new presentations).++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use std::sync::Arc;++use deckcraft_engine::model::Presentation;+use deckcraft_ui_egui::{Services, SlideApp};+use wasm_bindgen::JsCast as _;+use wasm_bindgen::JsValue;+use wasm_bindgen::closure::Closure;++use crate::bridge::SaveAcks;++/// A presentation save waiting for the parent: which presentation (`uid`), as it was when posted.+type Posted = (u64, Arc<Presentation>);++/// The longest user name taken from `?author=` (the preference's own limit).+const MAX_AUTHOR_CHARS: usize = 256;+/// Largest document taken from the parent page, checked before its bytes are copied: the same+/// 512 MiB as the largest single part DeckCraft's PPTX reader decompresses (`deckcraft_pptx::opc::MAX_PART`).+const MAX_OPEN_BYTES: u64 = 512 * 1024 * 1024;++/// Why `len` bytes from the parent can't be opened, or `None` if they can.+fn oversize(len: u64) -> Option<String> {+    (len > MAX_OPEN_BYTES).then(|| format!("document is {len} bytes; the largest DeckCraft opens from the host is {MAX_OPEN_BYTES} bytes (512 MiB)"))+}++#[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>>>,+    /// The `.deckcraft` saves posted since the last frame: request id and a hash of the bytes.+    posted: Rc<RefCell<Vec<(u64, u64)>>>,+    /// Saves waiting for `deckcraft:saved`.+    acks: Rc<RefCell<SaveAcks<Posted>>>,+    /// Request ids the parent confirmed since the last frame.+    confirmed: Rc<RefCell<Vec<u64>>>,+    /// Files the parent sent, waiting for the next frame.+    received: Rc<RefCell<Vec<(String, Vec<u8>)>>>,+}++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 a cross-origin parent'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(),+            posted: Rc::default(),+            acks: Rc::default(),+            confirmed: Rc::default(),+            received: Rc::default(),+        })+    }++    /// Send saved and exported files to the parent instead of downloading them, and hear files+    /// from it.+    pub fn connect(&self, services: &mut Services, ctx: egui::Context) {+        self.listen(ctx);+        let bridge = self.clone();+        services.download = Some(Box::new(move |name: &str, bytes: &[u8]| match bridge.post_save(name, bytes) {+            Ok((name, id)) => {+                if name.to_ascii_lowercase().ends_with(".deckcraft") {+                    bridge.posted.borrow_mut().push((id, crate::bridge::bytes_hash(bytes)));+                }+            }+            Err(e) => {+                log::error!("couldn't hand {name} to the host: {e}");+                bridge.post_failed(&format!("couldn't hand {name} to the host: {e}"));+            }+        }));+    }++    /// `?author=`: the host's name for the user.+    pub fn set_author(app: &mut SlideApp, author: &str) {+        let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();+        if !author.is_empty() {+            app.session.prefs.author = author;+        }+    }++    /// Once a frame: note which presentation each new save is for, mark presentations the parent+    /// confirmed saved, fail saves it didn't confirm in time, open what it sent, and report+    /// unsaved changes.+    pub fn sync(&self, app: &mut SlideApp, ctx: &egui::Context) {+        // Before any open below can change the active presentation: the saves posted since the+        // last frame came from the active one.+        // The snapshot is attached only if it is what was posted: an edit later in the same frame+        // (a key typed after Ctrl+S in one input batch) changes it, and that save then confirms+        // nothing, leaving the presentation dirty.+        let posted = std::mem::take(&mut *self.posted.borrow_mut());+        if !posted.is_empty()+            && let Some(d) = app.session.active()+        {+            let now = deckcraft_engine::cmd::file::save_bytes(&d.doc, "deckcraft").ok().map(|b| crate::bridge::bytes_hash(&b));+            let mut acks = self.acks.borrow_mut();+            for (id, hash) in posted {+                if now == Some(hash) {+                    acks.attach(id, (d.uid, d.doc.clone()));+                }+            }+        }+        for id in std::mem::take(&mut *self.confirmed.borrow_mut()) {+            let payload = self.acks.borrow_mut().saved(id);+            if let Some(Some((uid, snapshot))) = payload {+                app.session.confirm_saved(uid, snapshot);+            }+        }+        let expired = self.acks.borrow_mut().expire(js_sys::Date::now());+        for (id, name) in expired {+            self.send(&[+                ("type", "deckcraft:failed".into()),+                ("id", JsValue::from_f64(id as f64)),+                ("error", format!("{name}: the host page didn't confirm the save").into()),+            ]);+        }+        if self.acks.borrow().waiting() {+            ctx.request_repaint_after(std::time::Duration::from_secs(1));+        }+        for (name, bytes) in std::mem::take(&mut *self.received.borrow_mut()) {+            app.open_or_insert_bytes(&name, &bytes);+        }+        let dirty = app.session.active().is_some_and(|d| d.is_dirty());+        if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) {+            self.reported.set(Some(dirty));+        }+    }++    pub fn post_ready(&self) {+        self.send(&[("type", "deckcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);+    }++    pub fn post_failed(&self, error: &str) {+        self.send(&[("type", "deckcraft:failed".into()), ("error", error.into())]);+    }++    /// Post a file to the parent; the name it went under and its request id.+    fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(String, u64), 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 id = self.acks.borrow_mut().posted(&name, js_sys::Date::now());+        let sent = message(&[+            ("type", "deckcraft:save".into()),+            ("id", JsValue::from_f64(id as f64)),+            ("name", name.as_str().into()),+            ("bytes", buffer.clone().into()),+        ])+        .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:?}")))+        });+        match sent {+            Ok(()) => Ok((name, id)),+            Err(e) => {+                self.acks.borrow_mut().failed(id);+                Err(e)+            }+        }+    }++    fn listen(&self, ctx: egui::Context) {+        let parent = self.parent.clone();+        let origin = self.origin.clone();+        let received = self.received.clone();+        let confirmed = self.confirmed.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("deckcraft:open") => {}+                Some("deckcraft: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) {+                        confirmed.borrow_mut().push(id as u64);+                        ctx.request_repaint();+                    }+                    return;+                }+                _ => return,+            }+            let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Presentation.deckcraft".to_string());+            let Some(array) = byte_array(&data) else { return };+            // The cap covers this document plus any still waiting for the next frame.+            let queued: u64 = received.borrow().iter().map(|(_, b)| b.len() as u64).sum();+            if let Some(error) = oversize(queued.saturating_add(u64::from(array.byte_length()))) {+                if let Some(m) = message(&[("type", "deckcraft:failed".into()), ("error", error.as_str().into())]) {+                    let _ = parent.post_message(&m, &origin);+                }+                return;+            }+            let bytes = array.to_vec();+            received.borrow_mut().push((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())+    }+}++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+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn open_cap() {+        assert!(oversize(MAX_OPEN_BYTES).is_none());+        assert!(oversize(MAX_OPEN_BYTES + 1).is_some_and(|e| e.contains("512 MiB")));+    }+}diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs--- a/apps/deckcraft-web/src/main.rs+++ b/apps/deckcraft-web/src/main.rs@@ -14,10 +14,16 @@ //! - preferences are not persisted. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an-//! empty presentation instead of the sample deck; `?show` starts the slide show.+//! empty presentation instead of the sample deck; `?show` starts the slide show; `?host=parent`+//! (with optional `?author=`) hands open and save to a same-origin parent page, or to the+//! parent on the origin `?host_origin=` names (`host`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]+mod bridge;+#[cfg(target_arch = "wasm32")]+mod host; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs--- a/apps/deckcraft-web/src/web.rs+++ b/apps/deckcraft-web/src/web.rs@@ -24,6 +24,11 @@ pub fn start() {             log::error!("missing <canvas id=\"{CANVAS_ID}\">");             return;         };+        // `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and+        // stores what it saves (`host`).+        let bridge =+            if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new(query_param("host_origin").as_deref()) } else { None };+        let app_bridge = bridge.clone();         let mut options = eframe::WebOptions::default();         if query().contains("webgl")             && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup@@ -40,22 +45,37 @@ pub fn start() {                     }                     deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);                     let inbox: Inbox = Inbox::default();-                    let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));+                    let mut services = services(inbox.clone(), cc.egui_ctx.clone());+                    if let Some(bridge) = &app_bridge {+                        bridge.connect(&mut services, cc.egui_ctx.clone());+                    }+                    let mut app = SlideApp::new(Session::new(), services);                     let q = query();-                    if !q.contains("blank") {+                    if app_bridge.is_none() && !q.contains("blank") {                         if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {                             log::error!("sample deck: {e}");                         }                     } else if let Err(e) = app.run("file.new", serde_json::json!({})) {                         log::error!("new presentation: {e}");                     }-                    if q.contains("show") {+                    if app_bridge.is_some() {+                        if let Some(author) = query_param("author") {+                            crate::host::Bridge::set_author(&mut app, &author);+                        }+                    } else if q.contains("show") {                         app.start_show(0, false);                     }-                    Ok(Box::new(WebShell { app, inbox }))+                    Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))                 }),             )             .await;+        // Ready only once the runner started; otherwise the parent hears why it didn't.+        if let Some(bridge) = &bridge {+            match &result {+                Ok(()) => bridge.post_ready(),+                Err(e) => bridge.post_failed(&format!("{e:?}")),+            }+        }         if let Some(el) = document.get_element_by_id(LOADING_ID) {             match result {                 Ok(()) => el.remove(),@@ -69,16 +89,24 @@ fn query() -> String {     web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } +fn query_param(key: &str) -> Option<String> {+    web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)+}+ /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously) /// and feed them through the inbox. struct WebShell {     app: SlideApp,     inbox: Inbox,+    bridge: Option<crate::host::Bridge>, }  impl eframe::App for WebShell {     /// Delegate one eframe logic pass to the wrapped application.     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {+        if let Some(bridge) = &self.bridge {+            bridge.sync(&mut self.app, ctx);+        }         self.app.logic(ctx);     } @@ -111,6 +139,9 @@ impl eframe::App for WebShell {      fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {         self.app.ui(ui);+        if let Some(bridge) = &self.bridge {+            bridge.sync(&mut self.app, ui.ctx());+        }     } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -613,6 +613,17 @@ impl Session {             self.tool.vertex_kind = None;         }     }+    /// Mark document `uid` saved as `snapshot` (what a host confirmed it stored). False when that+    /// document is gone. Edits made since the snapshot keep it dirty.+    pub fn confirm_saved(&mut self, uid: u64, snapshot: Arc<Presentation>) -> bool {+        match self.docs.iter_mut().find(|d| d.uid == uid) {+            Some(d) => {+                d.saved_doc = snapshot;+                true+            }+            None => false,+        }+    }     pub fn next_untitled(&mut self) -> String {         self.untitled += 1;         format!("Presentation{}", self.untitled)diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -5462,6 +5462,38 @@ fn a_shape_in_front_of_edited_text_takes_the_click() {     assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } +/// A host's save confirmation marks the document saved as it was when posted; until then (or if+/// it never comes) the document stays dirty, and later edits keep it dirty.+#[test]+fn confirm_saved_marks_only_the_posted_snapshot() {+    let mut s = session();+    s.execute("slide.new", &json!({})).unwrap();+    let uid = s.doc().unwrap().uid;+    let snapshot = s.doc().unwrap().doc.clone();+    assert!(s.doc().unwrap().is_dirty(), "unconfirmed: still dirty");+    // Another presentation opens and becomes active before the confirmation arrives.+    s.add_document(DocState::new(deckcraft_model::Presentation::default(), None, "Other".into()));+    assert!(s.confirm_saved(uid, snapshot.clone()));+    assert!(!s.documents()[0].is_dirty(), "the saved presentation is clean, not the active one");+    s.set_active(0);+    s.execute("slide.new", &json!({})).unwrap();+    assert!(s.confirm_saved(uid, snapshot));+    assert!(s.doc().unwrap().is_dirty(), "edits after the posted save stay unsaved");+    assert!(!s.confirm_saved(u64::MAX, s.doc().unwrap().doc.clone()), "a closed document confirms nothing");+}++/// The web host bridge confirms a save only when the presentation still serialises to the bytes+/// it posted, so saving the same presentation twice must give the same bytes.+#[test]+fn deckcraft_save_bytes_are_deterministic() {+    let mut s = session();+    s.execute("slide.new", &json!({})).unwrap();+    let doc = s.doc().unwrap().doc.clone();+    let a = crate::cmd::file::save_bytes(&doc, "deckcraft").unwrap();+    let b = crate::cmd::file::save_bytes(&doc, "deckcraft").unwrap();+    assert_eq!(a, b);+}+ /// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off. /// The first box is selected. fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) {