mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-02 08:43:57 +00:00
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:
1 parent
f20f364d55
commit
b1401e33b1
11 files changed
+265
-4
No files matched your search
Generated
+5
@@ -828,6 +828,11 @@ dependencies = [
|
||||
"async-process",
|
||||
"egui",
|
||||
"futures-lite",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
"web-sys",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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)); }
|
||||
@@ -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/**
|
||||
Reference in new issue
Block a user