From 59b04d764472b7c195f88cae7e15736b18a96885 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:41:28 +0530 Subject: [PATCH] web: use File System Access API showSaveFilePicker with download fallback (#728) * web: use File System Access API showSaveFilePicker with download fallback * Review fixes for #728: mark the document saved only after the picker writes the file A cancelled or failed save in the browser's save picker marks the document unsaved again, and the beforeunload guard keeps warning while a picker save is in flight. The picker's file types use our own neutral format names. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft-web/src/web.rs | 189 +++++++++++++++++++++++++++++++--- contributors/people.toml | 2 - crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 176 insertions(+), 17 deletions(-) diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index bc5e479..fc250a0 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe //! web runner. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use wasm_bindgen::JsCast as _; @@ -27,7 +27,8 @@ pub fn start() { return; }; let dirty = Rc::new(Cell::new(false)); - if let Err(e) = guard_unload(dirty.clone()) { + let saves = Rc::new(PickerSaves::default()); + if let Err(e) = guard_unload(dirty.clone(), saves.clone()) { log::error!("no unsaved-changes guard: {e}"); } let mut options = eframe::WebOptions::default(); @@ -51,9 +52,9 @@ pub fn start() { log::error!("pasting pictures is unavailable: {e}"); } let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() }; - let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty)); + let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone())); app.autosave = false; - Ok(Box::new(WebShell { app, inbox })) + Ok(Box::new(WebShell { app, inbox, saves })) }), ) .await; @@ -75,6 +76,38 @@ fn query() -> String { struct WebShell { app: WordApp, inbox: Inbox, + saves: Rc, +} + +/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks +/// the document saved as soon as `download` returns, so a cancelled or failed picker save must +/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight. +#[derive(Default)] +struct PickerSaves { + /// Picker saves started and not yet written and closed. + pending: Cell, + /// Picker saves that didn't write the file: (name, error, or `None` when cancelled). + unsaved: RefCell)>>, +} + +impl WebShell { + /// A picker save that didn't happen leaves the document with unsaved changes. + fn settle_picker_saves(&mut self) { + let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut()); + for (name, error) in unsaved { + // Only a save in a format that keeps everything marked the document saved. + if wordcraft_ui_egui::keeps_everything(&name) { + self.app.session.dirty = true; + } + if let Some(error) = error { + let msg = wordcraft_ui_egui::i18n::fmt( + wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"), + &[("name", &name), ("error", &error)], + ); + self.app.status(msg); + } + } + } } impl eframe::App for WebShell { @@ -94,6 +127,7 @@ impl eframe::App for WebShell { } }); } + self.settle_picker_saves(); self.app.logic(ctx); } @@ -153,8 +187,9 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St } /// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]). -fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services { +fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: Rc) -> Services { let open_inbox = inbox.clone(); + let save_ctx = ctx.clone(); Services { open_async: Some(Box::new(move |purpose: &str| { let inbox = open_inbox.clone(); @@ -179,7 +214,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services })), // Exports ask for a name; the browser decides where the download goes. pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), - download: Some(Box::new(download)), + download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))), print: Some(Box::new(print_pdf)), inbox: Some(inbox), on_dirty: Some(Box::new(move |d| dirty.set(d))), @@ -200,10 +235,11 @@ fn animations_enabled() -> Box Option> { /// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own /// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save. -fn guard_unload(dirty: Rc>) -> Result<(), String> { +fn guard_unload(dirty: Rc>, saves: Rc) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; let on_unload = wasm_bindgen::closure::Closure::::new(move |e: web_sys::BeforeUnloadEvent| { - if dirty.get() { + // A save still in the browser's picker hasn't written anything yet. + if dirty.get() || saves.pending.get() > 0 { e.prevent_default(); // Older browsers ask only when `returnValue` is set. e.set_return_value("unsaved changes"); @@ -215,20 +251,145 @@ fn guard_unload(dirty: Rc>) -> Result<(), String> { Ok(()) } -/// Trigger a browser download of `bytes` named after the last component of `path`. -fn download(path: &str, bytes: &[u8]) -> Result<(), String> { - let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); +/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`. +fn download(saves: &Rc, ctx: &egui::Context, 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(|| "document".into()); let window = web_sys::window().ok_or("no window")?; + + // On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.), + // `showSaveFilePicker` lets the user choose the save location and filename natively. + if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker")) + && let Ok(picker_fn) = picker_val.dyn_into::() + { + match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download"); + } + } + } + + // Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari): + trigger_anchor_download(&window, &name, bytes) +} + +fn trigger_save_file_picker( + saves: &Rc, + ctx: &egui::Context, + window: &web_sys::Window, + picker_fn: &js_sys::Function, + name: &str, + bytes: &[u8], +) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase(); + let dot_ext = format!(".{ext}"); + let mime = mime_for(name); + + let accept_obj = js_sys::Object::new(); + let exts_arr = js_sys::Array::new(); + exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext)); + let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr); + + // Our own neutral description: the format's name, as its extension says it. + let type_desc = match ext.as_str() { + "htm" => "HTML".to_string(), + "txt" => "Text".to_string(), + e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(), + _ => "Document".to_string(), + }; + + let type_obj = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc)); + let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj); + + let types_arr = js_sys::Array::new(); + types_arr.push(&type_obj); + + let picker_opts = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name)); + let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr); + + // Call showSaveFilePicker synchronously while transient user activation is active: + let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?; + let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?; + + let bytes_vec = bytes.to_vec(); + let saves = saves.clone(); + let ctx = ctx.clone(); + let name = name.to_string(); + saves.pending.set(saves.pending.get().saturating_add(1)); + wasm_bindgen_futures::spawn_local(async move { + // The document counts as saved only once the file is written and closed. + let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await { + Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await { + Ok(()) => { + log::info!("saved {name} through the save picker"); + None + } + Err(e) => { + log::error!("couldn't write {name}: {e}"); + Some(Some(e)) + } + }, + Err(e) => { + let err_name = + js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default(); + if err_name == "AbortError" { + log::debug!("save picker cancelled"); + Some(None) + } else { + log::error!("showSaveFilePicker error: {e:?}"); + Some(Some(format!("{e:?}"))) + } + } + }; + if let Some(error) = unsaved { + saves.unsaved.borrow_mut().push((name, error)); + } + saves.pending.set(saves.pending.get().saturating_sub(1)); + ctx.request_repaint(); + }); + Ok(()) +} + +async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable")) + .map_err(js)? + .dyn_into() + .map_err(|_| "createWritable is not a function".to_string())?; + let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?; + let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?; + + let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write")) + .map_err(js)? + .dyn_into() + .map_err(|_| "write is not a function".to_string())?; + let uint8 = js_sys::Uint8Array::from(bytes); + let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?; + wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?; + + let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close")) + .map_err(js)? + .dyn_into() + .map_err(|_| "close is not a function".to_string())?; + let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?; + wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?; + Ok(()) +} + +fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let document = window.document().ok_or("no document")?; let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let opts = web_sys::BlobPropertyBag::new(); - opts.set_type(mime_for(&name)); + opts.set_type(mime_for(name)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; - let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?; a.set_href(&url); - a.set_download(&name); + a.set_download(name); a.style().set_property("display", "none").map_err(js)?; let body = document.body().ok_or("no body")?; body.append_child(&a).map_err(js)?; diff --git a/contributors/people.toml b/contributors/people.toml index 2fffb68..08212c6 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -59,6 +59,4 @@ display_name = "Gleyson Carvalho" sync_github_name = false [people.alexanderalber] real_name = "Alexander Alber" - [people.edurbs] -sync_github_name = true diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90e228c..19295c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1565,7 +1565,7 @@ impl AutoSaveBlock { /// Whether saving to `name` keeps the whole document (the formats `file.save` treats as the /// document's own file, including macro-enabled documents and templates); anything else is a /// copy that leaves it unsaved. AutoSave writes only these formats. -fn keeps_everything(name: &str) -> bool { +pub fn keeps_everything(name: &str) -> bool { let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml") }