Share browser download resource ownership (#226)

Add opt-in browser download helpers that own detached links and object URLs, clean up failed or abandoned preparation, and register delayed URL cleanup before issuing a click. Applications retain filename, MIME type, pacing, and user-activation policy.

Include browser lifecycle and failure-path tests, public API documentation, and Sonar test-source classification. All 32 PR checks passed; targeted WASM Clippy and formatting passed in local validation.
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-22 20:49:41 +02:00
1 parent f20f364d55
commit b1401e33b1
11 files changed
+265 -4

No files matched your search

Generated
+5
View File
@@ -828,6 +828,11 @@ dependencies = [
"async-process",
"egui",
"futures-lite",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
"web-sys",
"zbus",
]
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -25,7 +25,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle and Linux/BSD desktop appearance observation; application policy stays in the apps.
- [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle, Linux/BSD desktop appearance observation and browser download ownership; application policy stays in the apps.
- [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
+10
View File
@@ -14,6 +14,7 @@ workspace = true
default = []
dialogs = []
appearance = ["dep:zbus", "dep:futures-lite", "dep:async-channel", "dep:async-io", "dep:async-process"]
browser = ["dep:wasm-bindgen", "dep:js-sys", "dep:web-sys"]
egui = ["dep:egui"]
[dependencies]
@@ -25,3 +26,12 @@ futures-lite = { version = "2", optional = true }
async-channel = { version = "2", optional = true }
async-io = { version = "2", optional = true }
async-process = { version = "2", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2", optional = true }
js-sys = { version = "0.3", optional = true }
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Node", "Element"], optional = true }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -1,7 +1,7 @@
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.
+12 -1
View File
@@ -10,6 +10,7 @@ third-party dependencies. Enable only the services a host uses.
| `dialogs` | Reserve waiting workers before eager UI-thread native picker creation; serial requests, failure delivery and completion wakeups. The backend factory belongs to the app. |
| `egui` | Combine with `dialogs` for the deferred-close guard or with `appearance` for native theme mapping. |
| `appearance` | Linux/BSD XDG desktop preference observation, owned cancellable subscriptions, bounded reads/helper capture, legacy compatibility and reconnect. |
| `browser` | Wasm download link/object URL ownership, cleanup on setup failure or abandonment, and cleanup timer registration before clicking. |
## Native dialogs
@@ -63,8 +64,18 @@ It does not change styles or theme preference. Hosts whose custom palette is man
native window follows System keep observation active for the native window. Native decoration
rendering still depends on the window manager and requires visible acceptance testing.
## Browser downloads
`PreparedDownload::{bytes,blob}` owns a detached anchor and URL until click or drop. Call `click`
with the application's positive URL lifetime; setup/attachment/timer failure releases resources
without clicking. A successful return means a click was issued, not that the browser saved a file.
Applications own names, MIME types, pacing, capture hooks and user activation. File System Access
and print iframe lifetimes remain separate. This service has no native filesystem semantics.
## Integration
Enable only the services a host uses. Applications retain palettes, settings, document identity,
close policy, filters, names, MIME types and native command routing. Existing craft-settings,
craft-atomic, craft-print, craft-docpath and craft-quit remain canonical.
craft-atomic, craft-print, craft-docpath and craft-quit remain canonical. `craft-window` owns
decoration selection and window-button layout; appearance observation and native color hints here
do not choose a frame or draw an application header.
+89
View File
@@ -0,0 +1,89 @@
//! Browser downloads. Caller owns MIME/name decisions and pacing; this module owns resource cleanup.
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url, Window};
/// Object-URL lifetime for a requested download; the default is 60 seconds.
#[derive(Clone, Copy, Debug)]
pub struct DownloadOptions {
/// Positive delay in milliseconds before cleanup; nonpositive values reject the click.
pub revoke_after_ms: i32,
}
impl Default for DownloadOptions {
fn default() -> 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<String>,
}
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<Self, JsValue> {
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<Self, JsValue> {
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::<HtmlAnchorElement>()?;
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)
}
+2
View File
@@ -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;
@@ -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);
}
+42
View File
@@ -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)); }
+4
View File
@@ -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/**