diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index 0aad3b2..c3a8c2b 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -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"] } diff --git a/apps/cadcraft-web/src/download.rs b/apps/cadcraft-web/src/download.rs new file mode 100644 index 0000000..68185e3 --- /dev/null +++ b/apps/cadcraft-web/src/download.rs @@ -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 `` 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::() + .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:?}")) +} diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index b15e68a..277faf6 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -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::().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)) { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 025b0e1..f73e257 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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 = 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 { + DELIVER.get().copied() +} + pub fn specs() -> Vec { vec![ CommandSpec::new("new", "New Drawing...", run_new) @@ -119,12 +135,34 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result { pub(crate) fn save_to(s: &mut Session, path: &str) -> Result { 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 { + 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 { 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 { 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 { 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 { @@ -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); + } +}