diff --git a/Cargo.lock b/Cargo.lock index 50ffab2e..ea00f1fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,6 +828,11 @@ dependencies = [ "async-process", "egui", "futures-lite", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", "zbus", ] diff --git a/NOTICE b/NOTICE index 8703347b..38a65fcf 100644 --- a/NOTICE +++ b/NOTICE @@ -110,7 +110,7 @@ applications retain their own document-font selection and licensing policy. Platform lifecycle source provenance The optional crates/platform services adapt the preserved local platform candidate -9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and lifecycle), +9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and browser resource ownership), and current application implementations: - PdfCraft crates/platform/src/desktop_theme.rs, a8cd797a917b1007544575c7280c9a0ce6a17e8f. - CADCraft apps/cadcraft/src/desktop_theme.rs, 0cc66ad3066e71f2c3d4cacb7de905e79ba1c95b. diff --git a/README.md b/README.md index 7de7d010..2dc92f8a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Self { + Self { revoke_after_ms: 60_000 } + } +} + +/// Prepared DOM link and object URL. Dropping without clicking cleans both up. +/// Preparation can occur before an app's download pacer yields. +pub struct PreparedDownload { + window: Window, + anchor: HtmlAnchorElement, + url: Option, +} +impl PreparedDownload { + /// Prepare an owned Blob and hidden link using the caller's bytes, MIME and filename. + /// No download is requested until [`Self::click`]; browser setup failures return an error. + pub fn bytes(name: &str, mime: &str, bytes: &[u8]) -> Result { + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let options = BlobPropertyBag::new(); + options.set_type(mime); + Self::blob(name, &Blob::new_with_u8_array_sequence_and_options(&parts, &options)?) + } + /// Prepare a hidden link and owned object URL for an existing Blob. + /// Blank filenames, missing browser DOM and setup failures return an error. + pub fn blob(name: &str, blob: &Blob) -> Result { + if name.trim().is_empty() { + return Err(JsValue::from_str("a file name is required")); + } + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no browser window"))?; + let document = window.document().ok_or_else(|| JsValue::from_str("no browser document"))?; + document.body().ok_or_else(|| JsValue::from_str("the page has no body"))?; + let anchor = document.create_element("a")?.dyn_into::()?; + anchor.set_download(name); + anchor.set_attribute("style", "display:none")?; + let download = Self { window, anchor, url: Some(Url::create_object_url_with_blob(blob)?) }; + if let Some(url) = &download.url { + download.anchor.set_href(url); + } + Ok(download) + } + /// Register cleanup before clicking: a timer failure returns an error without triggering + /// a partial download. Success only means a click was issued; browser acceptance is unobservable. + pub fn click(mut self, options: DownloadOptions) -> Result<(), JsValue> { + if options.revoke_after_ms <= 0 { + return Err(JsValue::from_str("download lifetime must be positive")); + } + let Some(url) = self.url.as_ref().cloned() else { + return Err(JsValue::from_str("download has no object URL")); + }; + let body = self.window.document().and_then(|d| d.body()).ok_or_else(|| JsValue::from_str("the page has no body"))?; + body.append_child(&self.anchor)?; + let revoke = Closure::once(move || { + let _ = Url::revoke_object_url(&url); + }); + self.window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.as_ref().unchecked_ref(), options.revoke_after_ms)?; + revoke.forget(); // Ownership transfers only after successful timer registration. + self.url = None; + self.anchor.click(); + Ok(()) + } +} +impl Drop for PreparedDownload { + fn drop(&mut self) { + self.anchor.remove(); + if let Some(url) = self.url.take() { + let _ = Url::revoke_object_url(&url); + } + } +} +/// Prepare bytes and request a download with the specified URL lifetime. +/// Success means the click was issued, not that the browser accepted or saved the file. +pub fn download_bytes(name: &str, mime: &str, bytes: &[u8], options: DownloadOptions) -> Result<(), JsValue> { + PreparedDownload::bytes(name, mime, bytes)?.click(options) +} +/// Prepare a Blob and request a download with the specified URL lifetime. +/// Setup or scheduling failures clean up owned resources and return an error. +pub fn download_blob(name: &str, blob: &Blob, options: DownloadOptions) -> Result<(), JsValue> { + PreparedDownload::blob(name, blob)?.click(options) +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 2335afc6..b3fa54e0 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -2,6 +2,8 @@ //! Shared lifecycle mechanics; application state, theme choices and file policy stay in hosts. #[cfg(feature = "appearance")] pub mod appearance; +#[cfg(all(feature = "browser", target_arch = "wasm32"))] +pub mod browser; pub mod dialog_outcome; #[cfg(all(feature = "dialogs", not(target_arch = "wasm32")))] pub mod dialogs; diff --git a/crates/platform/tests/browser_downloads.rs b/crates/platform/tests/browser_downloads.rs new file mode 100644 index 00000000..a9dd94ae --- /dev/null +++ b/crates/platform/tests/browser_downloads.rs @@ -0,0 +1,98 @@ +#![cfg(all(target_arch = "wasm32", feature = "browser"))] +use craft_platform::browser::{DownloadOptions, PreparedDownload, download_bytes}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen(module = "/tests/browser_harness.js")] +extern "C" { + fn setup(mode: &str); + fn restore(); + fn created_count() -> u32; + fn revoked_count() -> u32; + fn clicked_count() -> u32; + fn anchor_count() -> u32; + fn last_name() -> String; + fn last_mime() -> String; + fn timer_before_click() -> bool; + fn wait_for_cleanup() -> js_sys::Promise; +} +struct Restore; +impl Drop for Restore { + fn drop(&mut self) { + restore(); + } +} + +#[wasm_bindgen_test] +fn abandoning_a_prepared_download_releases_its_url() -> Result<(), JsValue> { + setup(""); + let _restore = Restore; + let prepared = PreparedDownload::bytes("export.txt", "text/plain", b"content")?; + assert_eq!(created_count(), 1); + drop(prepared); + assert_eq!(revoked_count(), 1); + assert_eq!(clicked_count(), 0); + assert_eq!(anchor_count(), 0); + Ok(()) +} + +#[wasm_bindgen_test] +fn failed_attachment_and_timer_do_not_click_or_leak() { + for mode in ["attach-error", "timer-error"] { + setup(mode); + let _restore = Restore; + assert!(download_bytes("export.txt", "text/plain", b"content", Default::default()).is_err()); + assert_eq!(created_count(), 1); + assert_eq!(revoked_count(), 1); + assert_eq!(clicked_count(), 0); + assert_eq!(anchor_count(), 0); + } +} + +#[wasm_bindgen_test] +async fn successful_download_releases_its_url_after_the_click() -> Result<(), JsValue> { + setup(""); + let _restore = Restore; + download_bytes("export.txt", "text/plain", b"content", DownloadOptions { revoke_after_ms: 5 })?; + assert_eq!(clicked_count(), 1); + assert_eq!(last_name(), "export.txt"); + assert_eq!(last_mime(), "text/plain"); + assert!(timer_before_click()); + assert_eq!(revoked_count(), 0); + assert_eq!(anchor_count(), 0); + wasm_bindgen_futures::JsFuture::from(wait_for_cleanup()).await?; + assert_eq!(revoked_count(), 1); + Ok(()) +} + +#[wasm_bindgen_test] +fn invalid_lifetime_cleans_a_prepared_download() { + setup(""); + let _restore = Restore; + assert!(download_bytes("export.txt", "text/plain", b"content", DownloadOptions { revoke_after_ms: 0 }).is_err()); + assert_eq!(revoked_count(), 1); + assert_eq!(clicked_count(), 0); +} + +#[wasm_bindgen_test] +fn failed_setup_does_not_allocate_a_url_or_click() { + for mode in ["element-error", "style-error"] { + setup(mode); + let _restore = Restore; + assert!(download_bytes("export.txt", "text/plain", b"content", Default::default()).is_err()); + assert_eq!(created_count(), 0); + assert_eq!(revoked_count(), 0); + assert_eq!(clicked_count(), 0); + assert_eq!(anchor_count(), 0); + } +} + +#[wasm_bindgen_test] +fn empty_name_is_rejected_before_allocating_resources() { + setup(""); + let _restore = Restore; + assert!(download_bytes(" ", "text/plain", b"content", Default::default()).is_err()); + assert_eq!(created_count(), 0); + assert_eq!(clicked_count(), 0); +} diff --git a/crates/platform/tests/browser_harness.js b/crates/platform/tests/browser_harness.js new file mode 100644 index 00000000..fc652c31 --- /dev/null +++ b/crates/platform/tests/browser_harness.js @@ -0,0 +1,42 @@ +// Test-only browser fault injection; no app runtime depends on this module. +let originals; +let created, revoked, clicked, scheduled, lastName, lastMime, timerBeforeClick; +export function setup(mode) { + created = 0; revoked = 0; clicked = 0; scheduled = 0; + lastName = ''; lastMime = ''; timerBeforeClick = false; + originals = { + create: URL.createObjectURL, revoke: URL.revokeObjectURL, + click: HTMLAnchorElement.prototype.click, + append: Node.prototype.appendChild, timer: window.setTimeout, + element: Document.prototype.createElement, + attribute: Element.prototype.setAttribute, + }; + URL.createObjectURL = function(blob) { created++; lastMime = blob.type; return originals.create.call(URL, blob); }; + URL.revokeObjectURL = function(url) { revoked++; return originals.revoke.call(URL, url); }; + HTMLAnchorElement.prototype.click = function() { + clicked++; lastName = this.download; timerBeforeClick = scheduled > 0; + }; + window.setTimeout = function(...args) { + if (mode === 'timer-error') throw new Error('timer failed'); + scheduled++; + return originals.timer.apply(window, args); + }; + if (mode === 'attach-error') Node.prototype.appendChild = function() { throw new Error('attachment failed'); }; + if (mode === 'element-error') Document.prototype.createElement = function() { throw new Error('element failed'); }; + if (mode === 'style-error') Element.prototype.setAttribute = function() { throw new Error('style failed'); }; +} +export function restore() { + URL.createObjectURL = originals.create; URL.revokeObjectURL = originals.revoke; + HTMLAnchorElement.prototype.click = originals.click; + Node.prototype.appendChild = originals.append; window.setTimeout = originals.timer; + Document.prototype.createElement = originals.element; + Element.prototype.setAttribute = originals.attribute; +} +export function created_count() { return created; } +export function revoked_count() { return revoked; } +export function clicked_count() { return clicked; } +export function anchor_count() { return document.querySelectorAll('a[download]').length; } +export function last_name() { return lastName; } +export function last_mime() { return lastMime; } +export function timer_before_click() { return timerBeforeClick; } +export function wait_for_cleanup() { return new Promise(resolve => originals.timer.call(window, resolve, 50)); } diff --git a/sonar-project.properties b/sonar-project.properties index b2368f5d..8265ddc1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,6 @@ sonar.projectKey=storytold_craft-libs sonar.organization=storytold + +# Browser fault-injection fixtures belong to the test analysis scope. +sonar.tests=crates/platform/tests +sonar.test.inclusions=crates/platform/tests/**