diff --git a/compliance-dashboard/Cargo.toml b/compliance-dashboard/Cargo.toml index 46d5eb2..24e0d23 100644 --- a/compliance-dashboard/Cargo.toml +++ b/compliance-dashboard/Cargo.toml @@ -12,7 +12,7 @@ path = "../bin/main.rs" workspace = true [features] -web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:web-sys", "dep:gloo-timers"] +web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:web-sys", "dep:js-sys", "dep:wasm-bindgen", "dep:gloo-timers"] server = [ "dioxus/server", "dioxus/router", @@ -51,7 +51,9 @@ thiserror = { workspace = true } # Web-only reqwest = { workspace = true, optional = true } -web-sys = { version = "0.3", optional = true } +web-sys = { version = "0.3", optional = true, features = ["Blob", "BlobPropertyBag", "HtmlAnchorElement", "Url", "Document", "Window"] } +js-sys = { version = "0.3", optional = true } +wasm-bindgen = { version = "0.2", optional = true } gloo-timers = { version = "0.3", features = ["futures"], optional = true } # Server-only diff --git a/compliance-dashboard/src/pages/sbom.rs b/compliance-dashboard/src/pages/sbom.rs index bae5756..397efbe 100644 --- a/compliance-dashboard/src/pages/sbom.rs +++ b/compliance-dashboard/src/pages/sbom.rs @@ -23,7 +23,6 @@ pub fn SbomPage() -> Element { // ── Export state ── let mut show_export = use_signal(|| false); let mut export_format = use_signal(|| "cyclonedx".to_string()); - let mut export_result = use_signal(|| Option::::None); // ── Diff state ── let mut diff_repo_a = use_signal(String::new); @@ -213,8 +212,15 @@ pub fn SbomPage() -> Element { let repo = repo_filter(); let fmt = export_format(); spawn(async move { - match fetch_sbom_export(repo, fmt).await { - Ok(json) => export_result.set(Some(json)), + match fetch_sbom_export(repo.clone(), fmt.clone()).await { + Ok(json) => { + let filename = if fmt == "spdx" { + format!("sbom-{repo}-spdx.json") + } else { + format!("sbom-{repo}-cyclonedx.json") + }; + trigger_download(&json, &filename); + } Err(e) => tracing::error!("Export failed: {e}"), } }); @@ -229,24 +235,6 @@ pub fn SbomPage() -> Element { } } - // ── Export result display ── - if let Some(json) = export_result() { - div { class: "card sbom-export-result", - div { class: "sbom-export-result-header", - strong { "Exported SBOM" } - button { - class: "btn btn-secondary", - onclick: move |_| export_result.set(None), - "Close" - } - } - pre { - style: "max-height: 400px; overflow: auto; font-size: 12px;", - "{json}" - } - } - } - // ── SBOM table ── match &*sbom.read() { Some(Some(resp)) => { @@ -685,3 +673,35 @@ fn license_type_class(is_copyleft: bool) -> &'static str { "license-permissive" } } + +#[cfg(feature = "web")] +fn trigger_download(content: &str, filename: &str) { + use wasm_bindgen::JsCast; + let window = web_sys::window().expect("no window"); + let document = window.document().expect("no document"); + + let blob_parts = js_sys::Array::new(); + blob_parts.push(&wasm_bindgen::JsValue::from_str(content)); + + let mut opts = web_sys::BlobPropertyBag::new(); + opts.type_("application/json"); + let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &opts).expect("blob"); + + let url = web_sys::Url::create_object_url_with_blob(&blob).expect("object url"); + + let a: web_sys::HtmlAnchorElement = document + .create_element("a") + .expect("create a") + .dyn_into() + .expect("cast"); + a.set_href(&url); + a.set_download(filename); + a.click(); + + let _ = web_sys::Url::revoke_object_url(&url); +} + +#[cfg(not(feature = "web"))] +fn trigger_download(_content: &str, _filename: &str) { + // Server-side: no-op (downloads only happen in the browser) +}