feat: make the web version fully working (#67)
Reviewed-on: #67
This commit was merged in pull request #67.
This commit is contained in:
@@ -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
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>) {
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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