feat: make the web version fully working (#67)

Reviewed-on: #67
This commit was merged in pull request #67.
This commit is contained in:
2026-10-25 13:10:51 +00:00
parent 3212951ed4
commit 0824b1825b
42 changed files with 2948 additions and 936 deletions
+18
View File
@@ -20,6 +20,7 @@ futures.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
serde_json.workspace = true
log.workspace = true
mime_guess = "2.0.4"
@@ -28,7 +29,24 @@ 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
nostr-sqlite.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",
] }
sqlite-wasm-rs = "0.5"
sqlite-wasm-vfs = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
+21 -18
View File
@@ -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())),
}
}
+105
View File
@@ -0,0 +1,105 @@
use anyhow::Result;
#[cfg(target_arch = "wasm32")]
use common::web_store;
use gpui::{App, AppContext as _, Task};
use crate::USER_KEYRING;
/// The storage key for the NIP-4e app keys
const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
/// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)]
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.read_credentials(USER_KEYRING);
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
Task::ready(
web_store::get(USER_KEYRING)
.map(|stored| stored.map(|content| (String::new(), content.into_bytes()))),
)
}
}
/// Write the stored credential
pub(crate) fn write(cx: &App, username: &str, secret: &[u8]) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.write_credentials(USER_KEYRING, username, secret);
#[cfg(target_arch = "wasm32")]
{
let _ = (cx, username);
let content = match std::str::from_utf8(secret) {
Ok(content) => content,
Err(e) => return Task::ready(Err(anyhow::anyhow!("credential is not UTF-8: {e}"))),
};
Task::ready(web_store::set(USER_KEYRING, content))
}
}
/// Delete the stored credential
pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.delete_credentials(USER_KEYRING);
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
Task::ready(web_store::remove(USER_KEYRING))
}
}
/// Read named bytes from the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let read = cx.read_credentials(&key);
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
Task::ready(stored)
}
}
/// Write named bytes to the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let secret = secret.to_vec();
let write = cx.write_credentials(&key, "", &secret);
cx.background_spawn(write)
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let content = match std::str::from_utf8(secret) {
Ok(content) => content,
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
};
Task::ready(web_store::set(key, content))
}
}
/// Load the NIP-4e app keys from storage, as raw secret bytes
pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, APP_KEYS_KEY)
}
/// Persist the NIP-4e app keys, as raw secret bytes
pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
write_named(cx, APP_KEYS_KEY, secret)
}
+204 -62
View File
@@ -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> {
+63 -18
View File
@@ -4,26 +4,33 @@ 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"))]
use gpui_tokio::Tokio;
use instant::Duration;
#[cfg(target_arch = "wasm32")]
use nostr_browser_signer::BrowserSigner;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*;
#[cfg(target_arch = "wasm32")]
use nostr_memory::prelude::*;
use nostr_sdk::prelude::*;
mod blossom;
mod constants;
pub mod credentials;
mod file;
mod nip05;
mod nip4e;
mod signer;
pub use blossom::*;
#[cfg(target_arch = "wasm32")]
pub mod web_database;
#[cfg(target_arch = "wasm32")]
pub mod web_file;
pub use constants::*;
pub use file::*;
pub use nip4e::*;
@@ -107,7 +114,6 @@ impl NostrRegistry {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
// Construct the nostr lmdb instance
#[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
@@ -116,7 +122,7 @@ impl NostrRegistry {
});
#[cfg(target_arch = "wasm32")]
let database = MemoryDatabase::unbounded();
let database = web_database::take();
// Construct the nostr client
let client = ClientBuilder::default()
@@ -137,9 +143,7 @@ impl NostrRegistry {
let _ = entity.update(cx, |this, cx| {
this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as restore_session
let keys = Keys::new(secret);
this.set_signer(keys, cx);
@@ -241,7 +245,7 @@ impl NostrRegistry {
/// Restore the signer from the stored credential
fn restore_session(&mut self, cx: &mut Context<Self>) {
let user_keyring = cx.read_credentials(USER_KEYRING);
let user_keyring = credentials::read(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = async {
@@ -278,6 +282,11 @@ impl NostrRegistry {
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
} else if content == "extension" {
#[cfg(target_arch = "wasm32")]
this.update(cx, |this, cx| {
this.connect_extension(cx);
})?;
} else {
this.update(cx, |_, cx| {
cx.emit(StateEvent::NoSigner);
@@ -313,7 +322,7 @@ impl NostrRegistry {
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let password = password.to_owned();
let user_keyring = cx.read_credentials(USER_KEYRING);
let user_keyring = credentials::read(cx);
cx.spawn(async move |this, cx| {
let content = user_keyring
@@ -372,9 +381,8 @@ impl NostrRegistry {
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
});
let write =
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes()));
write.await?;
this.update(cx, |this, cx| {
@@ -411,9 +419,8 @@ impl NostrRegistry {
let keys = decrypt_task.await?;
let public_key = keys.public_key();
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
});
let write =
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes()));
write.await?;
this.update(cx, |this, cx| {
@@ -439,7 +446,7 @@ impl NostrRegistry {
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
let write = credentials::write(cx, "bunker", credential.as_bytes());
cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys)
@@ -463,12 +470,50 @@ impl NostrRegistry {
self.current_user = None;
self.passphrase_required = false;
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
credentials::delete(cx).detach_and_log_err(cx);
cx.emit(StateEvent::SignerChanged);
cx.emit(StateEvent::NoSigner);
cx.notify();
}
/// Connect the browser extension signer (NIP-07).
#[cfg(target_arch = "wasm32")]
pub fn connect_extension(&mut self, cx: &mut Context<Self>) {
let signer = match BrowserSigner::new() {
Ok(signer) => signer,
Err(e) => {
cx.emit(StateEvent::error(format!("No Nostr extension found: {e}.")));
cx.emit(StateEvent::NoSigner);
return;
}
};
self.tasks.push(cx.spawn(async move |this, cx| {
match signer.get_public_key_async().await {
Ok(public_key) => {
this.update(cx, |this, cx| {
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
this.signer.swap_inner(signer);
this.current_user = Some(public_key);
this.passphrase_required = false;
cx.emit(StateEvent::SignerChanged);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_, cx| {
credentials::delete(cx).detach_and_log_err(cx);
cx.emit(StateEvent::error(e.to_string()));
cx.emit(StateEvent::NoSigner);
})?;
}
};
Ok(())
}));
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
+6
View File
@@ -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(())
})
}
+62
View File
@@ -0,0 +1,62 @@
use std::cell::RefCell;
use std::sync::Arc;
use nostr_memory::prelude::*;
use nostr_sqlite::prelude::NostrSqlite;
use sqlite_wasm_rs::WasmOsCallback;
use sqlite_wasm_vfs::relaxed_idb;
const DATABASE_PATH: &str = "coop.sqlite3";
const VFS_NAME: &str = "coop-relaxed-idb";
thread_local! {
static DATABASE: RefCell<Option<NostrSqlite>> = const { RefCell::new(None) };
}
/// Install the IndexedDB storage backend and open the database.
pub async fn open() {
match open_persistent().await {
Some(database) => DATABASE.with(|slot| *slot.borrow_mut() = Some(database)),
None => log::error!("Falling back to the in-memory nostr database"),
}
}
/// Hand the opened database to the client constructor
pub(crate) fn take() -> Arc<dyn NostrDatabase> {
let database = DATABASE.with(|slot| slot.borrow_mut().take());
match database {
Some(database) => Arc::new(database),
None => {
log::error!("Persistent web database was not opened, falling back to memory");
Arc::new(MemoryDatabase::unbounded())
}
}
}
/// Open the database on IndexedDB storage, or `None` when unavailable
async fn open_persistent() -> Option<NostrSqlite> {
let options = relaxed_idb::RelaxedIdbCfgBuilder::new()
.vfs_name(VFS_NAME)
.build();
if let Err(e) = relaxed_idb::install::<WasmOsCallback>(&options, true).await {
log::warn!("IndexedDB storage unavailable: {e:?}");
return None;
}
match NostrSqlite::builder()
.persistent_with_vfs(DATABASE_PATH, VFS_NAME)
.build()
.await
{
Ok(database) => {
log::info!("Nostr database opened on IndexedDB storage");
Some(database)
}
Err(e) => {
log::error!("Failed to open the nostr database on IndexedDB: {e:?}");
None
}
}
}
+198
View File
@@ -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
}
}