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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Debarghaya Mitra 2026-10-12 14:41:28 +05:30 committed by GitHub
parent 6cf0aec0ff
commit 59b04d7644
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 176 additions and 17 deletions

View File

@ -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<PickerSaves>,
}
/// 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<u32>,
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
unsaved: RefCell<Vec<(String, Option<String>)>>,
}
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<Cell<bool>>) -> Services {
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> 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<Cell<bool>>) -> 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<dyn Fn() -> Option<bool>> {
/// 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<Cell<bool>>) -> Result<(), String> {
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::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<Cell<bool>>) -> 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<PickerSaves>, 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::<js_sys::Function>()
{
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<PickerSaves>,
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)?;

View File

@ -59,6 +59,4 @@ display_name = "Gleyson Carvalho"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.edurbs]
sync_github_name = true

View File

@ -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")
}