Browse / github.com/storytold/gridcraft / patch
fix(web): acknowledge managed native saves after durable storage
fix(web): acknowledge managed native saves after durable storage With an embedded editor, Ctrl+S currently marks the workbook saved immediately after posting bytes to the parent, before durable storage has succeeded. A host can now opt into `?host=parent&save=managed`: Save requests a native snapshot save, and only an acknowledgement identifying the latest exported request, document, and revision records that exact workbook as the saved baseline. Edits made while storage is pending remain dirty; stale or foreign acknowledgements cannot clear them. Legacy host saves remain unchanged. Builds on #2736 and relates to #2730. The follow-up also caps native valid
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Srbino (imported from pull request #2737, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +462 −4 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c68ae75bced55c7d2e6689ac
How it went
No other operator has reported using it yet.
Diff
raw · README.md apps/gridcraft-web/src/host.rs apps/gridcraft-web/src/main.rs apps/gridcraft-web/src/managed_save.rs apps/gridcraft-web/src/native_document.rs apps/gridcraft-web/src/web.rs
@@ -129,6 +129,29 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. +A same-origin parent can embed the editor with `?host=parent` and open complete native JSON+with `gridcraft:open` (`name: "workbook.json"`, `bytes: ArrayBuffer`). After `gridcraft:ready`,+send `gridcraft:export-native` with an opaque string `requestId`; `gridcraft:native-exported`+returns that ID, the exported `document` and `revision`, and complete native JSON `bytes`, or `error`.+The revision identifies the state after any pending draft was committed. Export commits pending drafts+through the engine and leaves the unsaved state intact: the host remains responsible for durable+storage. `gridcraft:changed` reports every active document revision, including further edits while+already dirty. `gridcraft:validate-native` (`requestId`, `bytes`) returns+`gridcraft:native-validated` (`requestId`, `valid`, `error`) without changing the document.+Validation accepts at most 32 MiB, checked before copying the input bytes. `gridcraft:closed`+reports that no workbook is active.+These messages are accepted only from the same-origin parent. They do not grant permission to+store a document or turn the ordinary XLSX Save action into a durable acknowledgement.++Hosts that durably store native snapshots can opt into `?host=parent&save=managed`.+Workbook Save (including Ctrl+S) then sends `gridcraft:save-request` (`document`, `revision`)+and leaves unsaved changes intact. Export a native snapshot using a fresh `requestId` unique+across frame reloads. After storing it durably, send `gridcraft:save-ack` with that exact+`requestId`, `document`, and `revision` from `gridcraft:native-exported`. Only the latest+export can be acknowledged. A matching acknowledgement records the exported workbook as the+saved baseline; edits made after that export remain unsaved. Old or foreign acknowledgements+are ignored. Without `save=managed`, ordinary XLSX Save behavior remains unchanged.+ ### Ribbon access keys Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,@@ -8,7 +8,21 @@ //! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export //! as PDF. The parent stores it and reports failures itself. //! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `gridcraft:changed` (to the parent): `{ document, revision, dirty }` when the active+//! workbook changes. Document and revision are opaque strings; edits while already dirty+//! still notify the host, so it can debounce a fresh native export. //! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.+//! - `gridcraft:export-native` (from the parent): `{ requestId }`; replies with+//! `gridcraft:native-exported` `{ requestId, document, revision, bytes }` (native JSON ArrayBuffer), or `{ requestId, error }`.+//! Pending drafts are committed first. Export does not claim a durable save or clear dirty state.+//! - `gridcraft:validate-native` (from the parent): `{ requestId, bytes }`; replies with+//! `gridcraft:native-validated` `{ requestId, valid, error? }` without changing the document.+//!+//! - `gridcraft:closed` (to the parent): no workbook is active.+//! - With `?host=parent&save=managed`, workbook Save emits `gridcraft:save-request`+//! `{ document, revision }` without marking it saved. After durable storage of the latest+//! native export, send `gridcraft:save-ack` `{ requestId, document, revision }`.+//! Only that exact exported workbook becomes the saved baseline; later edits remain dirty. //! //! Only the parent window on the page's own origin is heard or answered. In host mode the page //! starts with an empty workbook instead of a sample, and the parent guards leaving the page.@@ -28,8 +42,17 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"]; /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +enum NativeRequest {+ Export(String),+ Validate(String, Vec<u8>),+ RequestSave,+ Acknowledge(String, String, String),+}+ #[derive(Clone)] pub struct Bridge {+ managed: bool,+ managed_saves: Rc<RefCell<crate::managed_save::ManagedSaves>>, window: web_sys::Window, parent: web_sys::Window, origin: String,@@ -37,18 +60,30 @@ pub struct Bridge { saved: Rc<RefCell<Vec<String>>>, /// The unsaved-changes state last reported to the parent. reported: Rc<Cell<Option<bool>>>,+ native: Rc<RefCell<Vec<NativeRequest>>>,+ reported_revision: Rc<Cell<Option<(u64, u64)>>>, } impl Bridge { /// `None` when the page isn't framed (there is no parent to talk to).- pub fn new() -> Option<Self> {+ pub fn new(managed: bool) -> Option<Self> { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; if js_sys::Object::is(&parent, &window) { return None; } let origin = window.location().origin().ok()?;- Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })+ Some(Self {+ managed,+ managed_saves: Rc::default(),+ window,+ parent,+ origin,+ saved: Rc::default(),+ reported: Rc::default(),+ native: Rc::default(),+ reported_revision: Rc::default(),+ }) } /// Send saved and exported files to the parent instead of downloading them, and hear files@@ -82,6 +117,64 @@ impl Bridge { /// After each frame: a workbook handed to the parent this frame counts as saved under the /// name it was saved as, and the parent hears when unsaved changes appear or go away. pub fn sync(&self, app: &mut SheetApp) {+ let requests = std::mem::take(&mut *self.native.borrow_mut());+ for request in requests {+ match request {+ NativeRequest::Export(id) => match crate::native_document::export(app) {+ Ok(snapshot) => {+ if self.managed+ && let Err(error) = self.managed_saves.borrow_mut().remember(&id, &snapshot, app)+ {+ self.send(&[+ ("type", "gridcraft:native-exported".into()),+ ("requestId", id.as_str().into()),+ ("error", error.as_str().into()),+ ]);+ continue;+ }+ let buffer = js_sys::Uint8Array::from(snapshot.bytes.as_slice()).buffer();+ if let Some(message) = message(&[+ ("type", "gridcraft:native-exported".into()),+ ("requestId", id.as_str().into()),+ ("document", snapshot.document.as_str().into()),+ ("revision", snapshot.revision.as_str().into()),+ ("bytes", buffer.clone().into()),+ ]) {+ let _ = self.parent.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer));+ }+ }+ Err(error) => {+ self.send(&[+ ("type", "gridcraft:native-exported".into()),+ ("requestId", id.as_str().into()),+ ("error", error.as_str().into()),+ ]);+ }+ },+ NativeRequest::RequestSave => {+ if let Some(doc) = app.session.active() {+ self.send(&[+ ("type", "gridcraft:save-request".into()),+ ("document", doc.uid.to_string().into()),+ ("revision", doc.revision.to_string().into()),+ ]);+ }+ }+ NativeRequest::Acknowledge(id, document, revision) => {+ self.managed_saves.borrow_mut().acknowledge(app, &id, &document, &revision);+ }+ NativeRequest::Validate(id, bytes) => {+ let result = crate::native_document::validate(&bytes);+ let error = result.as_ref().err().map(String::as_str).unwrap_or("");+ self.send(&[+ ("type", "gridcraft:native-validated".into()),+ ("requestId", id.as_str().into()),+ ("valid", result.is_ok().into()),+ ("error", error.into()),+ ]);+ }+ }+ } let saved = std::mem::take(&mut *self.saved.borrow_mut()); if let Some(name) = saved.iter().rev().find(|name| is_workbook(name)) && let Some(doc) = app.session.active_mut()@@ -95,10 +188,31 @@ impl Bridge { if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) { self.reported.set(Some(dirty)); }+ let revision = app.session.active().map(|doc| (doc.uid, doc.revision));+ if revision != self.reported_revision.get() {+ if let Some((document, revision)) = revision {+ self.send(&[+ ("type", "gridcraft:changed".into()),+ ("document", document.to_string().into()),+ ("revision", revision.to_string().into()),+ ("dirty", dirty.into()),+ ]);+ } else {+ self.send(&[("type", "gridcraft:closed".into())]);+ }+ self.reported_revision.set(revision);+ } } fn post_save(&self, path: &str, bytes: &[u8]) -> 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());+ if self.managed && is_workbook(&name) {+ if self.native.borrow().len() >= 8 {+ return Err("The native document request queue is full.".into());+ }+ self.native.borrow_mut().push(NativeRequest::RequestSave);+ return Ok(());+ } let buffer = js_sys::Uint8Array::from(bytes).buffer(); let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())]) .ok_or_else(|| "couldn't build the message".to_string())?;@@ -112,12 +226,57 @@ impl Bridge { fn listen(&self, inbox: Inbox, ctx: egui::Context) { let parent = self.parent.clone(); let origin = self.origin.clone();+ let bridge = self.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();- if string(&data, "type").as_deref() != Some("gridcraft:open") {+ let kind = string(&data, "type");+ if kind.as_deref() == Some("gridcraft:save-ack") {+ if !bridge.managed || bridge.native.borrow().len() >= 8 {+ return;+ }+ let field = |key| string(&data, key).filter(|value| !value.is_empty() && value.len() <= 128);+ let (Some(id), Some(document), Some(revision)) = (field("requestId"), field("document"), field("revision")) else { return };+ bridge.native.borrow_mut().push(NativeRequest::Acknowledge(id, document, revision));+ ctx.request_repaint();+ return;+ }+ if matches!(kind.as_deref(), Some("gridcraft:export-native" | "gridcraft:validate-native")) {+ let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+ let response =+ if kind.as_deref() == Some("gridcraft:export-native") { "gridcraft:native-exported" } else { "gridcraft:native-validated" };+ if bridge.native.borrow().len() >= 8 {+ bridge.send(&[+ ("type", response.into()),+ ("requestId", id.as_str().into()),+ ("valid", false.into()),+ ("error", "The native document request queue is full.".into()),+ ]);+ return;+ }+ let request = if kind.as_deref() == Some("gridcraft:export-native") {+ NativeRequest::Export(id)+ } else {+ match native_bytes(&data) {+ Ok(bytes) => NativeRequest::Validate(id, bytes),+ Err(error) => {+ bridge.send(&[+ ("type", response.into()),+ ("requestId", id.as_str().into()),+ ("valid", false.into()),+ ("error", error.as_str().into()),+ ]);+ return;+ }+ }+ };+ bridge.native.borrow_mut().push(request);+ ctx.request_repaint();+ return;+ }+ if kind.as_deref() != Some("gridcraft:open") { return; } let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());@@ -164,3 +323,17 @@ fn bytes(data: &JsValue) -> Option<Vec<u8>> { None } }++/// The validation RPC checks size before allocating or copying the incoming payload.+fn native_bytes(data: &JsValue) -> Result<Vec<u8>, String> {+ let value = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).map_err(|_| "Native workbook bytes are required.".to_string())?;+ let array = if let Some(array) = value.dyn_ref::<js_sys::Uint8Array>() {+ array.clone()+ } else if value.is_instance_of::<js_sys::ArrayBuffer>() {+ js_sys::Uint8Array::new(&value)+ } else {+ return Err("Native workbook bytes are required.".into());+ };+ crate::native_document::validate_length(array.length() as usize)?;+ Ok(array.to_vec())+}@@ -19,6 +19,10 @@ #[cfg(target_arch = "wasm32")] mod host; #[cfg(any(target_arch = "wasm32", test))]+mod managed_save;+#[cfg(any(target_arch = "wasm32", test))]+mod native_document;+#[cfg(any(target_arch = "wasm32", test))] mod prefs; #[cfg(target_arch = "wasm32")] mod web;new file mode 100644@@ -0,0 +1,124 @@+//! A managed host only acknowledges the exact native snapshot it durably stored.++use std::sync::Arc;++use gridcraft_engine::model::Workbook;+use gridcraft_ui_egui::SheetApp;++use crate::native_document::Snapshot;++struct Pending {+ request: String,+ document: String,+ revision: String,+ workbook: Arc<Workbook>,+}++/// Retains only the latest exported baseline. Exporting and requesting a save+/// never change `DocState::saved`; only a matching host acknowledgement does.+#[derive(Default)]+pub struct ManagedSaves {+ pending: Option<Pending>,+}++impl ManagedSaves {+ pub fn remember(&mut self, request: &str, snapshot: &Snapshot, app: &SheetApp) -> Result<(), String> {+ let doc = app.session.active().ok_or_else(|| "No workbook is open.".to_string())?;+ if doc.uid.to_string() != snapshot.document || doc.revision.to_string() != snapshot.revision {+ return Err("The native snapshot no longer identifies the active document.".into());+ }+ self.pending = Some(Pending {+ request: request.to_string(),+ document: snapshot.document.clone(),+ revision: snapshot.revision.clone(),+ workbook: doc.wb.clone(),+ });+ Ok(())+ }++ pub fn acknowledge(&mut self, app: &mut SheetApp, request: &str, document: &str, revision: &str) -> bool {+ let Some(pending) = &self.pending else { return false };+ if pending.request != request+ || pending.document != document+ || pending.revision != revision+ || app.session.active().is_none_or(|doc| doc.uid.to_string() != document)+ {+ return false;+ }+ let Some(pending) = self.pending.take() else { return false };+ let Some(doc) = app.session.active_mut() else { return false };+ // Later edits remain different from this exact exported workbook.+ doc.saved = pending.workbook;+ true+ }+}++#[cfg(test)]+mod tests {+ use gridcraft_engine::Session;+ use gridcraft_ui_egui::{Services, SheetApp};+ use serde_json::json;+ use std::sync::Arc;++ fn app() -> SheetApp {+ let mut session = Session::new();+ session.new_workbook();+ let mut app = SheetApp::new(session, Services::default());+ app.run("cell.set", json!({"cell":"A1", "input":"first"})).unwrap();+ app+ }++ #[test]+ fn acknowledgement_retains_later_edits_then_exact_latest_snapshot_clears_dirty() {+ let mut app = app();+ let mut saves = super::ManagedSaves::default();+ let first = crate::native_document::export(&mut app).unwrap();+ saves.remember("request-1", &first, &app).unwrap();+ assert!(app.session.active().unwrap().is_dirty());+ app.run("cell.set", json!({"cell":"A1", "input":"second"})).unwrap();+ assert!(saves.acknowledge(&mut app, "request-1", &first.document, &first.revision));+ assert!(app.session.active().unwrap().is_dirty());+ let second = crate::native_document::export(&mut app).unwrap();+ saves.remember("request-2", &second, &app).unwrap();+ assert!(saves.acknowledge(&mut app, "request-2", &second.document, &second.revision));+ assert!(!app.session.active().unwrap().is_dirty());+ }++ #[test]+ fn old_or_foreign_acknowledgement_cannot_replace_the_latest_baseline() {+ let mut app = app();+ let mut saves = super::ManagedSaves::default();+ let first = crate::native_document::export(&mut app).unwrap();+ saves.remember("request-1", &first, &app).unwrap();+ app.run("cell.set", json!({"cell":"A1", "input":"second"})).unwrap();+ let second = crate::native_document::export(&mut app).unwrap();+ saves.remember("request-2", &second, &app).unwrap();+ let baseline = app.session.active().unwrap().saved.clone();+ for (id, document, revision) in [+ ("request-1", first.document.as_str(), first.revision.as_str()),+ ("wrong-request", second.document.as_str(), second.revision.as_str()),+ ("request-2", "wrong-document", second.revision.as_str()),+ ("request-2", second.document.as_str(), "wrong-revision"),+ ] {+ assert!(!saves.acknowledge(&mut app, id, document, revision));+ assert!(Arc::ptr_eq(&app.session.active().unwrap().saved, &baseline));+ }+ assert!(app.session.active().unwrap().is_dirty());+ assert!(saves.acknowledge(&mut app, "request-2", &second.document, &second.revision));+ assert!(!app.session.active().unwrap().is_dirty());+ }++ #[test]+ fn an_acknowledgement_for_another_document_leaves_its_baseline_untouched() {+ let mut app = app();+ let mut saves = super::ManagedSaves::default();+ let first = crate::native_document::export(&mut app).unwrap();+ saves.remember("request-1", &first, &app).unwrap();+ app.session.new_workbook();+ app.run("cell.set", json!({"cell":"A1", "input":"new document"})).unwrap();+ let baseline = app.session.active().unwrap().saved.clone();+ assert!(!saves.acknowledge(&mut app, "request-1", &first.document, &first.revision));+ assert!(Arc::ptr_eq(&app.session.active().unwrap().saved, &baseline));+ assert!(app.session.active().unwrap().is_dirty());+ }+}new file mode 100644@@ -0,0 +1,130 @@+//! Whole native documents for same-origin embedding hosts.++use gridcraft_ui_egui::SheetApp;+use serde_json::json;++pub struct Snapshot {+ pub bytes: Vec<u8>,+ pub document: String,+ pub revision: String,+}++/// Encode the complete native workbook through the same command as desktop/CLI.+/// `SheetApp::run` settles pending cell and text-box drafts before file commands.+/// Export is not a durable save and deliberately leaves the dirty baseline alone.+pub fn export(app: &mut SheetApp) -> Result<Snapshot, String> {+ let encoded = app.run("file.saveBytes", json!({"format": "json"}))?;+ let bytes = encoded+ .get("base64")+ .and_then(serde_json::Value::as_str)+ .and_then(gridcraft_engine::io::base64_decode)+ .ok_or_else(|| "The engine did not return a native workbook.".to_string())?;+ validate(&bytes)?;+ // A pending draft may have advanced the revision during the file command.+ let doc = app.session.active().ok_or_else(|| "No workbook is open.".to_string())?;+ Ok(Snapshot { bytes, document: doc.uid.to_string(), revision: doc.revision.to_string() })+}++/// Limit the new host validation RPC before crossing the JS-to-Rust copy boundary.+/// Native export and the legacy file-open path keep their existing behavior.+pub fn validate_length(length: usize) -> Result<(), String> {+ if length > 32 * 1024 * 1024 {+ return Err("Native validation accepts at most 32 MiB.".into());+ }+ Ok(())+}++/// Decode using the application's engine without changing the open workbook.+pub fn validate(bytes: &[u8]) -> Result<(), String> {+ // The file reader also sniffs XLSX irrespective of the supplied extension.+ // This endpoint promises native JSON, not an arbitrary import.+ serde_json::from_slice::<serde_json::Value>(bytes).map_err(|e| e.to_string())?;+ let (_, warnings) = gridcraft_engine::io::open_bytes("snapshot.json", bytes).map_err(|e| e.to_string())?;+ if !warnings.is_empty() {+ return Err(warnings.join("\n"));+ }+ Ok(())+}++#[cfg(test)]+mod tests {+ use gridcraft_engine::Session;+ use gridcraft_ui_egui::{Services, SheetApp};+ use serde_json::json;++ fn app() -> SheetApp {+ let mut session = Session::new();+ session.new_workbook();+ SheetApp::new(session, Services::default())+ }++ #[test]+ fn native_export_commits_the_cell_draft_without_claiming_durable_save() {+ let mut app = app();+ app.run("cell.set", json!({"cell":"A1", "input":"21"})).unwrap();+ app.run("cell.set", json!({"cell":"B1", "input":"=A1*2"})).unwrap();+ app.run("home.bold", json!({"range":"A1", "on":true})).unwrap();+ app.run("home.numberFormat", json!({"range":"B1", "code":"0.00"})).unwrap();+ app.run("home.mergeCells", json!({"range":"A4:C4"})).unwrap();+ app.run("home.rowHeight", json!({"rows":"5:5", "height":42})).unwrap();+ app.run("home.insertSheet", json!({"name":"Second"})).unwrap();+ app.run("cell.set", json!({"cell":"A1", "input":"Second sheet"})).unwrap();+ app.run("sheet.activate", json!({"sheet":"Sheet1"})).unwrap();+ app.run("selection.set", json!({"cell":"A1"})).unwrap();+ let before = app.session.active().unwrap().revision;+ let document = app.session.active().unwrap().uid.to_string();+ app.begin_edit(Some("22".into()), false);+ let snapshot = super::export(&mut app).unwrap();+ assert_eq!(snapshot.document, document);+ assert!(app.session.active().unwrap().revision > before);+ assert_eq!(snapshot.revision, app.session.active().unwrap().revision.to_string());+ let bytes = snapshot.bytes;+ assert!(app.editor.is_none());+ assert!(app.session.active().unwrap().is_dirty());+ let (wb, _) = gridcraft_engine::io::open_bytes("snapshot.json", &bytes).unwrap();+ // Formula AST caches and rebuilt style indices are intentionally not persisted.+ assert_eq!(serde_json::to_value(&wb).unwrap(), serde_json::to_value(app.session.active().unwrap().wb.as_ref()).unwrap());+ let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();+ assert!(json.get("sheets").is_some());+ }++ #[test]+ fn native_validation_length_boundary_is_checked_without_allocating_payloads() {+ assert!(super::validate_length(31 * 1024 * 1024).is_ok());+ assert!(super::validate_length(32 * 1024 * 1024).is_ok());+ assert!(super::validate_length(32 * 1024 * 1024 + 1).is_err());+ }++ #[test]+ fn native_validation_rejects_invalid_documents() {+ assert!(super::validate(b"not JSON").is_err());+ assert!(super::validate(br#"{"sheets":"broken"}"#).is_err());+ }++ #[test]+ fn rejected_draft_is_retained_instead_of_exporting_an_older_cell() {+ let mut app = app();+ app.begin_edit(Some("=1+".into()), false);+ assert!(super::export(&mut app).is_err());+ assert!(app.editor.is_some());+ }++ #[test]+ fn native_validation_does_not_accept_an_xlsx_disguised_as_json() {+ let mut session = Session::new();+ session.new_workbook();+ let encoded = session.execute("file.saveBytes", json!({"format":"xlsx"})).unwrap();+ let bytes = gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap();+ assert!(super::validate(&bytes).is_err());+ }++ #[test]+ fn native_export_has_no_four_mib_checkpoint_limit() {+ let mut app = app();+ let values: Vec<Vec<String>> = (0..130).map(|_| vec!["x".repeat(32767)]).collect();+ app.run("range.setValues", json!({"range":"A1", "values":values})).unwrap();+ let bytes = super::export(&mut app).unwrap().bytes;+ assert!(bytes.len() > 4 * 1024 * 1024);+ super::validate(&bytes).unwrap();+ }+}@@ -39,7 +39,11 @@ pub fn start() { } // `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens // workbooks in it and stores what it saves (`host`).- let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };+ let bridge = if query_param("host").as_deref() == Some("parent") {+ crate::host::Bridge::new(query_param("save").as_deref() == Some("managed"))+ } else {+ None+ }; let app_bridge = bridge.clone(); let result = eframe::WebRunner::new() .start(