support file picking and uploads on web
This commit is contained in:
@@ -28,8 +28,21 @@ sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
nostr-browser-signer.workspace = true
|
||||
nostr-memory.workspace = true
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"File",
|
||||
"FileList",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlInputElement",
|
||||
"Url",
|
||||
"Window",
|
||||
] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||
|
||||
+21
-18
@@ -1,17 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use gpui::AsyncApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::file::sha256_hex;
|
||||
|
||||
/// Upload a blob to a blossom server and return its URL
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) async fn upload_blob(
|
||||
@@ -41,16 +34,26 @@ pub(crate) async fn upload_blob(
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(&path).await?;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||
}
|
||||
|
||||
/// Upload a blob to a blossom server and return its URL.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
pub(crate) async fn upload_blob(
|
||||
server: &Url,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
sha256: &str,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<Url, Error> {
|
||||
let client = BlossomClient::new(server.clone());
|
||||
let keys = Keys::generate();
|
||||
let base = server.clone();
|
||||
let hash = sha256.to_string();
|
||||
|
||||
match client
|
||||
.upload_blob(data, Some(content_type.to_string()), None, Some(&keys))
|
||||
.await
|
||||
{
|
||||
Ok(blob) => Ok(blob.url),
|
||||
Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?),
|
||||
Err(e) => Err(anyhow!(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
+204
-62
@@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use futures::AsyncReadExt;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::PathPromptOptions;
|
||||
use gpui::http_client::AsyncBody;
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
@@ -115,6 +117,56 @@ impl FileAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
/// A file the user picked, read into memory.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickedFile {
|
||||
pub name: String,
|
||||
pub mime: String,
|
||||
pub data: Vec<u8>,
|
||||
pub local: LocalFile,
|
||||
}
|
||||
|
||||
/// A handle to a file stored on the user's device
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LocalFile {
|
||||
/// A path on disk (desktop)
|
||||
Path(PathBuf),
|
||||
/// An in-memory blob URL (web)
|
||||
Url(String),
|
||||
}
|
||||
|
||||
impl LocalFile {
|
||||
/// The source an `img` element can render
|
||||
pub fn image_source(&self) -> gpui::ImageSource {
|
||||
match self {
|
||||
LocalFile::Path(path) => path.clone().into(),
|
||||
LocalFile::Url(url) => SharedUri::from(url.clone()).into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL the platform opener understands
|
||||
pub fn open_url(&self) -> String {
|
||||
match self {
|
||||
LocalFile::Path(path) => format!("file://{}", path.display()),
|
||||
LocalFile::Url(url) => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the file with the platform handler
|
||||
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||
match self {
|
||||
LocalFile::Path(_) => {
|
||||
cx.open_url(&self.open_url());
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
LocalFile::Url(url) => crate::web_file::open_url(url),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
LocalFile::Url(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||
@@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(
|
||||
/// Ask the user for a single file and read it into memory
|
||||
pub fn pick_file(cx: &gpui::App) -> Task<Result<Option<PickedFile>, Error>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dialog = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Some(path) = dialog.await?? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?;
|
||||
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
Ok(Some(PickedFile {
|
||||
name,
|
||||
mime,
|
||||
data,
|
||||
local: LocalFile::Path(path),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
cx.background_spawn(async move { crate::web_file::pick_file().await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a file unencrypted and return its URL
|
||||
pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let PickedFile { mime, data, .. } = file;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await
|
||||
}
|
||||
|
||||
/// Upload a file encrypted as an opaque blob, returning the attachment metadata
|
||||
pub async fn upload_encrypted_file(
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
file: PickedFile,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
let encrypted = encrypt(&data)?;
|
||||
let encrypted = encrypt(&file.data)?;
|
||||
let sha256 = sha256_hex(&encrypted.data);
|
||||
let original_sha256 = sha256_hex(&data);
|
||||
let original_sha256 = sha256_hex(&file.data);
|
||||
let size = encrypted.data.len() as u64;
|
||||
let base_url = server.to_string();
|
||||
|
||||
@@ -207,26 +301,17 @@ pub async fn upload_encrypted(
|
||||
|
||||
Ok(FileAttachment {
|
||||
url,
|
||||
mime,
|
||||
mime: file.mime,
|
||||
key: encrypted.key,
|
||||
nonce: encrypted.nonce,
|
||||
sha256: Some(sha256),
|
||||
original_sha256: Some(original_sha256),
|
||||
size: Some(size),
|
||||
dim: None,
|
||||
name,
|
||||
name: Some(file.name),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload_encrypted(
|
||||
_server: Url,
|
||||
_path: PathBuf,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
pub(crate) async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
@@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt(
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment into a temporary file.
|
||||
///
|
||||
/// The same attachment always maps to the same path, so callers can render the
|
||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let name = file
|
||||
.sha256
|
||||
.clone()
|
||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||
/// A downloaded and decrypted attachment on the user's device
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DownloadedFile {
|
||||
pub local: LocalFile,
|
||||
pub mime: String,
|
||||
pub name: String,
|
||||
/// The decrypted bytes, kept so the web can offer them as a download
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) data: Vec<u8>,
|
||||
}
|
||||
|
||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{name}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
impl DownloadedFile {
|
||||
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||
match &self.local {
|
||||
LocalFile::Path(path) => {
|
||||
cx.open_with_system(path);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
LocalFile::Url(url) => {
|
||||
if self.mime.starts_with("image/") {
|
||||
crate::web_file::open_url(url)
|
||||
} else {
|
||||
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
LocalFile::Url(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the file to the user's downloads (web)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save(&self) -> Result<(), Error> {
|
||||
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment to the user's device
|
||||
pub async fn download_and_decrypt_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<DownloadedFile, Error> {
|
||||
let data = download_and_decrypt(
|
||||
&file.url,
|
||||
&file.key,
|
||||
@@ -297,25 +398,66 @@ pub async fn download_and_decrypt_to_file(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
let name = file
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_name(&file.url));
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
let local = store_file(&name, &file.mime, &data).await?;
|
||||
|
||||
Ok(path)
|
||||
Ok(DownloadedFile {
|
||||
local,
|
||||
mime: file.mime.clone(),
|
||||
name,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
_file: &FileAttachment,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
/// Persist decrypted bytes somewhere the platform can render or open them
|
||||
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let digest = sha256_hex(name.as_bytes());
|
||||
let extension = mime_guess::get_mime_extensions_str(mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{digest}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_err() {
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
}
|
||||
|
||||
Ok(LocalFile::Path(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = name;
|
||||
let url = crate::web_file::blob_url(mime, data)?;
|
||||
Ok(LocalFile::Url(url))
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name derived from the attachment URL's last path segment
|
||||
fn fallback_name(url: &Url) -> String {
|
||||
url.path()
|
||||
.rsplit('/')
|
||||
.find(|segment| !segment.is_empty())
|
||||
.unwrap_or("attachment")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -27,7 +28,9 @@ mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web_file;
|
||||
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
|
||||
@@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
auth_url: Url,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::web_file::open_url(auth_url.as_str())
|
||||
.map_err(nostr_connect::error::Error::other)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Browser file interoperability: a persistent hidden `<input type="file">`
|
||||
//! 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<Option<PickedFile>, 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<Option<Picker>> = const { RefCell::new(None) };
|
||||
/// The sender the active pick delivers through
|
||||
static PENDING: RefCell<Option<Sender<PickResult>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Picker {
|
||||
input: HtmlInputElement,
|
||||
/// Keep the change handler alive for the life of the page
|
||||
_on_change: Closure<dyn FnMut()>,
|
||||
}
|
||||
|
||||
/// 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<Picker, Error> {
|
||||
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::<HtmlInputElement>()
|
||||
.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<dyn FnMut()>);
|
||||
|
||||
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<Vec<u8>, 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<String, Error> {
|
||||
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<dyn FnMut()>);
|
||||
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>) -> String {
|
||||
let value: String = value.into();
|
||||
|
||||
if value.is_empty() {
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user