//! Browser file interoperability: a persistent hidden `` //! picker, blob URLs for rendering, and anchor-based downloads. use std::cell::RefCell; use anyhow::{Error, anyhow}; use flume::Sender; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window}; use crate::file::PickedFile; type PickResult = Result, Error>; thread_local! { /// The picker input lives for the whole page so its change closure does /// not have to be leaked or re-registered on every pick static PICKER: RefCell> = const { RefCell::new(None) }; /// The sender the active pick delivers through static PENDING: RefCell>> = const { RefCell::new(None) }; } struct Picker { input: HtmlInputElement, /// Keep the change handler alive for the life of the page _on_change: Closure, } /// Ask the user for a single file and read its bytes pub async fn pick_file() -> PickResult { let (sender, receiver) = flume::bounded(1); PICKER.with(|picker| -> Result<(), Error> { let mut picker = picker.borrow_mut(); if picker.is_none() { *picker = Some(init_picker()?); } let picker = picker.as_ref().expect("picker just initialized"); PENDING.with(|pending| *pending.borrow_mut() = Some(sender)); picker.input.click(); Ok(()) })?; receiver .recv_async() .await .map_err(|_| anyhow!("File picker closed"))? } fn init_picker() -> Result { let document = js_window() .ok_or_else(|| anyhow!("no browser window"))? .document() .ok_or_else(|| anyhow!("no browser document"))?; let input = document .create_element("input") .map_err(|e| anyhow!("failed to create file input: {e:?}"))? .dyn_into::() .map_err(|_| anyhow!("created element is not an input"))?; input.set_type("file"); input.style().set_property("display", "none").ok(); let on_change = Closure::wrap(Box::new(move || { let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else { return; }; wasm_bindgen_futures::spawn_local(async move { sender.send_async(read_selected_file().await).await.ok(); }); }) as Box); input.set_onchange(Some(on_change.as_ref().unchecked_ref())); let body = document.body().ok_or_else(|| anyhow!("no browser body"))?; body.append_child(&input) .map_err(|e| anyhow!("failed to mount file input: {e:?}"))?; Ok(Picker { input, _on_change: on_change, }) } /// Read the first file of the picker input, if one was chosen async fn read_selected_file() -> PickResult { let picker = PICKER .with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone())) .ok_or_else(|| anyhow!("file picker is not initialized"))?; let Some(file) = picker.files().and_then(|files| files.get(0)) else { // The dialog was dismissed without choosing a file return Ok(None); }; let mime = non_empty(file.type_()); let data = read_file_bytes(&file).await?; let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?); Ok(Some(PickedFile { name: file.name(), mime, data, local, })) } /// Read a browser `File` into memory async fn read_file_bytes(file: &File) -> Result, Error> { let promise = file.array_buffer(); let buffer = wasm_bindgen_futures::JsFuture::from(promise) .await .map_err(|e| anyhow!("failed to read file: {e:?}"))?; Ok(js_sys::Uint8Array::new(&buffer).to_vec()) } /// Create an object URL for bytes, so images and downloads render from memory pub fn blob_url(mime: &str, data: &[u8]) -> Result { let array = js_sys::Uint8Array::from(data); let parts = js_sys::Array::new(); parts.push(&array); let bag = web_sys::BlobPropertyBag::new(); bag.set_type(mime); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag) .map_err(|e| anyhow!("failed to create blob: {e:?}"))?; DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}")) } /// Offer bytes to the user as a browser download pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> { let window = js_window().ok_or_else(|| anyhow!("no browser window"))?; let document = window .document() .ok_or_else(|| anyhow!("no browser document"))?; let url = blob_url(mime, data)?; let anchor: HtmlAnchorElement = document .create_element("a") .map_err(|e| anyhow!("failed to create download link: {e:?}"))? .dyn_into() .map_err(|_| anyhow!("created element is not an anchor"))?; anchor.set_href(&url); anchor.set_download(name); anchor.style().set_property("display", "none").ok(); let body = document.body().ok_or_else(|| anyhow!("no browser body"))?; body.append_child(&anchor) .map_err(|e| anyhow!("failed to mount download link: {e:?}"))?; anchor.click(); body.remove_child(&anchor) .map_err(|e| anyhow!("failed to remove download link: {e:?}"))?; // Keep the blob alive briefly so the download is not cancelled let callback = Closure::wrap(Box::new(move || { DomUrl::revoke_object_url(&url).ok(); }) as Box); window .set_timeout_with_callback_and_timeout_and_arguments_0( callback.as_ref().unchecked_ref(), 10_000, ) .map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?; // The closure only runs once, after which it can be released callback.forget(); Ok(()) } /// Open a URL in a new browser tab pub fn open_url(url: &str) -> Result<(), Error> { js_window() .ok_or_else(|| anyhow!("no browser window"))? .open_with_url_and_target(url, "_blank") .map_err(|e| anyhow!("failed to open url: {e:?}"))? .ok_or_else(|| anyhow!("the browser blocked opening the url"))?; Ok(()) } fn non_empty(value: impl Into) -> String { let value: String = value.into(); if value.is_empty() { "application/octet-stream".to_string() } else { value } }