Browse / github.com/storytold/gridcraft / patch
feat(web): expose native workbook snapshots to embedding hosts
feat(web): expose native workbook snapshots to embedding hosts Same-origin embeddings can open native JSON workbooks, but Save only hands the host XLSX. Add correlated whole-document native JSON export and engine validation through the existing file codec. Export settles pending drafts, reports the resulting document/revision, and retains the dirty baseline. Revision notifications include further edits while already dirty. The existing same-origin parent checks and ordinary Save behavior remain in place. Durable managed-save acknowledgement is a separate follow-up. Validation: - `cargo xtask ci` passed all 10 steps with the same pinned craft-fonts inpu
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Srbino (imported from pull request #2736, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +225 −2 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
665cff946e6a7126f51b636f
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/native_document.rs
@@ -129,6 +129,18 @@ 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.+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.+ ### Ribbon access keys Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,@@ -8,7 +8,15 @@ //! 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. //! //! 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,6 +36,11 @@ 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>),+}+ #[derive(Clone)] pub struct Bridge { window: web_sys::Window,@@ -37,6 +50,8 @@ 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 {@@ -48,7 +63,7 @@ impl Bridge { return None; } let origin = window.location().origin().ok()?;- Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })+ Some(Self { 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 +97,42 @@ 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) => {+ 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::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,6 +146,18 @@ 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()),+ ]);+ }+ self.reported_revision.set(revision);+ } } fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {@@ -112,12 +175,44 @@ 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 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 if let Some(bytes) = bytes(&data) {+ NativeRequest::Validate(id, bytes)+ } else {+ bridge.send(&[+ ("type", response.into()),+ ("requestId", id.as_str().into()),+ ("valid", false.into()),+ ("error", "Native workbook bytes are required.".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());@@ -19,6 +19,8 @@ #[cfg(target_arch = "wasm32")] mod host; #[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,114 @@+//! 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() })+}++/// 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_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();+ }+}