Browse / github.com/storytold/wordcraft / patch
feat(web): add capability-bound private port hosting
Web host: private capability port for opaque sandbox embeds The existing `host=parent` bridge deliberately requires a same-origin parent. An embedding page using `sandbox="allow-scripts"` with a credentialless iframe instead gives the editor an opaque origin; it needs an opt-in capability transport without relaxing that sandbox or the legacy window checks. This follows #4573, #4584 and #4590. Use `host=port&parent_origin=<canonical HTTP(S) origin>`, with the bundle digest taken from the immutable `/b/<64 lowercase hex>/index.html` path (optionally under `/_editor` for a same-host asset mount, or `/workbook-editor`). The public window channel should ca
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Srbino (imported from pull request #4603, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +1315 −46 in 16 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4e32d9c92d45b50c46b2fc70
How it went
No other operator has reported using it yet.
Diff
raw · apps/wordcraft-web/Cargo.toml apps/wordcraft-web/src/capability.rs apps/wordcraft-web/src/host.rs apps/wordcraft-web/src/main.rs apps/wordcraft-web/src/managed.rs apps/wordcraft-web/src/snapshot.rs apps/wordcraft-web/src/web.rs crates/engine/src/tests.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/app/files.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panes/comments.rs crates/ui-egui/src/services.rs crates/ui-egui/src/tests.rs
@@ -29,4 +29,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", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "MessagePort", "UrlSearchParams"] }new file mode 100644@@ -0,0 +1,109 @@+//! Control-only handshake and size bounds for an opt-in private native port.++/// Native documents use the host's version limit, independently of small control messages.+pub const MAX_NATIVE_BYTES: usize = 32 * 1024 * 1024;++pub fn native_len_ok(length: usize) -> bool {+ length > 0 && length <= MAX_NATIVE_BYTES+}++/// The immutable bundle comes from the fetched path, never a query supplied by the parent.+pub fn bundle_from_path(path: &str) -> Option<&str> {+ let path = path.strip_prefix("/_editor").or_else(|| path.strip_prefix("/workbook-editor")).unwrap_or(path);+ let bundle = path.strip_prefix("/b/")?.strip_suffix("/index.html")?;+ (bundle.len() == 64 && bundle.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))).then_some(bundle)+}++/// `protocol` and `origin` are obtained from the browser's URL parser. Require the supplied+/// string to already be the canonical HTTP(S) origin, without path, credentials or query.+pub fn canonical_origin(input: &str, protocol: &str, origin: &str) -> bool {+ matches!(protocol, "http:" | "https:") && input == origin+}++pub fn connect_matches(value: &serde_json::Value, expected_bundle: &str, ports: usize) -> bool {+ let Some(fields) = value.as_object() else { return false };+ ports == 1+ && fields.len() == 3+ && value.get("type").and_then(serde_json::Value::as_str) == Some("wordcraft:connect")+ && value.get("protocol").and_then(serde_json::Value::as_u64) == Some(1)+ && value.get("bundle").and_then(serde_json::Value::as_str) == Some(expected_bundle)+}++/// Read JS byteLength as a Number before any u32 conversion: large buffers must not wrap.+pub fn native_js_len_ok(length: f64) -> bool {+ length.is_finite() && length.fract() == 0. && length > 0. && length <= MAX_NATIVE_BYTES as f64+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;+ const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";++ #[test]+ fn bundle_is_taken_only_from_a_qualified_path() {+ assert_eq!(bundle_from_path(&format!("/b/{DIGEST}/index.html")), Some(DIGEST));+ assert_eq!(bundle_from_path(&format!("/workbook-editor/b/{DIGEST}/index.html")), Some(DIGEST));+ for path in [+ format!("/other/b/{DIGEST}/index.html"),+ format!("/b/{DIGEST}/other.html"),+ format!("/b/{DIGEST}/index.html/"),+ format!("/b/{}/index.html", DIGEST.to_uppercase()),+ format!("/b/{DIGEST}/../index.html"),+ ] {+ assert_eq!(bundle_from_path(&path), None, "{path}");+ }+ }+ #[test]+ fn connect_has_exactly_three_keys_matching_one_bundle_and_one_port() {+ let good = json!({"type":"wordcraft:connect","protocol":1,"bundle":DIGEST});+ assert!(connect_matches(&good, DIGEST, 1));+ assert!(!connect_matches(&good, DIGEST, 0));+ assert!(!connect_matches(&good, DIGEST, 2));+ assert!(!connect_matches(&good, &"a".repeat(64), 1));+ for bad in [+ json!({"type":"wordcraft:connect","protocol":1,"bundle":DIGEST,"extra":true}),+ json!({"type":"wordcraft:connect","protocol":2,"bundle":DIGEST}),+ json!({"type":"wordcraft:open","protocol":1,"bundle":DIGEST}),+ json!({"type":"wordcraft:connect","protocol":1}),+ json!([]),+ ] {+ assert!(!connect_matches(&bad, DIGEST, 1));+ }+ }+ #[test]+ fn parent_origin_must_be_the_exact_canonical_http_origin() {+ assert!(canonical_origin("https://host.test", "https:", "https://host.test"));+ assert!(canonical_origin("http://127.0.0.1:8090", "http:", "http://127.0.0.1:8090"));+ for (raw, protocol, parsed) in [+ ("null", "file:", "null"),+ ("https://host.test/", "https:", "https://host.test"),+ ("https://host.test/path", "https:", "https://host.test"),+ ("https://user@host.test", "https:", "https://host.test"),+ ("HTTPS://HOST.TEST", "https:", "https://host.test"),+ ("ftp://host.test", "ftp:", "ftp://host.test"),+ ] {+ assert!(!canonical_origin(raw, protocol, parsed));+ }+ }+ #[test]+ fn native_binary_limit_accepts_more_than_four_mib_but_never_more_than_32() {+ assert!(!native_len_ok(0));+ assert!(native_len_ok(4 * 1024 * 1024 + 1));+ assert!(native_len_ok(32 * 1024 * 1024));+ assert!(!native_len_ok(32 * 1024 * 1024 + 1));+ }+ #[test]+ fn qualified_same_host_editor_prefix_is_accepted() {+ assert_eq!(bundle_from_path(&format!("/_editor/b/{DIGEST}/index.html")), Some(DIGEST));+ }++ #[test]+ fn native_js_buffer_lengths_do_not_wrap_at_four_gib() {+ assert!(native_js_len_ok(4_194_305.));+ assert!(native_js_len_ok(33_554_432.));+ for length in [0., -1., 0.5, f64::NAN, f64::INFINITY, 33_554_433., 4_294_967_297.] {+ assert!(!native_js_len_ok(length), "{length}");+ }+ }+}@@ -5,7 +5,10 @@ //! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and //! listening; send a file now. //! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or-//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.+//! `Uint8Array`. Without a request ID it opens like a picked or dropped document. An optional+//! bounded `requestId` requests a correlated import: unsaved/unconfirmed work is refused, and+//! `wordcraft:opened {requestId, document, revision}` is sent only after the actual import, or+//! `{requestId, error}` on failure. One correlated import can be pending. //! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for //! every file WordCraft would otherwise download. The parent stores it and reports failures //! itself. `saveAs` says whether the file becomes the document's file:@@ -18,34 +21,98 @@ //! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and //! Export Page as PNG: `false`. They are copies; the document's file stays the same. //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `wordcraft:changed` (to the parent): `{ document, revision }` after every native document revision,+//! including further edits while `dirty` stays true. Revision is an opaque string that also+//! distinguishes replacing the document. Document is a separate opaque identity string; hosts+//! must refuse to store snapshots under a different document's identity.+//! - `wordcraft:snapshot` (from the parent): `{ requestId }`, a nonempty string of at most 128+//! UTF-8 bytes. Requests the complete native JSON. Pending comment/reply text is committed as+//! for File Save; dirty state is never cleared and storage is not acknowledged.+//! - `wordcraft:snapshot-result` (to the parent): `{ requestId, document, revision, bytes }` (a transferred+//! `ArrayBuffer` of native JSON), or `{ requestId, error }`. At most one request is pending;+//! another receives an error. The native Document parser checks the serialized snapshot.+//! Neither snapshot nor changed grants durable Saved. Reopen native bytes with `wordcraft:open`+//! and a `.json` name. The host guards unsaved work before replacing a document.+//!+//! With `&save=managed`, Save instead emits `wordcraft:save-request+//! {requestId, document, revision}` without stamping, exporting or marking the document saved.+//! The host requests a native snapshot with that requestId and, only after durable storage,+//! answers `wordcraft:saved {requestId, document, revision}` for that exported snapshot. Only+//! an exact current document/revision (including pending comment drafts) clears dirty. An older+//! known manual save completes its ticket while newer edits remain unsaved. Unknown ACKs do+//! nothing. `wordcraft:save-failed {requestId, error}` ends only its matching manual ticket;+//! error is nonempty and at most 1024 UTF-8 bytes. One manual save may be pending. Save As and+//! Export remain downloadable copies and never acknowledge storage of the native document.+//! //! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start. //!-//! Only the parent window on the page's own origin is heard or answered. In host mode the parent-//! guards leaving the page, and `?author=` names the user for comments and tracked changes.+//! Legacy parent mode only hears and answers the parent window on the page's own origin.+//! `?host=port&parent_origin=<canonical HTTP(S) origin>` instead supports an opaque sandbox.+//! Its immutable path must be `/b/<64 lowercase hex>/index.html`, optionally prefixed by+//! `/_editor` (same-host assets) or `/workbook-editor`. After child load it sends only `{type:"wordcraft:hello",protocol:1,bundle}`+//! over the window channel. One exact-parent, configured-origin connect with exactly those three+//! keys (`type:"wordcraft:connect"`) and one transferred MessagePort is accepted once. All+//! document content and subsequent events use that private port, never a window fallback.+//! Document identities and revisions are local to this editor instance. Hosts must retire old+//! ports and callbacks on navigation, never reusing an old ACK for a new iframe instance.+//! Port mode enables managed saves; native binary frames are nonempty and at most 32 MiB.+//! Correlated Open also checks this bound before copying JS bytes; legacy ID-less window Open+//! retains its behavior. In host mode the parent guards leaving the page, and `?author=` names+//! the user for comments and tracked changes. -use std::cell::Cell;+use std::cell::{Cell, RefCell}; use std::rc::Rc; +use crate::managed::{ManagedSaves, SnapshotKey}; use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure;+use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome}; use wordcraft_ui_egui::{Inbox, Services, WordApp}; +#[derive(Clone, PartialEq, Eq)]+enum Acknowledgement {+ Saved(SnapshotKey),+ Failed(String, String),+}+ /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +struct PendingOpen {+ request_id: String,+ name: String,+ bytes: Vec<u8>,+}++#[derive(Clone)]+struct PrivatePort {+ bundle: String,+ endpoint: Rc<RefCell<Option<web_sys::MessagePort>>>,+ hello_sent: Rc<Cell<bool>>,+ ready: Rc<Cell<bool>>,+}+ #[derive(Clone)] pub struct Bridge {+ port: Option<PrivatePort>,+ managed: bool,+ saves: AsyncSaves,+ managed_saves: Rc<RefCell<ManagedSaves>>,+ acknowledgements: Rc<RefCell<Vec<Acknowledgement>>>, window: web_sys::Window, parent: web_sys::Window, origin: String,+ snapshot: Rc<RefCell<Option<String>>>,+ open: Rc<RefCell<Option<PendingOpen>>>,+ observed: Rc<RefCell<crate::snapshot::RevisionObserver>>, } impl Bridge { /// `None` when the page isn't framed (there is no parent to talk to), or when the parent is /// on another origin: messages only go to this origin, so the browser would drop every save /// without an error while the document showed as saved.- 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) {@@ -60,7 +127,48 @@ impl Bridge { if parent.location().origin().ok()? != origin { return None; }- Some(Self { window, parent, origin })+ Some(Self {+ port: None,+ managed,+ saves: AsyncSaves::default(),+ managed_saves: Rc::default(),+ acknowledgements: Rc::default(),+ window,+ parent,+ origin,+ snapshot: Rc::default(),+ open: Rc::default(),+ observed: Rc::default(),+ })+ }++ /// Opaque iframe profile: trust only a canonical configured parent origin and one private+ /// capability port. The legacy constructor's same-origin checks remain unchanged.+ pub fn new_port(parent_origin: &str) -> Option<Self> {+ let window = web_sys::window()?;+ let parent = window.parent().ok().flatten()?;+ if js_sys::Object::is(&parent, &window) {+ return None;+ }+ let parsed = web_sys::Url::new(parent_origin).ok()?;+ if !crate::capability::canonical_origin(parent_origin, &parsed.protocol(), &parsed.origin()) {+ return None;+ }+ let path = window.location().pathname().ok()?;+ let bundle = crate::capability::bundle_from_path(&path)?.to_owned();+ Some(Self {+ port: Some(PrivatePort { bundle, endpoint: Rc::default(), hello_sent: Rc::default(), ready: Rc::default() }),+ managed: true,+ saves: AsyncSaves::default(),+ managed_saves: Rc::default(),+ acknowledgements: Rc::default(),+ window,+ parent,+ origin: parent_origin.into(),+ snapshot: Rc::default(),+ open: Rc::default(),+ observed: Rc::default(),+ }) } /// Hand saved and exported files to the parent instead of downloading them, report unsaved@@ -74,6 +182,38 @@ impl Bridge { services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false))); // The browser's save picker is never used, so no save finishes later. services.async_saves = None;+ if self.managed {+ services.async_saves = Some(self.saves.clone());+ let bridge = self.clone();+ services.host_save_request = Some(Box::new(move |session, draft_revision| {+ if bridge.managed_saves.borrow().pending() {+ return Err("native save already pending".into());+ }+ let ticket = bridge.saves.start();+ let document = crate::snapshot::document(session);+ let revision = crate::snapshot::revision(session, draft_revision);+ let request_id = match bridge.managed_saves.borrow_mut().start(&document, ticket) {+ Ok(id) => id,+ Err(error) => {+ bridge.saves.finish(ticket, SaveOutcome::Failed(error.clone()));+ return Err(error);+ }+ };+ if bridge.send(&[+ ("type", "wordcraft:save-request".into()),+ ("requestId", request_id.as_str().into()),+ ("document", document.as_str().into()),+ ("revision", revision.as_str().into()),+ ]) {+ Ok(ticket)+ } else {+ bridge.managed_saves.borrow_mut().fail(&request_id);+ let error = "couldn't request native save".to_string();+ bridge.saves.finish(ticket, SaveOutcome::Failed(error.clone()));+ Err(error)+ }+ }));+ } let bridge = self.clone(); let reported: Rc<Cell<Option<bool>>> = Rc::default(); services.on_dirty = Some(Box::new(move |dirty: bool| {@@ -92,6 +232,9 @@ impl Bridge { } pub fn post_ready(&self) {+ if let Some(port) = &self.port {+ port.ready.set(true);+ } self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]); } @@ -101,6 +244,9 @@ impl Bridge { } fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {+ if self.port.is_some() && !crate::capability::native_len_ok(bytes.len()) {+ return Err("native file must be nonempty and at most 32 MiB".into());+ } 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 message = message(&[@@ -110,37 +256,315 @@ impl Bridge { ("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.transfer(&message, &buffer) } /// Hear `wordcraft:open` from the parent and queue the file like a dropped one. fn listen(&self, inbox: Inbox, ctx: egui::Context) {+ let snapshot = self.snapshot.clone();+ let open = self.open.clone();+ let bridge = self.clone();+ let request_ctx = ctx.clone();+ let receive: Rc<dyn Fn(JsValue)> = Rc::new(move |data: JsValue| {+ if bridge.managed {+ let kind = string(&data, "type");+ let acknowledgement = match kind.as_deref() {+ Some("wordcraft:saved") => {+ let Some(request_id) = bounded_string(&data, "requestId", 128) else { return };+ let Some(document) = bounded_string(&data, "document", 128) else { return };+ let Some(revision) = bounded_string(&data, "revision", 128) else { return };+ let key = SnapshotKey { request_id, document, revision };+ if !bridge.managed_saves.borrow().knows(&key) {+ return;+ }+ Some(Acknowledgement::Saved(key))+ }+ Some("wordcraft:save-failed") => {+ let Some(id) = bounded_string(&data, "requestId", 128) else { return };+ if !bridge.managed_saves.borrow().knows_failure(&id) {+ return;+ }+ let Some(error) = bounded_string(&data, "error", 1024) else { return };+ Some(Acknowledgement::Failed(id, error))+ }+ _ => None,+ };+ if let Some(acknowledgement) = acknowledgement {+ let mut pending = bridge.acknowledgements.borrow_mut();+ let id = match &acknowledgement {+ Acknowledgement::Saved(key) => &key.request_id,+ Acknowledgement::Failed(id, _) => id,+ };+ let already_pending = pending.iter().any(|old| match old {+ Acknowledgement::Saved(key) => &key.request_id == id,+ Acknowledgement::Failed(old_id, _) => old_id == id,+ });+ if pending.len() < 2 && !already_pending {+ pending.push(acknowledgement);+ request_ctx.request_repaint();+ }+ return;+ }+ }+ if string(&data, "type").as_deref() == Some("wordcraft:snapshot") {+ let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+ if snapshot.borrow().is_some() {+ bridge.send(&[+ ("type", "wordcraft:snapshot-result".into()),+ ("requestId", id.into()),+ ("error", "snapshot already pending".into()),+ ]);+ return;+ }+ *snapshot.borrow_mut() = Some(id);+ request_ctx.request_repaint();+ return;+ }+ if string(&data, "type").as_deref() != Some("wordcraft:open") {+ return;+ }+ let request_id = if js_sys::Reflect::has(&data, &JsValue::from_str("requestId")).unwrap_or(false) {+ let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+ Some(id)+ } else {+ None+ };+ if let Some(id) = &request_id+ && open.borrow().is_some()+ {+ bridge.post_open_error(id, "open already pending");+ return;+ }+ let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());+ let bytes = match bytes(&data, bridge.port.is_some() || request_id.is_some()) {+ Ok(bytes) => bytes,+ Err(error) => {+ if let Some(id) = request_id {+ bridge.post_open_error(&id, &error);+ }+ return;+ }+ };+ if let Some(request_id) = request_id {+ *open.borrow_mut() = Some(PendingOpen { request_id, name, bytes });+ } else {+ inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));+ }+ request_ctx.request_repaint();+ });+ if self.port.is_some() {+ self.listen_port(receive, ctx);+ return;+ } let parent = self.parent.clone(); let origin = self.origin.clone(); let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| { if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { return; }+ receive(event.data());+ });+ if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {+ on_message.forget();+ }+ }++ fn listen_port(&self, receive: Rc<dyn Fn(JsValue)>, 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.hello_sent.get()+ || state.endpoint.borrow().is_some()+ || event.origin() != bridge.origin+ || !event.source().is_some_and(|s| js_sys::Object::is(&s, &bridge.parent))+ {+ return;+ } let data = event.data();- if string(&data, "type").as_deref() != Some("wordcraft:open") {+ let Some(object) = data.dyn_ref::<js_sys::Object>() else {+ return;+ };+ if js_sys::Object::keys(object).length() != 3+ || string(&data, "type").as_deref() != Some("wordcraft:connect")+ || string(&data, "bundle").as_deref() != Some(state.bundle.as_str())+ || js_sys::Reflect::get(&data, &"protocol".into()).ok().and_then(|v| v.as_f64()) != Some(1.)+ { return; }- let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());- let Some(bytes) = bytes(&data) else { return };- inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));+ // Only three bounded primitive control fields can reach JSON.stringify; native+ // document content is always binary and never passes through this conversion.+ let valid = js_sys::JSON::stringify(&data)+ .ok()+ .and_then(|v| v.as_string())+ .and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())+ .is_some_and(|v| crate::capability::connect_matches(&v, &state.bundle, event.ports().length() as usize));+ if !valid {+ return;+ }+ let Ok(port) = event.ports().get(0).dyn_into::<web_sys::MessagePort>() else {+ return;+ };+ let receive = receive.clone();+ let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| receive(event.data()));+ port.set_onmessage(Some(on_message.as_ref().unchecked_ref()));+ port.start();+ *state.endpoint.borrow_mut() = Some(port);+ on_message.forget();+ if state.ready.get() {+ bridge.post_ready();+ } 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();+ if self.window.add_event_listener_with_callback("message", on_connect.as_ref().unchecked_ref()).is_ok() {+ on_connect.forget();+ if self.window.document().is_some_and(|d| d.ready_state() == "complete") {+ self.defer_hello();+ } else {+ let bridge = self.clone();+ let on_load = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| bridge.defer_hello());+ if self.window.add_event_listener_with_callback("load", on_load.as_ref().unchecked_ref()).is_ok() {+ on_load.forget();+ }+ } } } - /// Post a message to the parent; false if it couldn't be sent.+ fn defer_hello(&self) {+ let bridge = self.clone();+ let callback = Closure::<dyn FnMut()>::once(move || {+ let Some(port) = &bridge.port else {+ return;+ };+ if port.hello_sent.get() {+ return;+ }+ let Some(value) = message(&[("type", "wordcraft:hello".into()), ("protocol", 1.into()), ("bundle", port.bundle.as_str().into())]) else {+ return;+ };+ if bridge.parent.post_message(&value, &bridge.origin).is_ok() {+ port.hello_sent.set(true);+ }+ });+ // A task after child load also lets the parent's iframe load counter advance before+ // its strict handshake verifier receives the hello.+ if self.window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.as_ref().unchecked_ref(), 0).is_ok() {+ callback.forget();+ }+ }++ /// Report native revisions independently of dirty state, then answer a pending native+ /// snapshot request after the app has processed its inbox and commands. Pending comment/reply+ /// drafts are committed through the same commands as Save. Neither is a save+ /// acknowledgement; the host must confirm its own durable storage separately.+ pub fn observe(&self, app: &mut WordApp, ctx: &egui::Context) {+ // Never flush drafts on ACK. Compare the current token first: a known old manual save+ // completes its ticket, but newer edits (including unblurred comment text) stay dirty.+ for acknowledgement in std::mem::take(&mut *self.acknowledgements.borrow_mut()) {+ match acknowledgement {+ Acknowledgement::Saved(key) => {+ let document = crate::snapshot::document(&app.session);+ let revision = crate::snapshot::revision(&app.session, app.native_draft_revision());+ if let Some(completion) = self.managed_saves.borrow_mut().acknowledge(&key, &document, &revision) {+ if completion.mark_saved {+ app.session.dirty = false;+ }+ if let Some(ticket) = completion.ticket {+ self.saves.finish(ticket, SaveOutcome::Saved);+ }+ ctx.request_repaint();+ }+ }+ Acknowledgement::Failed(id, error) => {+ if let Some(ticket) = self.managed_saves.borrow_mut().fail(&id) {+ self.saves.finish(ticket, SaveOutcome::Failed(error));+ ctx.request_repaint();+ }+ }+ }+ }+ if let Some(pending) = self.open.borrow_mut().take() {+ match app.open_host_document(&pending.name, &pending.bytes) {+ Ok(()) => {+ let document = crate::snapshot::document(&app.session);+ let revision = crate::snapshot::revision(&app.session, app.native_draft_revision());+ self.send(&[+ ("type", "wordcraft:opened".into()),+ ("requestId", pending.request_id.as_str().into()),+ ("document", document.as_str().into()),+ ("revision", revision.as_str().into()),+ ]);+ }+ Err(error) => self.post_open_error(&pending.request_id, &error),+ }+ }+ let pending = self.snapshot.borrow_mut().take();+ let prepared = if pending.is_some() { app.prepare_native_snapshot() } else { Ok(()) };+ let session = &app.session;+ let draft_revision = app.native_draft_revision();+ let document = crate::snapshot::document(session);+ let revision = crate::snapshot::revision(session, draft_revision);+ if self.observed.borrow_mut().changed(session, draft_revision) {+ self.send(&[("type", "wordcraft:changed".into()), ("document", document.as_str().into()), ("revision", revision.as_str().into())]);+ }+ let Some(id) = pending else { return };+ let key = SnapshotKey { request_id: id.clone(), document: document.clone(), revision: revision.clone() };+ let result = prepared+ .and_then(|()| {+ if self.managed {+ self.managed_saves.borrow().check_export(&key)?;+ }+ crate::snapshot::native_snapshot(session)+ })+ .and_then(|bytes| {+ if self.port.is_some() && !crate::capability::native_len_ok(bytes.len()) {+ return Err("native snapshot must be nonempty and at most 32 MiB".into());+ }+ let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer();+ let value = message(&[+ ("type", "wordcraft:snapshot-result".into()),+ ("requestId", id.as_str().into()),+ ("document", document.as_str().into()),+ ("revision", revision.as_str().into()),+ ("bytes", buffer.clone().into()),+ ])+ .ok_or_else(|| "couldn't build the snapshot message".to_string())?;+ self.transfer(&value, &buffer)?;+ if self.managed {+ self.managed_saves.borrow_mut().exported(key)?;+ }+ Ok(())+ });+ if let Err(error) = result {+ self.send(&[("type", "wordcraft:snapshot-result".into()), ("requestId", id.as_str().into()), ("error", error.as_str().into())]);+ }+ }++ fn post_open_error(&self, request_id: &str, error: &str) {+ self.send(&[("type", "wordcraft:opened".into()), ("requestId", request_id.into()), ("error", error.into())]);+ }++ /// Port mode never falls back to the public window channel for content or events. 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| match &self.port {+ Some(state) => state.endpoint.borrow().as_ref().is_some_and(|port| port.post_message(&m).is_ok()),+ None => self.parent.post_message(&m, &self.origin).is_ok(),+ })+ }++ fn transfer(&self, value: &JsValue, buffer: &js_sys::ArrayBuffer) -> Result<(), String> {+ let transfer = js_sys::Array::of1(buffer);+ match &self.port {+ Some(state) => state+ .endpoint+ .borrow()+ .as_ref()+ .ok_or_else(|| "private editor port is not connected".to_string())?+ .post_message_with_transferable(value, &transfer),+ None => self.parent.post_message_with_transfer(value, &self.origin, &transfer),+ }+ .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}"))) } } @@ -156,14 +580,31 @@ fn string(data: &JsValue, key: &str) -> Option<String> { js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() } -/// `bytes` as an `ArrayBuffer` or `Uint8Array`.-fn bytes(data: &JsValue) -> Option<Vec<u8>> {- let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;- if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {- Some(array.to_vec())+/// Check native bounds before copying any JS buffer into WASM. Legacy ID-less window Open+/// retains its original behavior; correlated imports and all private-port imports are bounded.+fn bytes(data: &JsValue, bounded: bool) -> Result<Vec<u8>, String> {+ let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).map_err(|_| "open requires document bytes")?;+ let array = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {+ array.clone() } else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {- Some(js_sys::Uint8Array::new(&bytes).to_vec())+ js_sys::Uint8Array::new(&bytes) } else {- None+ return Err("open requires document bytes".into());+ };+ if bounded+ && (!array.buffer().is_instance_of::<js_sys::ArrayBuffer>()+ || !native_byte_length(array.as_ref())+ || !native_byte_length(array.buffer().as_ref()))+ {+ return Err("native document must be nonempty and at most 32 MiB".into()); }+ Ok(array.to_vec())+}++fn bounded_string(data: &JsValue, key: &str, max: usize) -> Option<String> {+ string(data, key).filter(|value| !value.is_empty() && value.len() <= max)+}++fn native_byte_length(value: &JsValue) -> bool {+ js_sys::Reflect::get(value, &"byteLength".into()).ok().and_then(|value| value.as_f64()).is_some_and(crate::capability::native_js_len_ok) }@@ -18,6 +18,13 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(any(target_arch = "wasm32", test))]+mod capability;+#[cfg(any(target_arch = "wasm32", test))]+mod managed;+#[cfg(any(target_arch = "wasm32", test))]+mod snapshot;+ #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")]new file mode 100644@@ -0,0 +1,159 @@+//! Bounded native snapshot acknowledgements for opt-in host-managed saves.++#[derive(Clone, Debug, PartialEq, Eq)]+pub struct SnapshotKey {+ pub request_id: String,+ pub document: String,+ pub revision: String,+}++struct ManualSave {+ request_id: String,+ document: String,+ ticket: u64,+ snapshot: Option<SnapshotKey>,+}++#[derive(Debug, PartialEq, Eq)]+pub struct Completion {+ pub ticket: Option<u64>,+ pub mark_saved: bool,+}++/// Keep one manual save independent of the latest automatic snapshot. A newer autosnapshot+/// must not strand an older manual ticket or let its ACK mark newer edits saved.+#[derive(Default)]+pub struct ManagedSaves {+ manual: Option<ManualSave>,+ latest: Option<SnapshotKey>,+}++impl ManagedSaves {+ pub fn pending(&self) -> bool {+ self.manual.is_some()+ }++ pub fn start(&mut self, document: &str, ticket: u64) -> Result<String, String> {+ if self.pending() {+ return Err("native save already pending".into());+ }+ let request_id = format!("native-save-{ticket}");+ self.manual = Some(ManualSave { request_id: request_id.clone(), document: document.into(), ticket, snapshot: None });+ Ok(request_id)+ }++ pub fn check_export(&self, snapshot: &SnapshotKey) -> Result<(), String> {+ if let Some(manual) = &self.manual+ && manual.request_id == snapshot.request_id+ && (manual.document != snapshot.document || manual.snapshot.as_ref().is_some_and(|old| old != snapshot))+ {+ return Err("manual save snapshot already bound to another document or revision".into());+ }+ Ok(())+ }++ /// Call only after the native snapshot was validated and successfully posted to the host.+ pub fn exported(&mut self, snapshot: SnapshotKey) -> Result<(), String> {+ self.check_export(&snapshot)?;+ if let Some(manual) = &mut self.manual+ && manual.request_id == snapshot.request_id+ {+ manual.snapshot = Some(snapshot);+ } else {+ self.latest = Some(snapshot);+ }+ Ok(())+ }++ pub fn knows(&self, snapshot: &SnapshotKey) -> bool {+ self.manual.as_ref().and_then(|m| m.snapshot.as_ref()) == Some(snapshot) || self.latest.as_ref() == Some(snapshot)+ }++ pub fn knows_failure(&self, request_id: &str) -> bool {+ self.manual.as_ref().is_some_and(|m| m.request_id == request_id)+ }++ pub fn acknowledge(&mut self, snapshot: &SnapshotKey, document: &str, revision: &str) -> Option<Completion> {+ let ticket = if self.manual.as_ref().and_then(|m| m.snapshot.as_ref()) == Some(snapshot) {+ self.manual.take().map(|m| m.ticket)+ } else if self.latest.as_ref() == Some(snapshot) {+ self.latest = None;+ None+ } else {+ return None;+ };+ Some(Completion { ticket, mark_saved: snapshot.document == document && snapshot.revision == revision })+ }++ pub fn fail(&mut self, request_id: &str) -> Option<u64> {+ if self.knows_failure(request_id) { self.manual.take().map(|m| m.ticket) } else { None }+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ fn key(id: &str, revision: &str) -> SnapshotKey {+ SnapshotKey { request_id: id.into(), document: "document".into(), revision: revision.into() }+ }+ #[test]+ fn stale_manual_ack_settles_its_ticket_after_a_newer_autosnapshot() {+ let mut saves = ManagedSaves::default();+ let id = saves.start("document", 7).unwrap();+ let manual = key(&id, "r1");+ saves.exported(manual.clone()).unwrap();+ saves.exported(key("auto", "r2")).unwrap();+ assert_eq!(saves.acknowledge(&manual, "document", "r2"), Some(Completion { ticket: Some(7), mark_saved: false }));+ assert!(saves.start("document", 8).is_ok());+ }+ #[test]+ fn only_an_exported_exact_document_and_revision_can_mark_saved() {+ let mut saves = ManagedSaves::default();+ let id = saves.start("document", 7).unwrap();+ let manual = key(&id, "r1");+ assert!(!saves.knows(&manual));+ assert_eq!(saves.acknowledge(&manual, "document", "r1"), None);+ saves.exported(manual.clone()).unwrap();+ assert!(saves.knows(&manual));+ let mut wrong = manual.clone();+ wrong.document = "other".into();+ assert_eq!(saves.acknowledge(&wrong, "document", "r1"), None);+ assert_eq!(saves.acknowledge(&key("unknown", "r1"), "document", "r1"), None);+ assert!(saves.start("document", 8).is_err());+ assert_eq!(saves.acknowledge(&manual, "document", "r1"), Some(Completion { ticket: Some(7), mark_saved: true }));+ assert_eq!(saves.acknowledge(&manual, "document", "r1"), None);+ }+ #[test]+ fn changed_draft_or_document_keeps_dirty_but_completes_the_known_ticket() {+ for (document, revision) in [("document", "r1:draft2"), ("replacement", "r1")] {+ let mut saves = ManagedSaves::default();+ let id = saves.start("document", 4).unwrap();+ let snapshot = key(&id, "r1");+ saves.exported(snapshot.clone()).unwrap();+ assert_eq!(saves.acknowledge(&snapshot, document, revision), Some(Completion { ticket: Some(4), mark_saved: false }));+ }+ }+ #[test]+ fn manual_snapshot_cannot_be_overwritten_and_unknown_failure_cannot_consume_it() {+ let mut saves = ManagedSaves::default();+ let id = saves.start("document", 4).unwrap();+ saves.exported(key(&id, "r1")).unwrap();+ assert!(saves.exported(key(&id, "r2")).is_err());+ let mut wrong = key(&id, "r1");+ wrong.document = "replacement".into();+ assert!(saves.exported(wrong).is_err());+ assert_eq!(saves.fail("unknown"), None);+ assert_eq!(saves.fail(&id), Some(4));+ assert_eq!(saves.fail(&id), None);+ assert!(saves.start("document", 5).is_ok());+ }+ #[test]+ fn autosnapshot_ack_has_no_manual_ticket_and_requires_exact_current_token() {+ let mut saves = ManagedSaves::default();+ let snapshot = key("auto", "r1");+ saves.exported(snapshot.clone()).unwrap();+ assert_eq!(saves.acknowledge(&snapshot, "document", "r2"), Some(Completion { ticket: None, mark_saved: false }));+ saves.exported(snapshot.clone()).unwrap();+ assert_eq!(saves.acknowledge(&snapshot, "document", "r1"), Some(Completion { ticket: None, mark_saved: true }));+ }+}new file mode 100644@@ -0,0 +1,121 @@+//! Native JSON snapshots for a host that stores complete editor versions.++use wordcraft_engine::Session;++/// Serialize only the native model, without changing dirty state, undo or file identity.+/// Parsing our own output also rejects values (such as a non-finite required float) that+/// serialize but cannot be opened as a native Document.+pub fn native_snapshot(session: &Session) -> Result<Vec<u8>, String> {+ let bytes = wordcraft_engine::io::save_bytes("snapshot.json", &session.doc)?;+ let _: wordcraft_doc::Document = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;+ Ok(bytes)+}++/// Native session identity, kept separate from the opaque revision and out of JS numbers.+pub fn document(session: &Session) -> String {+ session.document_id().to_string()+}++/// An opaque token: include document identity, and keep u64 values out of JS numbers.+pub fn revision(session: &Session, draft_revision: u64) -> String {+ format!("{}:{}:{}", session.document_id(), session.rev(), draft_revision)+}++#[derive(Default)]+pub struct RevisionObserver(Option<(u64, u64, u64)>);++impl RevisionObserver {+ pub fn changed(&mut self, session: &Session, draft_revision: u64) -> bool {+ let current = (session.document_id(), session.rev(), draft_revision);+ if self.0 == Some(current) {+ return false;+ }+ self.0 = Some(current);+ true+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use wordcraft_doc::{InlineObject, Part, PartKind};+ use wordcraft_engine::Session;++ #[test]+ fn native_snapshot_preserves_complete_model_and_does_not_mark_saved() {+ let mut session = Session::new(wordcraft_engine::sample::sample_document());+ session.doc.parts.insert(+ 600,+ Part {+ kind: PartKind::Comment,+ blocks: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Snapshot comment", Default::default()))],+ ..Default::default()+ },+ );+ session.doc.comments.insert(1, wordcraft_doc::Comment { author: "Tester".into(), part: 600, ..Default::default() });+ let image = session.doc.add_media(vec![71; 3_200_000], "png");+ let mut para = wordcraft_doc::Paragraph::with_text("\u{fffc}", Default::default());+ para.objects.push(InlineObject::Image {+ media: image,+ w: 10.,+ h: 10.,+ alt: "Large image".into(),+ float: Default::default(),+ crop: [0.; 4],+ ole: None,+ });+ session.doc.body.push(wordcraft_doc::para_block(para));+ session.run("text.insert", &serde_json::json!({"text":"Edited "})).unwrap();+ let document_id = session.document_id();+ let revision = session.rev();+ let undo_depth = session.undo_depth();+ let before = serde_json::to_value(&session.doc).unwrap();+ let bytes = native_snapshot(&session).unwrap();+ assert!(bytes.len() > 4 * 1024 * 1024);+ let reopened = wordcraft_engine::io::open_bytes("version.json", &bytes).unwrap();+ assert_eq!(serde_json::to_value(&reopened).unwrap(), before);+ assert_eq!(serde_json::to_value(&session.doc).unwrap(), before);+ assert!(session.dirty);+ assert_eq!(session.document_id(), document_id);+ assert_eq!(session.rev(), revision);+ assert_eq!(session.undo_depth(), undo_depth);+ }+ #[test]+ fn changes_report_every_revision_even_while_the_document_stays_dirty() {+ let mut session = Session::new(wordcraft_doc::Document::new());+ let mut observer = RevisionObserver::default();+ assert!(observer.changed(&session, 0));+ assert!(!observer.changed(&session, 0));+ session.run("text.insert", &serde_json::json!({"text":"first"})).unwrap();+ assert!(session.dirty);+ assert!(observer.changed(&session, 0));+ let first = revision(&session, 0);+ session.run("text.insert", &serde_json::json!({"text":"second"})).unwrap();+ assert!(session.dirty);+ assert!(observer.changed(&session, 0));+ assert_ne!(revision(&session, 0), first);+ assert!(!observer.changed(&session, 0));+ session.set_document(wordcraft_doc::Document::new());+ assert!(observer.changed(&session, 0));+ }+ #[test]+ fn pending_draft_changes_are_reported_without_serializing_the_document() {+ let session = Session::new(wordcraft_doc::Document::new());+ let mut observer = RevisionObserver::default();+ assert!(observer.changed(&session, 0));+ assert!(observer.changed(&session, 1));+ assert!(observer.changed(&session, 2));+ assert!(!observer.changed(&session, 2));+ assert_ne!(revision(&session, 1), revision(&session, 2));+ }++ #[test]+ fn document_identity_is_an_opaque_string_stable_for_edits_and_new_for_replacement() {+ let mut session = Session::new(wordcraft_doc::Document::new());+ let original: String = document(&session);+ session.run("text.insert", &serde_json::json!({"text":"edit"})).unwrap();+ assert_eq!(document(&session), original);+ session.set_document(wordcraft_doc::Document::new());+ assert_ne!(document(&session), original);+ }+}@@ -29,7 +29,11 @@ pub fn start() { }; // `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores // what it saves (`host`). That page guards leaving it, so this one doesn't.- let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };+ let bridge = match query_param("host").as_deref() {+ Some("parent") => crate::host::Bridge::new(query_param("save").as_deref() == Some("managed")),+ Some("port") => query_param("parent_origin").and_then(|origin| crate::host::Bridge::new_port(&origin)),+ _ => None,+ }; let app_bridge = bridge.clone(); let dirty = Rc::new(Cell::new(false)); let saves = AsyncSaves::default();@@ -99,7 +103,7 @@ pub fn start() { crate::host::Bridge::set_author(&mut app, &author); prefs.keep_own_author(&app.session.author, &own); }- Ok(Box::new(WebShell { app, inbox, messages, prefs }))+ Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge })) }), ) .await;@@ -134,6 +138,7 @@ struct WebShell { inbox: Inbox, messages: Rc<RefCell<Vec<String>>>, prefs: crate::prefs::PrefsSync,+ bridge: Option<crate::host::Bridge>, } fn local_storage() -> Option<web_sys::Storage> {@@ -155,6 +160,9 @@ impl eframe::App for WebShell { self.app.status(msg); } self.app.logic(ctx);+ if let Some(bridge) = &self.bridge {+ bridge.observe(&mut self.app, ctx);+ } // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132). if let Some(json) = self.prefs.changed(&self.app) { match local_storage() {@@ -10865,10 +10865,11 @@ fn ime_body_epoch_marks_body_replacements_not_edits() { run(&mut s, "text.insert", json!({"text": " two"})); assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); + let document_id = s.document_id(); run(&mut s, "document.setText", json!({"text": "same text"})); let replaced = s.ime_body_epoch(); assert!(replaced > first, "setText replaces the body under the same document id");- assert_eq!(s.document_id(), 1, "the document id itself is unchanged");+ assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); // The same text again still replaces the body: a composition must not survive on content // equality.@@ -141,8 +141,37 @@ impl WordApp { } return r; }+ // Native host Save is an intent, not a download or a storage acknowledgement. Commit+ // the same pending comment text as snapshot export before binding the ticket's token.+ if id == "file.save" && self.services.host_save_request.is_some() {+ self.prepare_native_snapshot()?;+ let saves = self.services.async_saves.clone().ok_or("native host saves require asynchronous completion")?;+ let _ = saves.take_started();+ let Some(request) = &self.services.host_save_request else { return Err("native host save is unavailable".into()) };+ let result = request(&self.session, self.native_draft_revision());+ let started = saves.take_started();+ let ticket = match result {+ Ok(ticket) if started == Some(ticket) => ticket,+ result => {+ let error = result.err().unwrap_or_else(|| "the host did not start its save ticket".into());+ if let Some(ticket) = started {+ saves.finish(ticket, async_saves::SaveOutcome::Failed(error.clone()));+ }+ return Err(error);+ }+ };+ self.saves_in_flight.push(SaveInFlight {+ ticket,+ name: self.web_save_name(id),+ document: self.session.document_id(),+ marked_saved: false,+ host_managed: true,+ });+ return Ok(json!({"pendingSave": ticket}));+ } // Web: saving and exporting become downloads. if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {+ let host_managed = self.services.host_save_request.is_some(); let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id)); let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name }; // A save with a password encrypts this and later saves (`file.save`'s `password`).@@ -152,15 +181,23 @@ impl WordApp { if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) { return Err("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password, and it can't be empty".into()); }- self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;+ if !host_managed {+ self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;+ } } // A save is a save, download or not: advance the revision and modified stamp (#262).- if matches!(id, "file.save" | "file.saveAs") {+ if !host_managed && matches!(id, "file.save" | "file.saveAs") { self.session.stamp_save(); } // Word documents are encrypted with the document's password, if it has one.- let password =- if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };+ let password = if matches!(id, "file.save" | "file.saveAs") {+ host_managed+ .then(|| params.get("password").and_then(Value::as_str))+ .flatten()+ .or_else(|| self.session.password.as_ref().map(wordcraft_engine::Password::as_str))+ } else {+ None+ }; let opts = wordcraft_engine::cmd::file::export_options(&self.session, ¶ms); let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password, &opts)?; // A ticket left over from an earlier call isn't this save's.@@ -170,7 +207,7 @@ impl WordApp { } // An export is a copy, and so is a save in a format that doesn't keep everything (a // page image, plain text): the document itself still has unsaved changes.- let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);+ let marked_saved = !host_managed && matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name); // Save As tells the host page the document is now this file, which it only is when the // save marks it saved. A Save under a name the page never sent (an untitled document, // an imported .doc saved as .docx) is a new file too, never an overwrite.@@ -198,7 +235,7 @@ impl WordApp { // the document's save isn't confirmed. if let Some(ticket) = saves.as_ref().and_then(async_saves::AsyncSaves::take_started) { let document = self.session.document_id();- self.saves_in_flight.push(SaveInFlight { ticket, name: name.clone(), document, marked_saved });+ self.saves_in_flight.push(SaveInFlight { ticket, name: name.clone(), document, marked_saved, host_managed: false }); return Ok(json!({"downloaded": name, "bytes": bytes.len(), "pendingSave": ticket})); } return Ok(json!({"downloaded": name, "bytes": bytes.len()}));@@ -61,6 +61,11 @@ impl WordApp { /// In the browser the save picker may write the file later ([`PromptSave::Later`], #1315). fn save_for_prompt(&mut self) -> Result<PromptSave, String> { let v = self.execute("file.save", json!({}))?;+ if self.services.host_save_request.is_some()+ && let Some(ticket) = v.get("pendingSave").and_then(Value::as_u64)+ {+ return Ok(PromptSave::Later(ticket));+ } let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(); if !self.safely_saved(saved) { return Ok(PromptSave::NotSaved);@@ -75,7 +80,7 @@ impl WordApp { /// looks saved, but nothing is written yet, so New/Open ask first. pub(crate) fn save_unconfirmed(&self) -> bool { let document = self.session.document_id();- self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == document)+ self.saves_in_flight.iter().any(|s| (s.marked_saved || s.host_managed) && s.document == document) } /// The document's name as the Save Changes prompt shows it: its file's name, else its title.@@ -128,7 +133,12 @@ impl WordApp { } } if let async_saves::SaveOutcome::Failed(error) = &outcome {- let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &save.name), ("error", error)]);+ let template = if save.host_managed {+ tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}")+ } else {+ tl!("Couldn't download {name}: {error}")+ };+ let msg = i18n::fmt(template, &[("name", &save.name), ("error", error)]); log::error!("the save picker couldn't save {}: {error}", save.name); self.status(msg); }@@ -140,7 +150,12 @@ impl WordApp { } match outcome { // Only when nothing changed since and no later save of it is still unconfirmed.- async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => {+ async_saves::SaveOutcome::Saved+ if !self.session.dirty+ && self.session.rev() == wait.rev+ && !self.save_unconfirmed()+ && (self.services.host_save_request.is_none() || !panes::has_comment_drafts(self)) =>+ { if let Err(e) = self.execute_user(&wait.then, wait.params) { self.status(e); }@@ -20,6 +20,35 @@ impl WordApp { } } + /// Commit pending comment/reply text as a normal save does, without acknowledging any+ /// storage or clearing dirty state. A refused draft stays local and returns an error.+ pub fn prepare_native_snapshot(&mut self) -> Result<(), String> {+ panes::flush_comment_drafts(self)+ }++ /// Import host-owned bytes synchronously, only when replacing the current document cannot+ /// discard unsaved work. Unlike UI Open this never queues a Save Changes or picker dialog.+ /// Success means the native engine has parsed and installed a new document, including when+ /// the name matches an already opened file.+ pub fn open_host_document(&mut self, name: &str, bytes: &[u8]) -> Result<(), String> {+ if self.session.dirty || panes::has_comment_drafts(self) || self.save_unconfirmed() {+ return Err("the current document has unsaved or unconfirmed changes".into());+ }+ let before = self.session.document_id();+ let result = self.execute("file.open", json!({"path":name,"data":wordcraft_engine::cmd::insert::base64_encode(bytes)}))?;+ if result.get("pending").is_some() || self.session.document_id() == before {+ return Err("the host document was not opened".into());+ }+ // Match a successful ordinary inbox open: show the document instead of File Home.+ self.ui.backstage = false;+ Ok(())+ }++ /// Changes to pending comment/reply text, independent of the document's dirty flag.+ pub fn native_draft_revision(&self) -> u64 {+ self.comment_drafts.revision_for(self.session.document_id())+ }+ /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]). pub(super) fn report_dirty(&self) { if let Some(f) = &self.services.on_dirty {@@ -384,7 +384,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { format!( "{} — {}", app.title_stem(),- if app.session.dirty {+ if app.session.dirty || (app.services.host_save_request.is_some() && crate::panes::has_comment_drafts(app)) { tl!("Edited") } else if app.session.path.is_some() { tl!("Saved")@@ -101,7 +101,7 @@ mod state; use app::control_click_as_secondary; pub(crate) use app::{discards_document, works_without_document}; pub use clock::now_ms;-pub use services::{HostSaveFn, Inbox, Services};+pub use services::{HostSaveFn, HostSaveRequestFn, Inbox, Services}; pub use state::{AutoSaveBlock, DefaultApp, SaveChoice, keeps_everything}; /// Web: a save the browser's picker hasn't finished ([`async_saves`]).@@ -113,6 +113,8 @@ struct SaveInFlight { document: u64, /// Whether it marked the document saved (a Save in a format that keeps everything). marked_saved: bool,+ /// Native host Save keeps dirty state intact, but still guards replacement until its ACK.+ host_managed: bool, } /// Web: the command a Save Changes prompt holds back until its picker save ends.@@ -72,6 +72,7 @@ pub(crate) struct CommentDrafts { document: u64, edits: BTreeMap<u32, String>, replies: BTreeMap<u32, String>,+ revision: u64, } impl CommentDrafts {@@ -85,6 +86,37 @@ impl CommentDrafts { self } + /// A cheap change token for unsaved text that is not in the native model yet.+ pub(crate) fn revision_for(&self, document: u64) -> u64 {+ if self.document == document { self.revision } else { 0 }+ }++ fn record_edit(&mut self, id: u32, text: String) {+ if self.edits.get(&id) != Some(&text) {+ self.edits.insert(id, text);+ self.revision = self.revision.wrapping_add(1);+ }+ }++ fn record_reply(&mut self, id: u32, text: String) {+ if self.replies.get(&id) != Some(&text) {+ self.replies.insert(id, text);+ self.revision = self.revision.wrapping_add(1);+ }+ }++ fn forget_edit(&mut self, id: u32) {+ if self.edits.remove(&id).is_some() {+ self.revision = self.revision.wrapping_add(1);+ }+ }++ fn forget_reply(&mut self, id: u32) {+ if self.replies.remove(&id).is_some() {+ self.revision = self.revision.wrapping_add(1);+ }+ }+ /// Nothing typed and not yet written. pub(crate) fn is_empty(&self) -> bool { self.edits.is_empty() && self.replies.is_empty()@@ -133,12 +165,12 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti r.request_focus(); } if r.changed() {- drafts(app).edits.insert(id, text);+ drafts(app).record_edit(id, text); } if r.lost_focus() { if ui.input(|i| i.key_pressed(egui::Key::Escape)) { // Escape throws the draft away: the field shows the comment's text again.- drafts(app).edits.remove(&id);+ drafts(app).forget_edit(id); } else { // A refusal is on the status line, and the draft stays in the field. let _ = commit_comment(app, id);@@ -161,7 +193,7 @@ pub(crate) fn commit_comment(app: &mut WordApp, id: u32) -> Result<(), String> { { app.run("review.editComment", json!({"id": id, "text": text}))?; }- drafts(app).edits.remove(&id);+ drafts(app).forget_edit(id); Ok(()) } @@ -193,11 +225,11 @@ pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option r.request_focus(); } if r.changed() {- drafts(app).replies.insert(id, text);+ drafts(app).record_reply(id, text); } if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { // Escape throws the reply away and closes the field.- drafts(app).replies.remove(&id);+ drafts(app).forget_reply(id); return true; } r.lost_focus() && commit_reply(app, id).is_ok()@@ -211,7 +243,7 @@ pub(crate) fn commit_reply(app: &mut WordApp, id: u32) -> Result<(), String> { let root = thread_root(app, id); app.run("review.reply", json!({"id": root, "text": text.trim_end()}))?; }- drafts(app).replies.remove(&id);+ drafts(app).forget_reply(id); Ok(()) } @@ -238,3 +270,110 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> { } first }++#[cfg(test)]+mod native_snapshot_tests {+ use super::*;+ use crate::Services;+ use wordcraft_engine::Session;++ #[test]+ fn native_snapshot_commits_pending_comment_and_keeps_it_unsaved() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.run("review.newComment", json!({"text":"original"})).unwrap();+ let id = *app.session.doc.comments.keys().next().unwrap();+ let part = app.session.doc.comments[&id].part;+ let before = app.session.rev();+ drafts(&mut app).record_edit(id, "pending native comment".into());+ assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "original");+ app.prepare_native_snapshot().unwrap();+ assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "pending native comment");+ assert!(app.session.dirty, "export is not a storage acknowledgement");+ assert!(app.session.rev() > before);+ assert!(!has_comment_drafts(&app));+ }++ #[test]+ fn every_pending_comment_and_reply_edit_changes_the_native_draft_token() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.session.dirty = true;+ let first = app.native_draft_revision();+ drafts(&mut app).record_edit(0, "first".into());+ let second = app.native_draft_revision();+ drafts(&mut app).record_edit(0, "second".into());+ let third = app.native_draft_revision();+ drafts(&mut app).record_reply(0, "reply draft".into());+ let fourth = app.native_draft_revision();+ assert_ne!(first, second);+ assert_ne!(second, third);+ assert_ne!(third, fourth);+ assert!(app.session.dirty);+ }+ #[test]+ fn refused_pending_comment_stays_local_and_snapshot_preparation_fails() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.run("review.newComment", json!({"text":"original"})).unwrap();+ let id = *app.session.doc.comments.keys().next().unwrap();+ drafts(&mut app).record_edit(id, "pending retained".into());+ app.session.run("review.restrict", &json!({"mode":"readOnly"})).unwrap();+ let before = serde_json::to_value(&app.session.doc).unwrap();+ assert!(app.prepare_native_snapshot().is_err());+ assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);+ assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));+ }++ #[test]+ fn correlated_host_open_refuses_pending_comment_without_flushing_or_replacing_it() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.run("review.newComment", json!({"text":"original"})).unwrap();+ let id = *app.session.doc.comments.keys().next().unwrap();+ app.session.dirty = false;+ app.ui.backstage = true;+ drafts(&mut app).record_edit(id, "pending retained".into());+ let before = serde_json::to_value(&app.session.doc).unwrap();+ let document = app.session.document_id();+ let revision = app.native_draft_revision();+ let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::new()).unwrap();+ assert!(app.open_host_document("version.json", &bytes).is_err());+ assert_eq!(app.session.document_id(), document);+ assert_eq!(app.native_draft_revision(), revision);+ assert!(app.ui.backstage);+ assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);+ assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));+ }+ #[test]+ fn stale_manual_ack_never_closes_a_new_comment_draft_after_an_earlier_auto_ack() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.run("review.newComment", json!({"text":"original native comment"})).unwrap();+ let id = *app.session.doc.comments.keys().next().unwrap();+ let document = app.session.document_id();+ let saves = crate::async_saves::AsyncSaves::default();+ let hook = saves.clone();+ let ticket = std::rc::Rc::new(std::cell::Cell::new(0));+ let seen = ticket.clone();+ app.services.download = Some(Box::new(|_, _| Err("native managed save never downloads".into())));+ app.services.async_saves = Some(saves.clone());+ app.services.host_save_request = Some(Box::new(move |_, _| {+ let id = hook.start();+ seen.set(id);+ Ok(id)+ }));+ app.run("file.close", json!({})).unwrap();+ app.run("ui.saveChanges", json!({"answer":"save"})).unwrap();+ assert_ne!(ticket.get(), 0);+ // A separate, exact current auto-snapshot ACK arrived first. It legitimately clears+ // Session.dirty; the original manual ticket is still waiting for its durable reply.+ app.session.dirty = false;+ drafts(&mut app).record_edit(id, "newer uncommitted comment".into());+ let draft_revision = app.native_draft_revision();+ // The bridge knows the old manual snapshot/ticket but its current draft token differs:+ // settle that ticket as Saved without flushing or clearing the newer draft.+ saves.finish(ticket.get(), crate::async_saves::SaveOutcome::Saved);+ app.settle_async_saves();+ assert!(app.has_document(), "Close must wait for the newer pending comment");+ assert_eq!(app.session.document_id(), document);+ assert_eq!(app.native_draft_revision(), draft_revision);+ assert_eq!(comment_draft(&app, id).as_deref(), Some("newer uncommitted comment"));+ assert!(matches!(app.dialog.as_ref(), Some(crate::dialogs::Dialog::SaveChanges { .. })));+ }+}@@ -42,6 +42,10 @@ pub struct Services { /// under its own name and format, as on the desktop, and a Save or Save As that keeps /// everything makes that name the document's file, so the next Save goes to it. pub host_save: Option<HostSaveFn>,+ /// Opt-in native host persistence: Save requests an asynchronous ticket without exporting,+ /// stamping metadata or marking the document saved. The hook starts the ticket in+ /// `async_saves`; the host completes it only after durable storage (or failure).+ pub host_save_request: Option<HostSaveRequestFn>, /// Web: told whether the document has unsaved changes after each pass, for the browser's /// leave-page guard (`beforeunload` runs between frames and can't ask the app). pub on_dirty: Option<Box<dyn Fn(bool)>>,@@ -100,3 +104,7 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>; /// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get /// the file, and the save failed. pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;++/// A native Save request: the current session and pending-draft revision, returning the ticket+/// started in [`Services::async_saves`]. The session is read-only to the hook.+pub type HostSaveRequestFn = Box<dyn Fn(&Session, u64) -> Result<u64, String>>;@@ -6972,3 +6972,196 @@ fn focus_hides_the_ribbon_and_escape_brings_it_back() { h.step(); assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar"); }++#[test]+fn correlated_host_open_finishes_the_actual_import_and_reimports_same_named_bytes() {+ let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ let original = a.session.document_id();+ let first = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::from_text("first native body")).unwrap();+ a.open_host_document("version.json", &first).unwrap();+ let imported = a.session.document_id();+ assert_ne!(imported, original);+ assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "first native body");+ let second = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::from_text("second native body")).unwrap();+ a.open_host_document("version.json", &second).unwrap();+ assert_ne!(a.session.document_id(), imported);+ assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "second native body");+ assert!(!a.session.dirty);+}++#[test]+fn correlated_host_open_reports_parse_failure_without_replacing_the_document() {+ let mut a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("retain original")), Services::default());+ let original = a.session.document_id();+ let before = serde_json::to_value(&a.session.doc).unwrap();+ assert!(a.open_host_document("version.json", br#"{"body":"malformed"}"#).is_err());+ assert_eq!(a.session.document_id(), original);+ assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);+}++#[test]+fn correlated_host_open_refuses_unsaved_work_without_queueing_a_replacement_dialog() {+ let mut a = typed();+ a.ui.backstage = true;+ let original = a.session.document_id();+ let before = serde_json::to_value(&a.session.doc).unwrap();+ let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::new()).unwrap();+ assert!(a.open_host_document("version.json", &bytes).is_err());+ assert_eq!(a.session.document_id(), original);+ assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);+ assert!(a.session.dirty);+ assert!(a.ui.backstage);+ assert_eq!(prompt(&a), None);+}++#[test]+fn correlated_host_open_refuses_a_save_that_is_still_unconfirmed() {+ let (mut a, _saves, _last) = web_picker_app();+ a.execute("file.save", json!({})).unwrap();+ assert!(!a.session.dirty);+ assert!(a.save_unconfirmed());+ let before = serde_json::to_value(&a.session.doc).unwrap();+ let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::new()).unwrap();+ assert!(a.open_host_document("version.json", &bytes).is_err());+ assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);+ assert!(a.save_unconfirmed());+}++#[test]+fn correlated_host_open_leaves_file_home_only_after_actual_success() {+ let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ a.ui.backstage = true;+ let original = a.session.document_id();+ assert!(a.open_host_document("version.json", br#"{"body":"malformed"}"#).is_err());+ assert!(a.ui.backstage);+ assert_eq!(a.session.document_id(), original);+ let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::from_text("show the native editor")).unwrap();+ a.open_host_document("version.json", &bytes).unwrap();+ assert!(!a.ui.backstage, "successful correlated host import leaves File Home");+}++fn managed_host_app() -> (WordApp, async_saves::AsyncSaves, std::rc::Rc<std::cell::RefCell<Vec<u64>>>) {+ let mut a = typed();+ let saves = async_saves::AsyncSaves::default();+ let requests = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));+ let (hook, seen) = (saves.clone(), requests.clone());+ a.services.download = Some(Box::new(|_, _| Err("native save must not download a package".into())));+ a.services.host_save_request = Some(Box::new(move |_, _| {+ let ticket = hook.start();+ seen.borrow_mut().push(ticket);+ Ok(ticket)+ }));+ a.services.async_saves = Some(saves.clone());+ (a, saves, requests)+}++#[test]+fn managed_native_save_request_leaves_document_dirty_and_does_not_stamp_or_export_it() {+ let (mut a, saves, requests) = managed_host_app();+ let before = serde_json::to_value(&a.session.doc).unwrap();+ let path = a.session.path.clone();+ let rev = a.session.rev();+ let result = a.execute("file.save", json!({})).unwrap();+ let ticket = requests.borrow()[0];+ assert_eq!(result["pendingSave"], ticket);+ assert_eq!(saves.pending(), 1);+ assert!(a.session.dirty);+ assert_eq!(a.session.path, path);+ assert_eq!(a.session.rev(), rev);+ assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);+}++#[test]+fn managed_save_as_exports_a_copy_without_marking_the_native_document_saved() {+ let (mut a, got) = host_app(false);+ a.services.host_save_request = Some(Box::new(|_, _| Err("SaveAs exports a copy; no native save request".into())));+ let before = serde_json::to_value(&a.session.doc).unwrap();+ let path = a.session.path.clone();+ a.execute("file.saveAs", json!({"path":"copy.docx"})).unwrap();+ assert!(a.session.dirty);+ assert_eq!(a.session.path, path);+ assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);+ let got = got.borrow();+ assert_eq!(got.len(), 1);+ assert_eq!(got[0].0, "copy.docx");+ assert!(!got[0].1, "download copy, never host file identity");+ assert!(got[0].2 > 0);+}++#[test]+fn managed_save_before_close_waits_for_a_confirmed_snapshot() {+ let (mut a, saves, requests) = managed_host_app();+ a.run("file.close", json!({})).unwrap();+ let result = a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();+ assert_eq!(result["pending"], "save");+ a.settle_async_saves();+ assert!(body_text(&a).contains(UNSAVED));+ assert!(a.session.dirty);+ // Simulate the bridge accepting an exact current snapshot ACK, then settling its ticket.+ a.session.dirty = false;+ saves.finish(requests.borrow()[0], async_saves::SaveOutcome::Saved);+ a.settle_async_saves();+ assert!(!body_text(&a).contains(UNSAVED));+ assert_eq!(prompt(&a), None);+}++#[test]+fn managed_save_failure_keeps_the_document_and_can_retry() {+ let (mut a, saves, requests) = managed_host_app();+ a.run("file.close", json!({})).unwrap();+ a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();+ let first = requests.borrow()[0];+ saves.finish(first, async_saves::SaveOutcome::Failed("store unavailable".into()));+ a.settle_async_saves();+ assert!(a.session.dirty && body_text(&a).contains(UNSAVED));+ assert_eq!(prompt(&a), Some("saveChanges"));+ a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();+ assert_ne!(requests.borrow()[1], first);+ assert_eq!(saves.pending(), 1);+}++#[test]+fn managed_stale_saved_ticket_settles_but_keeps_newer_edits_and_allows_manual_retry() {+ let (mut a, saves, requests) = managed_host_app();+ a.execute("file.save", json!({})).unwrap();+ let first = requests.borrow()[0];+ a.execute("text.insert", json!({"text":"newer edit"})).unwrap();+ // Known old durable ACK: finish only its ticket; the bridge must not clear dirty.+ saves.finish(first, async_saves::SaveOutcome::Saved);+ a.settle_async_saves();+ assert_eq!(saves.pending(), 0);+ assert!(a.session.dirty);+ a.execute("file.save", json!({})).unwrap();+ assert_ne!(requests.borrow()[1], first);+ assert_eq!(saves.pending(), 1);+}++#[test]+fn managed_stale_saved_ticket_never_continues_close_over_newer_edits() {+ let (mut a, saves, requests) = managed_host_app();+ a.run("file.close", json!({})).unwrap();+ a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();+ let first = requests.borrow()[0];+ a.execute("text.insert", json!({"text":"newer edit"})).unwrap();+ saves.finish(first, async_saves::SaveOutcome::Saved);+ a.settle_async_saves();+ assert_eq!(saves.pending(), 0);+ assert!(a.session.dirty && body_text(&a).contains(UNSAVED));+ assert_eq!(prompt(&a), Some("saveChanges"));+ a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();+ assert_ne!(requests.borrow()[1], first);+ assert_eq!(saves.pending(), 1);+}++#[test]+fn managed_save_of_a_clean_document_still_guards_replacement_until_its_ticket_finishes() {+ let (mut a, saves, requests) = managed_host_app();+ a.session.dirty = false;+ a.execute("file.save", json!({})).unwrap();+ assert!(a.save_unconfirmed());+ a.run("file.close", json!({})).unwrap();+ assert_eq!(prompt(&a), Some("saveChanges"));+ saves.finish(requests.borrow()[0], async_saves::SaveOutcome::Saved);+ a.settle_async_saves();+ assert!(!a.save_unconfirmed());+}