Deliver browser saves as downloads; keep unsaved state when they fail (#133) (#193)

On wasm, saving skipped the file write but still marked the drawing saved,
renamed its tab and reported success, so nothing reached the user. Saving
now hands the bytes to a host delivery hook (`file::set_deliver`); the web
app installs a small web-sys download helper (Blob + object URL + temporary
<a download>), reusable for other files such as plots. Without the hook, or
when the download can't be started, save errors and the drawing stays dirty.
A started download counts as saved and keeps the name for later QSAVEs.
The web app's Save/Save As (menu, close prompt) download under the drawing's
name, as there is no file picker in the browser.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 20:39:58 +02:00 committed by GitHub
parent 3d125493f9
commit 3b73884abd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 158 additions and 7 deletions

View File

@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url"] }

View File

@ -0,0 +1,59 @@
//! Browser downloads: hand bytes to the user as a file, through web-sys only (a `Blob`, an object
//! URL and a temporary `<a download>` link). This is the web's file route for everything that
//! produces a file: drawing saves today (installed as the engine's
//! [`cadcraft_engine::cmd::file::set_deliver`] hook), plots next.
use wasm_bindgen::JsCast as _;
use web_sys::js_sys;
/// How long the object URL stays valid after the click; browsers read it asynchronously.
const REVOKE_AFTER_MS: i32 = 60_000;
/// Start a browser download of `bytes` as a file called `name` with MIME type `mime`.
///
/// `Ok` once the download was triggered. Where it lands (or whether the user cancels a "save as"
/// prompt of the browser) is up to the browser and can't be observed from the page.
pub fn download(name: &str, mime: &str, bytes: &[u8]) -> Result<(), String> {
if name.trim().is_empty() {
return Err("a file name is required".into());
}
let window = web_sys::window().ok_or("no browser window")?;
let document = window.document().ok_or("no browser document")?;
let body = document.body().ok_or("the page has no body")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let options = web_sys::BlobPropertyBag::new();
options.set_type(mime);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_error)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_error)?;
if let Err(e) = click_link(&document, &body, &url, name) {
let _ = web_sys::Url::revoke_object_url(&url);
return Err(e);
}
let later = url.clone();
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&later);
});
if window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_AFTER_MS).is_err() {
let _ = web_sys::Url::revoke_object_url(&url);
}
Ok(())
}
fn click_link(document: &web_sys::Document, body: &web_sys::HtmlElement, url: &str, name: &str) -> Result<(), String> {
let link = document
.create_element("a")
.map_err(js_error)?
.dyn_into::<web_sys::HtmlAnchorElement>()
.map_err(|_| "could not create a download link".to_string())?;
link.set_href(url);
link.set_download(name);
link.set_attribute("style", "display:none").map_err(js_error)?;
body.append_child(&link).map_err(js_error)?;
link.click();
link.remove();
Ok(())
}
fn js_error(e: wasm_bindgen::JsValue) -> String {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}

View File

@ -3,9 +3,12 @@
//! Runs the same [`cadcraft_ui_egui::CadApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the sample
//! drawing.
//! drawing. Saving downloads the drawing through the browser (`download.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
pub mod download;
#[cfg(target_arch = "wasm32")]
mod web {
use cadcraft_engine::Session;
@ -44,6 +47,12 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
fn services() -> Services {
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
@ -51,6 +60,8 @@ mod web {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
// No file system here: saved files reach the user as browser downloads.
cadcraft_engine::cmd::file::set_deliver(crate::download::download);
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
@ -68,7 +79,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
let mut app = CadApp::new(Session::new(), Services::default());
let mut app = CadApp::new(Session::new(), services());
app.system_languages =
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {

View File

@ -1,5 +1,6 @@
//! File menu: new, close, switching drawings. Open/save of DWG/DXF go through the io layer,
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`].
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. On the web, where there is
//! no file system, saved files reach the user through the host's [`crate::cmd::file::set_deliver`].
use std::sync::OnceLock;
@ -29,6 +30,21 @@ pub fn io() -> Option<&'static IoHooks> {
IO.get()
}
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
static DELIVER: OnceLock<DeliverHook> = OnceLock::new();
/// Install the host's file delivery (the web app: a browser download). Saving uses it on wasm;
/// anything else that produces a file on the web (plots) can use [`deliver`] too.
pub fn set_deliver(h: DeliverHook) {
let _ = DELIVER.set(h);
}
pub fn deliver() -> Option<DeliverHook> {
DELIVER.get().copied()
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("new", "New Drawing...", run_new)
@ -119,12 +135,34 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
let hooks = io().ok_or_else(|| bad("save", "file formats are not available in this build"))?;
let bytes = (hooks.write)(s.doc()?, path).map_err(|e| bad("save", e))?;
commit_save(s, path, &bytes, store)
}
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, &bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
Ok(())
}
#[cfg(target_arch = "wasm32")]
{
let name = file_name(path);
let deliver = deliver().ok_or_else(|| bad("save", "saving files is not available in this build; use document.bytes"))?;
deliver(&name, "application/octet-stream", bytes).map_err(|e| bad("save", format!("{name}: {e}")))
}
}
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
/// a later QSAVE downloads the drawing again under it.
fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result<usize> {
s.state()?;
store(path, bytes)?;
let st = s.state_mut()?;
st.saved = st.doc.clone();
st.path = Some(path.to_string());
@ -132,19 +170,28 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
Ok(bytes.len())
}
/// The save result; on the web it also names the file the browser downloaded.
fn saved(path: &str, bytes: usize) -> Value {
let mut r = json!({ "path": path, "bytes": bytes });
if cfg!(target_arch = "wasm32") {
r["download"] = json!(file_name(path));
}
r
}
fn run_save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_param(p, "path") {
Some(p) => p.to_string(),
None => s.state()?.path.clone().ok_or_else(|| bad("qsave", "drawing has no file name yet; use saveas {path}"))?,
};
let n = save_to(s, &path)?;
Ok(json!({ "path": path, "bytes": n }))
Ok(saved(&path, n))
}
fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string();
let n = save_to(s, &path)?;
Ok(json!({ "path": path, "bytes": n }))
Ok(saved(&path, n))
}
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
@ -300,3 +347,37 @@ mod tests {
assert_eq!(active_title(&s), "Drawing2");
}
}
#[cfg(test)]
mod pr193_tests {
use super::*;
#[test]
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
let mut s = Session::new();
s.cmdline("circle 4,4 1").unwrap();
let st = s.state().unwrap();
let title = st.title.clone();
assert!(st.is_dirty() && st.path.is_none());
// The file route is unavailable or fails (the web without a download): no success, still dirty.
let failed = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |_, _| Err(bad("save", "no download")));
assert!(failed.is_err());
let st = s.state().unwrap();
assert!(st.is_dirty(), "a save that delivered nothing must keep the unsaved state");
assert_eq!((st.path.as_deref(), st.title.as_str()), (None, title.as_str()));
// The file route took the bytes (written, or a download started): saved under that name.
let mut got = None;
let n = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |p, b| {
got = Some((p.to_string(), b.len()));
Ok(())
})
.unwrap();
assert_eq!((n, got), (6, Some(("a/Owned.dxf".to_string(), 6))));
let st = s.state().unwrap();
assert!(!st.is_dirty());
assert_eq!((st.path.as_deref(), st.title.as_str()), (Some("a/Owned.dxf"), "Owned.dxf"));
assert_eq!(saved("a/Owned.dxf", n)["bytes"], 6);
}
}