Browse / github.com/storytold/gridcraft / patch
feat: add opt-in capability port host transport
Web host bridge: capability port transport for opaque sandboxed frames A same-origin window bridge cannot serve a host that isolates its editor in an opaque iframe (`sandbox="allow-scripts"`, without `allow-same-origin`). Please add an opt-in capability MessagePort transport, preserving the existing window mode. The public window channel should carry only a content-free protocol/bundle hello and a one-shot connect from the exact embedding parent and configured canonical parent origin, transferring exactly one port. Validate the pinned bundle on both handshake messages. All workbook bytes, state notifications and managed-save acknowledgements then travel only on
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Srbino (imported from pull request #2744, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +1066 −19 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5b5fe01a86701cdf1e2ed8e2
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock README.md apps/gridcraft-web/Cargo.toml apps/gridcraft-web/src/draft_state.rs 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/port_handshake.rs apps/gridcraft-web/src/web.rs crates/ui-egui/src/text_box.rs
@@ -2112,6 +2112,7 @@ dependencies = [ "log", "rfd", "serde_json",+ "sha2 0.11.1", "wasm-bindgen", "wasm-bindgen-futures", "web-sys",@@ -129,6 +129,49 @@ 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`). Add an optional+`requestId` for a correlated `gridcraft:opened` reply (`requestId`, `document`, `revision`, or+`requestId`, `error`). It is sent only after the engine actually imports the workbook, not when+bytes are queued; overlapping correlated imports are rejected. Unsaved work and pending native+drafts refuse the import before they are committed or replaced. Wait for this reply before+associating a document with a host save destination. 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 or pending cell/text-box draft change,+including further edits while already dirty. Revisions are bounded opaque string equality tokens,+never JavaScript numbers. An idle host may request export without Enter; the engine flushes its+pending drafts before the export reply and corresponding changed event identify the same state. `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.++For an opaque sandboxed iframe, use `?host=port&parent_origin=https%3A%2F%2Fhost.example`+(and `&save=managed` for durable native saves). Serve it at a pinned+`/b/<64 lowercase hex digits>/index.html` path; a prefix before `/b/` is allowed.+The public assets must permit cross-origin loading from the opaque frame. The editor sends only+`{type:"gridcraft:hello", protocol:1, bundle}` over the window channel, addressed to the+configured canonical HTTP(S) parent origin. The parent must check the iframe source, opaque+origin and expected bundle pin, then send `{type:"gridcraft:connect", protocol:1, bundle}`+with exactly one transferred MessagePort. Only the exact parent and origin can connect, once.+All existing document requests and notifications then use structured objects on that private+port, including transferred ArrayBuffer bytes up to 32 MiB. Subsequent window messages are+ignored. The origin and bundle handshake bind the transport; they grant no storage authority+or runtime credentials. The legacy `host=parent` mode retains its existing same-origin policy.+ ### Ribbon access keys Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,@@ -19,6 +19,7 @@ gridcraft-ui-egui = { workspace = true } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true }+sha2 = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }@@ -26,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", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "MessagePort", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }new file mode 100644@@ -0,0 +1,162 @@+//! Bounded host revision pulses include pending native editor drafts.++use gridcraft_engine::core::CellRef;+use gridcraft_ui_egui::SheetApp;+use sha2::{Digest, Sha256};++#[derive(PartialEq)]+struct Fingerprint {+ active: Option<(u64, u64)>,+ cell: Option<(u64, usize, CellRef, [u8; 32])>,+ text_box: Option<(u32, [u8; 32])>,+}++fn changed_cell(app: &SheetApp) -> bool {+ app.editor.as_ref().is_some_and(|edit| edit.original.as_ref().is_none_or(|original| original != &edit.text))+}++/// Draft buffers are native editor state, not yet part of `DocState::wb`.+pub fn dirty(app: &SheetApp) -> bool {+ app.session.documents().iter().any(|doc| doc.is_dirty())+ || changed_cell(app)+ || app.text_box_editor.as_ref().is_some_and(|edit| edit.is_modified())+}++/// Constant-size activity state: never clone or retain an input-sized draft.+/// The counter is an opaque equality token, not a JavaScript number or a lease.+#[derive(Default)]+pub struct Revisions {+ previous: Option<Fingerprint>,+ serial: u64,+}++impl Revisions {+ pub fn observe(&mut self, app: &SheetApp) -> bool {+ let fingerprint = Fingerprint {+ active: app.session.active().map(|doc| (doc.uid, doc.revision)),+ cell: app+ .editor+ .as_ref()+ .filter(|_| changed_cell(app))+ .map(|edit| (edit.doc, edit.sheet, edit.cell, Sha256::digest(edit.text.as_bytes()).into())),+ text_box: app+ .text_box_editor+ .as_ref()+ .filter(|edit| edit.is_modified())+ .map(|edit| (edit.id, Sha256::digest(edit.text.as_bytes()).into())),+ };+ if self.previous.as_ref() == Some(&fingerprint) {+ return false;+ }+ self.previous = Some(fingerprint);+ self.serial = self.serial.wrapping_add(1);+ true+ }++ pub fn sequence(&self) -> u64 {+ self.serial+ }++ pub fn revision(&self) -> String {+ self.sequence().to_string()+ }+}++#[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();+ let mut app = SheetApp::new(session, Services::default());+ app.run("cell.set", json!({"cell":"A1", "input":"first"})).unwrap();+ app+ }++ #[test]+ fn pending_cell_survives_older_ack_and_every_text_edit_pulses_without_enter() {+ let mut app = app();+ let mut revisions = super::Revisions::default();+ revisions.observe(&app);+ let mut saves = crate::managed_save::ManagedSaves::default();+ let first = crate::native_document::export(&mut app).unwrap();+ let first_revision = revisions.revision();+ saves.remember("first", &first, &app, &first_revision).unwrap();+ let engine_revision = app.session.active().unwrap().revision;+ app.begin_edit(Some("second".into()), false);+ assert!(revisions.observe(&app));+ let pending = revisions.revision();+ assert_ne!(first_revision, pending);+ assert!(!revisions.observe(&app));+ app.editor.as_mut().unwrap().text.push('!');+ assert!(revisions.observe(&app));+ assert_ne!(pending, revisions.revision());+ assert_eq!(engine_revision, app.session.active().unwrap().revision);+ assert!(saves.acknowledge(&mut app, "first", &first.document, &first_revision));+ assert!(!app.session.active().unwrap().is_dirty());+ assert!(super::dirty(&app), "a pending cell is unsaved even after the older workbook ACK");+ let second = crate::native_document::export(&mut app).unwrap();+ assert!(revisions.observe(&app), "export flushes the pending native cell before its revision is returned");+ let second_revision = revisions.revision();+ saves.remember("second", &second, &app, &second_revision).unwrap();+ assert!(saves.acknowledge(&mut app, "second", &second.document, &second_revision));+ assert!(!super::dirty(&app));+ }++ #[test]+ fn unchanged_editor_is_clean_and_invalid_draft_stays_dirty_until_cancelled() {+ let mut app = app();+ let first = crate::native_document::export(&mut app).unwrap();+ let mut saves = crate::managed_save::ManagedSaves::default();+ saves.remember("first", &first, &app, &first.revision).unwrap();+ assert!(saves.acknowledge(&mut app, "first", &first.document, &first.revision));+ app.begin_edit(None, false);+ assert!(!super::dirty(&app));+ app.editor.as_mut().unwrap().text = "=1+".into();+ assert!(super::dirty(&app));+ assert!(crate::native_document::export(&mut app).is_err());+ assert!(super::dirty(&app));+ app.cancel_edit();+ assert!(!super::dirty(&app));+ }++ #[test]+ fn pending_text_box_survives_ack_and_flushes_through_the_native_engine() {+ let mut app = app();+ let id = app.run("insert.textBox", json!({"at":"B2","text":"original"})).unwrap()["shape"].as_u64().unwrap() as u32;+ let first = crate::native_document::export(&mut app).unwrap();+ let mut saves = crate::managed_save::ManagedSaves::default();+ saves.remember("first", &first, &app, &first.revision).unwrap();+ assert!(saves.acknowledge(&mut app, "first", &first.document, &first.revision));+ assert!(app.begin_text_box_edit(id));+ assert!(!super::dirty(&app));+ let mut revisions = super::Revisions::default();+ revisions.observe(&app);+ let before = revisions.revision();+ saves.remember("older", &first, &app, &before).unwrap();+ app.text_box_editor.as_mut().unwrap().text = "pending text".into();+ assert!(revisions.observe(&app));+ assert_ne!(before, revisions.revision());+ assert!(saves.acknowledge(&mut app, "older", &first.document, &before));+ assert!(!app.session.active().unwrap().is_dirty());+ assert!(super::dirty(&app));+ let snapshot = crate::native_document::export(&mut app).unwrap();+ let (wb, _) = gridcraft_engine::io::open_bytes("snapshot.json", &snapshot.bytes).unwrap();+ assert_eq!(wb.active().unwrap().shapes.iter().find(|shape| shape.id == id).unwrap().text, "pending text");+ assert!(app.text_box_editor.is_none());+ }++ #[test]+ fn draft_counter_wraps_with_a_bounded_opaque_string() {+ let mut app = app();+ let mut revisions = super::Revisions::default();+ revisions.observe(&app);+ revisions.serial = u64::MAX;+ app.begin_edit(Some("another".into()), false);+ assert!(revisions.observe(&app));+ assert_eq!(revisions.revision(), "0");+ }+}@@ -4,13 +4,38 @@ //! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now. //! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or //! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.+//! With optional `requestId`, `gridcraft:opened` replies with `{ requestId, document, revision }`+//! only after actual engine import, or `{ requestId, error }`. One correlated import may be pending.+//! Correlated import refuses unsaved work or pending native drafts before settling them. //! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every //! 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 or pending cell/text-box draft changes. Revision is a bounded opaque activity+//! string, never a JavaScript number. 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+//! - `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.+//!+//! `?host=port&parent_origin=<canonical HTTP(S) origin>` opts into a one-shot private+//! MessagePort instead. Only a content-free `{type:"gridcraft:hello",protocol:1,bundle}`+//! uses the window channel. The bundle comes from the last `/b/<64hex>/index.html`+//! path segment. The exact parent and origin must connect with that bundle and+//! exactly one port; all document messages then use the port, with a 32 MiB byte limit.+//! Later window frames are ignored. This transport grants no runtime credentials.+//!+//! In legacy window mode, only the parent 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. use std::cell::{Cell, RefCell};@@ -28,8 +53,26 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"]; /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +enum NativeRequest {+ Open(String, String, Vec<u8>),+ Export(String),+ Validate(String, Vec<u8>),+ RequestSave,+ Acknowledge(String, String, String),+}++struct PortState {+ handshake: crate::port_handshake::Handshake,+ channel: Option<web_sys::MessagePort>,+}+ #[derive(Clone)] pub struct Bridge {+ port: Option<Rc<RefCell<PortState>>>,+ revisions: Rc<RefCell<crate::draft_state::Revisions>>,+ importing: Rc<Cell<bool>>,+ managed: bool,+ managed_saves: Rc<RefCell<crate::managed_save::ManagedSaves>>, window: web_sys::Window, parent: web_sys::Window, origin: String,@@ -37,28 +80,71 @@ 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 {+ port: None,+ revisions: Rc::default(),+ importing: Rc::default(),+ managed,+ managed_saves: Rc::default(),+ window,+ parent,+ origin,+ saved: Rc::default(),+ reported: Rc::default(),+ native: Rc::default(),+ reported_revision: Rc::default(),+ })+ }++ /// Opt-in private transport for an opaque sandboxed frame. The configured+ /// origin binds only the handshake; it never supplies runtime credentials.+ pub fn new_port(managed: bool, parent_origin: &str) -> Option<Self> {+ let url = web_sys::Url::new(parent_origin).ok()?;+ if !matches!(url.protocol().as_str(), "http:" | "https:")+ || url.origin() != parent_origin+ || !url.username().is_empty()+ || !url.password().is_empty()+ {+ return None;+ }+ let mut bridge = Self::new(managed)?;+ let path = bridge.window.location().pathname().ok()?;+ let bundle = crate::port_handshake::bundle_from_path(&path)?.to_string();+ bridge.port = Some(Rc::new(RefCell::new(PortState {+ handshake: crate::port_handshake::Handshake::new(parent_origin.to_string(), bundle),+ channel: None,+ })));+ Some(bridge) } /// Send saved and exported files 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);+ if self.port.is_some() {+ self.listen_port(inbox, ctx);+ } else {+ self.listen(inbox, ctx);+ } let bridge = self.clone(); services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { if let Err(e) = bridge.post_save(name, bytes) { log::error!("couldn't hand {name} to the page: {e}");+ if bridge.port.is_some() {+ bridge.post_failed(&e);+ } } })); }@@ -72,6 +158,18 @@ impl Bridge { } pub fn post_ready(&self) {+ if let Some(state) = &self.port {+ let mut state = state.borrow_mut();+ if state.channel.is_none() {+ if let Some(hello) =+ message(&[("type", "gridcraft:hello".into()), ("protocol", 1.into()), ("bundle", state.handshake.bundle.as_str().into())])+ && self.parent.post_message(&hello, &state.handshake.parent_origin).is_ok()+ {+ state.handshake.announced();+ }+ return;+ }+ } self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]); } @@ -82,6 +180,102 @@ 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) {+ self.revisions.borrow_mut().observe(app);+ let requests = std::mem::take(&mut *self.native.borrow_mut());+ for request in requests {+ match request {+ NativeRequest::Open(id, name, bytes) => {+ let result = crate::native_document::open(app, &name, &bytes).and_then(|opened| {+ if app+ .session+ .active()+ .is_none_or(|doc| doc.uid.to_string() != opened.document || doc.revision.to_string() != opened.revision)+ {+ return Err("The imported workbook is no longer active.".into());+ }+ Ok(opened)+ });+ self.importing.set(false);+ match result {+ Ok(opened) => {+ self.revisions.borrow_mut().observe(app);+ self.send(&[+ ("type", "gridcraft:opened".into()),+ ("requestId", id.as_str().into()),+ ("document", opened.document.as_str().into()),+ ("revision", self.revisions.borrow().revision().into()),+ ]);+ }+ Err(error) => self.open_failed(&id, &error),+ }+ }+ NativeRequest::Export(id) => match crate::native_document::export(app) {+ Ok(snapshot) => {+ self.revisions.borrow_mut().observe(app);+ let revision = self.revisions.borrow().revision();+ if self.port.is_some()+ && let Err(error) = crate::native_document::validate_length(snapshot.bytes.len())+ {+ self.send(&[+ ("type", "gridcraft:native-exported".into()),+ ("requestId", id.as_str().into()),+ ("error", error.as_str().into()),+ ]);+ continue;+ }+ if self.managed+ && let Err(error) = self.managed_saves.borrow_mut().remember(&id, &snapshot, app, &revision)+ {+ 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", revision.as_str().into()),+ ("bytes", buffer.clone().into()),+ ]) {+ let _ = self.post(&message, Some(&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", self.revisions.borrow().revision().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()@@ -91,20 +285,44 @@ impl Bridge { doc.title = name.clone(); } }- let dirty = app.session.documents().iter().any(|d| d.is_dirty());+ self.revisions.borrow_mut().observe(app);+ let dirty = crate::draft_state::dirty(app);+ let initial = self.reported.get().is_none(); 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, self.revisions.borrow().sequence()));+ if revision != self.reported_revision.get() || initial && revision.is_none() && self.port.is_some() {+ 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(());+ }+ if self.port.is_some() {+ crate::native_document::validate_length(bytes.len())?;+ } 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())?;- self.parent- .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))- .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;+ self.post(&message, Some(&js_sys::Array::of1(&buffer)))?; self.saved.borrow_mut().push(name); Ok(()) }@@ -112,18 +330,12 @@ 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") {- return;- }- let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());- let Some(bytes) = bytes(&data) else { return };- inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));- ctx.request_repaint();+ bridge.receive(&event.data(), &inbox, &ctx); }); 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.@@ -131,10 +343,174 @@ impl Bridge { } } + fn receive(&self, data: &JsValue, inbox: &Inbox, ctx: &egui::Context) {+ let bridge = self;+ 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());+ if js_sys::Reflect::get(data, &"requestId".into()).ok().is_some_and(|value| !value.is_undefined()) {+ let Some(id) = string(data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+ if bridge.importing.get() || bridge.native.borrow().len() >= 8 {+ bridge.open_failed(&id, "Another workbook import is pending.");+ return;+ }+ let bytes = match native_bytes(data) {+ Ok(bytes) => bytes,+ Err(error) => {+ bridge.open_failed(&id, &error);+ return;+ }+ };+ bridge.importing.set(true);+ bridge.native.borrow_mut().push(NativeRequest::Open(id, name, bytes));+ ctx.request_repaint();+ return;+ }+ let bytes = if bridge.port.is_some() {+ match native_bytes(data) {+ Ok(bytes) => bytes,+ Err(error) => {+ bridge.post_failed(&error);+ return;+ }+ }+ } else {+ let Some(bytes) = bytes(data) else { return };+ bytes+ };+ inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));+ ctx.request_repaint();+ }++ fn open_failed(&self, id: &str, error: &str) {+ self.send(&[("type", "gridcraft:opened".into()), ("requestId", id.into()), ("error", error.into())]);+ }++ fn listen_port(&self, inbox: Inbox, ctx: egui::Context) {+ let bridge = self.clone();+ let on_connect = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {+ let Some(state) = &bridge.port else { return };+ if state.borrow().channel.is_some() {+ return; // All window frames are ignored after the one-shot bind.+ }+ let data = event.data();+ if !closed_connect(&data) {+ return;+ }+ let ports = event.ports();+ let Ok(channel) = ports.get(0).dyn_into::<web_sys::MessagePort>() else { return };+ let protocol = js_sys::Reflect::get(&data, &"protocol".into()).ok().and_then(|value| value.as_f64());+ let parent = event.source().is_some_and(|source| js_sys::Object::is(&source, &bridge.parent));+ if !state.borrow_mut().handshake.accept(+ parent,+ &event.origin(),+ string(&data, "type").as_deref(),+ protocol,+ string(&data, "bundle").as_deref(),+ ports.length() as usize,+ ) {+ return;+ }+ let receiver = bridge.clone();+ let inbox = inbox.clone();+ let ctx = ctx.clone();+ let repaint = ctx.clone();+ let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {+ receiver.receive(&event.data(), &inbox, &ctx);+ });+ channel.set_onmessage(Some(on_message.as_ref().unchecked_ref()));+ channel.start();+ state.borrow_mut().channel = Some(channel);+ on_message.forget(); // Exactly one private listener lives until the frame closes.+ bridge.reported.set(None);+ bridge.reported_revision.set(None);+ bridge.post_ready();+ repaint.request_repaint();+ });+ if self.window.add_event_listener_with_callback("message", on_connect.as_ref().unchecked_ref()).is_ok() {+ on_connect.forget();+ }+ }++ fn post(&self, value: &JsValue, transfer: Option<&js_sys::Array>) -> Result<(), String> {+ let result = if let Some(state) = &self.port {+ let state = state.borrow();+ let channel = state.channel.as_ref().ok_or_else(|| "The private host port is not connected.".to_string())?;+ match transfer {+ Some(transfer) => channel.post_message_with_transferable(value, transfer),+ None => channel.post_message(value),+ }+ } else {+ match transfer {+ Some(transfer) => self.parent.post_message_with_transfer(value, &self.origin, transfer),+ None => self.parent.post_message(value, &self.origin),+ }+ };+ result.map_err(|error| error.as_string().unwrap_or_else(|| format!("{error:?}")))+ }+ /// 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())+ message(fields).is_some_and(|m| self.post(&m, None).is_ok())+ }+}++/// A closed plain-object handshake; inherited fields, arrays and extra keys+/// cannot smuggle content or credentials onto the public window channel.+fn closed_connect(value: &JsValue) -> bool {+ if !value.is_object() || value.is_null() || js_sys::Array::is_array(value) {+ return false; }+ if !js_sys::Object::is(&js_sys::Object::get_prototype_of(value), &js_sys::Object::get_prototype_of(&js_sys::Object::new())) {+ return false;+ }+ let keys = js_sys::Object::keys(value.unchecked_ref::<js_sys::Object>());+ keys.length() == 3 && keys.iter().all(|key| key.as_string().is_some_and(|key| matches!(key.as_str(), "type" | "protocol" | "bundle"))) } fn is_workbook(name: &str) -> bool {@@ -164,3 +540,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())+}@@ -16,9 +16,17 @@ //! interface size, editing options…) are kept in `localStorage` (`prefs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))]+mod draft_state; #[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 port_handshake;+#[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, revision: &str) -> 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: revision.to_string(),+ 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, &first.revision).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, &second.revision).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, &first.revision).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, &second.revision).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, &first.revision).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,229 @@+//! Whole native documents for opt-in embedding hosts.++use gridcraft_ui_egui::SheetApp;+use serde_json::json;++pub struct Snapshot {+ pub bytes: Vec<u8>,+ pub document: String,+ pub revision: String,+}++pub struct Opened {+ pub document: String,+ pub revision: String,+}++/// A correlated host import completes through the real UI/engine command before+/// its identity is returned. Merely enqueueing bytes in the file-picker inbox+/// cannot prove which document was opened.+pub fn open(app: &mut SheetApp, name: &str, bytes: &[u8]) -> Result<Opened, String> {+ // File commands settle drafts. Refuse before that boundary so an error or+ // a replacement cannot silently commit or discard unacknowledged work.+ if crate::draft_state::dirty(app) || app.editor.is_some() || app.text_box_editor.is_some() {+ return Err("Save or discard unsaved work and finish native drafts before opening another workbook.".into());+ }+ let result = app.run("file.open", json!({"name":name,"base64":gridcraft_engine::io::base64_encode(bytes)}))?;+ if let Some(warnings) = result.get("warnings").and_then(serde_json::Value::as_array)+ && !warnings.is_empty()+ {+ return Err(warnings.iter().filter_map(serde_json::Value::as_str).collect::<Vec<_>>().join("\n"));+ }+ let doc = app.session.active().ok_or_else(|| "The engine did not open a workbook.".to_string())?;+ Ok(Opened { document: doc.uid.to_string(), revision: doc.revision.to_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 native RPC bytes before crossing the JS-to-Rust copy boundary.+/// The private port also bounds file import/export; legacy window import stays unchanged.+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 correlated_open_identity_belongs_to_the_import_not_a_later_file_new() {+ let mut app = app();+ let mut source = Session::new();+ source.new_workbook();+ source.execute("cell.set", json!({"cell":"A1","input":"import A"})).unwrap();+ let encoded = source.execute("file.saveBytes", json!({"format":"json"})).unwrap();+ let bytes = gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap();+ let opened = super::open(&mut app, "owned.json", &bytes).unwrap();+ assert_eq!(opened.document, app.session.active().unwrap().uid.to_string());+ assert_eq!(opened.revision, app.session.active().unwrap().revision.to_string());+ assert_eq!(app.run("cell.get", json!({"cell":"A1"})).unwrap()["value"], "import A");+ app.run("file.new", json!({})).unwrap();+ assert_ne!(opened.document, app.session.active().unwrap().uid.to_string());+ let before = app.session.active().unwrap().uid;+ assert!(super::open(&mut app, "invalid.json", b"invalid JSON").is_err());+ assert_eq!(before, app.session.active().unwrap().uid);+ }++ fn imported_bytes(value: &str) -> Vec<u8> {+ let mut source = Session::new();+ source.new_workbook();+ source.execute("cell.set", json!({"cell":"A1","input":value})).unwrap();+ let encoded = source.execute("file.saveBytes", json!({"format":"json"})).unwrap();+ gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap()+ }++ #[test]+ fn same_import_name_with_new_bytes_opens_a_new_actual_document() {+ let mut app = app();+ let first = super::open(&mut app, "owned.json", &imported_bytes("import A")).unwrap();+ let second = super::open(&mut app, "owned.json", &imported_bytes("import B")).unwrap();+ assert_ne!(first.document, second.document);+ assert_eq!(second.document, app.session.active().unwrap().uid.to_string());+ assert_eq!(app.run("cell.get", json!({"cell":"A1"})).unwrap()["value"], "import B");+ }++ #[test]+ fn owned_import_refuses_dirty_work_without_replacing_or_settling_it() {+ let mut app = app();+ app.run("cell.set", json!({"cell":"A1","input":"unsaved"})).unwrap();+ let before = app.session.active().unwrap().wb.clone();+ let uid = app.session.active().unwrap().uid;+ assert!(super::open(&mut app, "owned.json", &imported_bytes("replacement")).is_err());+ assert_eq!(uid, app.session.active().unwrap().uid);+ assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().wb));+ assert!(crate::draft_state::dirty(&app));+ }++ #[test]+ fn owned_import_error_retains_pending_cell_without_committing_it() {+ let mut app = app();+ let before = app.session.active().unwrap().wb.clone();+ app.begin_edit(Some("pending cell".into()), false);+ assert!(super::open(&mut app, "invalid.json", b"invalid JSON").is_err());+ assert_eq!(app.editor.as_ref().unwrap().text, "pending cell");+ assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().wb));+ assert!(crate::draft_state::dirty(&app));+ }++ #[test]+ fn owned_import_refuses_pending_text_box_without_committing_it() {+ let mut app = app();+ let id = app.run("insert.textBox", json!({"at":"B2","text":"original"})).unwrap()["shape"].as_u64().unwrap() as u32;+ let doc = app.session.active_mut().unwrap();+ doc.saved = doc.wb.clone();+ let before = doc.wb.clone();+ assert!(app.begin_text_box_edit(id));+ app.text_box_editor.as_mut().unwrap().text = "pending text box".into();+ assert!(super::open(&mut app, "owned.json", &imported_bytes("replacement")).is_err());+ assert_eq!(app.text_box_editor.as_ref().unwrap().text, "pending text box");+ assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().wb));+ assert!(crate::draft_state::dirty(&app));+ }++ #[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();+ }+}new file mode 100644@@ -0,0 +1,77 @@+//! One-shot binding of an opaque editor frame to its embedding parent.++/// The bundle declares its identity from the last pinned deployment path segment.+pub fn bundle_from_path(path: &str) -> Option<&str> {+ let (_, tail) = path.rsplit_once("/b/")?;+ let bundle = tail.strip_suffix("/index.html")?;+ (bundle.len() == 64 && bundle.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))).then_some(bundle)+}++pub struct Handshake {+ pub parent_origin: String,+ pub bundle: String,+ announced: bool,+ connected: bool,+}++impl Handshake {+ pub fn new(parent_origin: String, bundle: String) -> Self {+ Self { parent_origin, bundle, announced: false, connected: false }+ }++ pub fn announced(&mut self) {+ self.announced = true;+ }++ pub fn accept(&mut self, parent: bool, origin: &str, kind: Option<&str>, protocol: Option<f64>, bundle: Option<&str>, ports: usize) -> bool {+ if !self.announced+ || self.connected+ || !parent+ || origin != self.parent_origin+ || kind != Some("gridcraft:connect")+ || protocol != Some(1.0)+ || bundle != Some(self.bundle.as_str())+ || ports != 1+ {+ return false;+ }+ self.connected = true;+ true+ }+}++#[cfg(test)]+mod tests {+ #[test]+ fn bundle_identity_is_the_last_pinned_bundle_path_segment() {+ let hash = "a".repeat(64);+ for prefix in ["", "/workbook-editor", "/b/old"] {+ assert_eq!(super::bundle_from_path(&format!("{prefix}/b/{hash}/index.html")), Some(hash.as_str()));+ }+ for path in ["/index.html", "/b/short/index.html", "/b/ABC/index.html", "/b/a/other/index.html"] {+ assert!(super::bundle_from_path(path).is_none());+ }+ assert!(super::bundle_from_path(&format!("/b/{}/index.html", "A".repeat(64))).is_none());+ }++ #[test]+ fn only_exact_parent_origin_bundle_protocol_and_one_port_bind_once_after_hello() {+ let hash = "a".repeat(64);+ let mut binding = super::Handshake::new("https://host.example".into(), hash.clone());+ assert!(!binding.accept(true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(&hash), 1));+ binding.announced();+ for (parent, origin, kind, protocol, bundle, ports) in [+ (false, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 1),+ (true, "https://other.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 1),+ (true, "https://host.example", Some("gridcraft:open"), Some(1.0), Some(hash.as_str()), 1),+ (true, "https://host.example", Some("gridcraft:connect"), Some(2.0), Some(hash.as_str()), 1),+ (true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some("wrong"), 1),+ (true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 0),+ (true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 2),+ ] {+ assert!(!binding.accept(parent, origin, kind, protocol, bundle, ports));+ }+ assert!(binding.accept(true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(&hash), 1));+ assert!(!binding.accept(true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(&hash), 1));+ }+}@@ -39,7 +39,12 @@ 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 managed = query_param("save").as_deref() == Some("managed");+ let bridge = match query_param("host").as_deref() {+ Some("parent") => crate::host::Bridge::new(managed),+ Some("port") => query_param("parent_origin").and_then(|origin| crate::host::Bridge::new_port(managed, &origin)),+ _ => None,+ }; let app_bridge = bridge.clone(); let result = eframe::WebRunner::new() .start(@@ -17,6 +17,13 @@ pub struct EditState { rect: Option<Rect>, } +impl EditState {+ /// The native pending text differs from the engine's original text box.+ pub fn is_modified(&self) -> bool {+ self.text != self.original+ }+}+ /// Returns the stable id for the text-box editor. pub(crate) fn editor_id() -> egui::Id { egui::Id::unique("gridcraft.text_box_editor")