From f9b40d9d49ec9f91aa7b68e51263463dcc05e19c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 20:29:09 +0700 Subject: [PATCH] add NIP-07 browser extension signer support for wasm --- Cargo.lock | 38 +++++++++---- Cargo.toml | 1 + crates/common/Cargo.toml | 4 ++ crates/common/src/lib.rs | 17 +++--- crates/common/src/web_store.rs | 32 +++++++++++ crates/settings/src/lib.rs | 50 ++++++++++------- crates/state/Cargo.toml | 1 + crates/state/src/credentials.rs | 51 ++++++++++++++++++ crates/state/src/lib.rs | 74 +++++++++++++++++++++----- crates/workspace/src/dialogs/import.rs | 34 +++++++++++- 10 files changed, 251 insertions(+), 51 deletions(-) create mode 100644 crates/common/src/web_store.rs create mode 100644 crates/state/src/credentials.rs diff --git a/Cargo.lock b/Cargo.lock index 04fbcdc5..b3567e7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,6 +1223,7 @@ dependencies = [ name = "common" version = "2.0.0-alpha2" dependencies = [ + "anyhow", "chrono", "dirs 5.0.1", "futures", @@ -1232,6 +1233,7 @@ dependencies = [ "nostr", "nostr-sdk", "regex", + "web-sys", ] [[package]] @@ -1824,7 +1826,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2052,7 +2054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4900,6 +4902,19 @@ dependencies = [ "serde", ] +[[package]] +name = "nostr-browser-signer" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f7ebc0a88ea27ac4e3d74b941e3a44ddb10b306c" +dependencies = [ + "js-sys", + "nostr", + "opaquerr 0.2.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "nostr-connect" version = "0.45.2" @@ -5047,7 +5062,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6134,7 +6149,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6707,7 +6722,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6775,7 +6790,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7381,7 +7396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7434,6 +7449,7 @@ dependencies = [ "mime_guess", "nostr", "nostr-blossom", + "nostr-browser-signer", "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", @@ -7702,7 +7718,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8233,7 +8249,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9031,7 +9047,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -9656,7 +9672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index da069921..2573dced 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } +nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index af857673..aefb5ab9 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -16,3 +16,7 @@ futures.workspace = true dirs = "5.0" regex = "1.10" +anyhow.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["Window", "Storage"] } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 265f3e21..12b4f52a 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,13 +1,16 @@ -pub use debounced_delay::*; -pub use display::*; -pub use event::*; -pub use media_extractor::*; -pub use parser::*; -pub use paths::*; - mod debounced_delay; mod display; mod event; mod media_extractor; mod parser; mod paths; + +#[cfg(target_arch = "wasm32")] +pub mod web_store; + +pub use debounced_delay::*; +pub use display::*; +pub use event::*; +pub use media_extractor::*; +pub use parser::*; +pub use paths::*; diff --git a/crates/common/src/web_store.rs b/crates/common/src/web_store.rs new file mode 100644 index 00000000..5e21a0eb --- /dev/null +++ b/crates/common/src/web_store.rs @@ -0,0 +1,32 @@ +use anyhow::{Error, anyhow}; + +const LOCAL_STORAGE: &str = "localStorage"; + +fn storage() -> Result { + web_sys::window() + .ok_or_else(|| anyhow!("no browser window"))? + .local_storage() + .map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))? + .ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable")) +} + +/// Read a value previously written with [`set`] +pub fn get(key: &str) -> Result, Error> { + storage()? + .get_item(key) + .map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}")) +} + +/// Write a value, replacing any previous one +pub fn set(key: &str, value: &str) -> Result<(), Error> { + storage()? + .set_item(key, value) + .map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}")) +} + +/// Remove a value if it exists +pub fn remove(key: &str) -> Result<(), Error> { + storage()? + .remove_item(key) + .map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}")) +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 51f56b37..fb7b1150 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,12 +1,17 @@ use std::path::PathBuf; use anyhow::{Error, anyhow}; +#[cfg(not(target_arch = "wasm32"))] use common::config_dir; use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use smallvec::{SmallVec, smallvec}; +/// Browser storage key for the settings JSON +#[cfg(target_arch = "wasm32")] +const SETTINGS_KEY: &str = "coop.settings"; + /// The user's appearance preference, persisted with the rest of the settings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum AppearanceMode { @@ -63,21 +68,21 @@ pub fn init(cx: &mut App) { } /// Read the persisted appearance mode synchronously. -/// -/// The async load fills the global settings later; the native theme must be -/// applied before the first window opens, so boot reads the mode straight -/// from the settings file instead. fn persisted_settings() -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - let path = config_dir().join(".settings"); - if let Ok(content) = std::fs::read_to_string(path) - && let Ok(settings) = serde_json::from_str::(&content) - { - return Some(settings); - } - } - None + let content = persisted_content(); + content.and_then(|content| serde_json::from_str(&content).ok()) +} + +/// The persisted settings JSON, from the config file or browser storage +#[cfg(not(target_arch = "wasm32"))] +fn persisted_content() -> Option { + let path = config_dir().join(".settings"); + std::fs::read_to_string(path).ok() +} + +#[cfg(target_arch = "wasm32")] +fn persisted_content() -> Option { + common::web_store::get(SETTINGS_KEY).ok().flatten() } pub fn persisted_appearance_mode() -> AppearanceMode { @@ -87,10 +92,6 @@ pub fn persisted_appearance_mode() -> AppearanceMode { } /// Read the persisted theme tuning synchronously. -/// -/// Like [`persisted_appearance_mode`], the native theme is applied before the -/// async settings load completes, so boot reads the tuning straight from the -/// settings file instead. pub fn persisted_theme() -> ThemeSettings { persisted_settings() .map(|settings| settings.theme) @@ -357,6 +358,14 @@ impl AppSettings { return Ok(serde_json::from_str(&content)?); } } + + #[cfg(target_arch = "wasm32")] + { + if let Some(content) = common::web_store::get(SETTINGS_KEY)? { + return Ok(serde_json::from_str(&content)?); + } + } + Err(anyhow!("Not found")) }); @@ -389,6 +398,11 @@ impl AppSettings { smol::fs::write(&path, content).await.ok(); }) .detach(); + + #[cfg(target_arch = "wasm32")] + if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) { + log::warn!("failed to persist settings: {e}"); + } } } diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 18c2196f..d65f4cc4 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -28,6 +28,7 @@ sha2.workspace = true data-encoding.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] +nostr-browser-signer.workspace = true nostr-memory.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/state/src/credentials.rs b/crates/state/src/credentials.rs new file mode 100644 index 00000000..f7b93a2e --- /dev/null +++ b/crates/state/src/credentials.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +#[cfg(target_arch = "wasm32")] +use common::web_store; +use gpui::{App, Task}; + +use crate::USER_KEYRING; + +/// Read the stored credential as `(username, secret)` +#[allow(clippy::type_complexity)] +pub(crate) fn read(cx: &App) -> Task)>>> { + #[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> { + #[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> { + #[cfg(not(target_arch = "wasm32"))] + return cx.delete_credentials(USER_KEYRING); + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + Task::ready(web_store::remove(USER_KEYRING)) + } +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index b157bf9b..7c285789 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -9,6 +9,8 @@ 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"))] @@ -19,6 +21,7 @@ use nostr_sdk::prelude::*; mod blossom; mod constants; +mod credentials; mod file; mod nip05; mod nip4e; @@ -138,9 +141,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); @@ -242,7 +243,7 @@ impl NostrRegistry { /// Restore the signer from the stored credential fn restore_session(&mut self, cx: &mut Context) { - 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 { @@ -279,6 +280,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); @@ -314,7 +320,7 @@ impl NostrRegistry { cx: &mut Context, ) -> Task> { 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 @@ -373,9 +379,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| { @@ -412,9 +417,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| { @@ -440,7 +444,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) @@ -464,12 +468,56 @@ 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), remembering the choice so + /// reloads reconnect automatically + #[cfg(target_arch = "wasm32")] + pub fn connect_extension(&mut self, cx: &mut Context) { + let signer = match BrowserSigner::new() { + Ok(signer) => signer, + Err(e) => { + cx.emit(StateEvent::error(format!( + "No Nostr extension found: {e}. Install a NIP-07 extension like Alby or nos2x." + ))); + cx.emit(StateEvent::NoSigner); + return; + } + }; + + self.tasks.push(cx.spawn(async move |this, cx| { + match signer.get_public_key_async().await { + Ok(public_key) => { + // Remember the choice so reloads reconnect automatically + 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) => { + // Drop the remembered choice so the next visit does not + // retry a signer that is no longer answering + 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) { diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index e492595b..3e81a434 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -9,7 +9,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; use instant::Duration; use nostr_connect::prelude::*; -use state::NostrRegistry; +use state::{NostrRegistry, StateEvent}; pub fn open(window: &mut Window, cx: &mut App) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); @@ -73,12 +73,13 @@ impl ImportIdentity { } fn build(mode: Mode, window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://")); let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let error = cx.new(|_| None); - let subscriptions = vec![ + let mut subscriptions = vec![ cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| { if let InputEvent::PressEnter { .. } = event { this.login(cx); @@ -96,6 +97,14 @@ impl ImportIdentity { }), ]; + subscriptions.push( + cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| { + if let StateEvent::Error(error) = event { + this.set_error(error.clone(), cx); + } + }), + ); + Self { mode, key_input, @@ -188,6 +197,14 @@ impl ImportIdentity { }); } + #[cfg(target_arch = "wasm32")] + fn extension(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + nostr.update(cx, |this, cx| { + this.connect_extension(cx); + }); + } + fn set_loading(&mut self, status: bool, cx: &mut Context) { self.loading = status; cx.notify(); @@ -329,6 +346,19 @@ impl Render for ImportIdentity { ) }); + #[cfg(target_arch = "wasm32")] + let content = content.when(!is_unlock, |this| { + this.child( + Button::new("extension") + .label("Continue with Browser Extension (NIP-07)") + .ghost() + .disabled(self.loading) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.extension(cx); + })), + ) + }); + content }) .when_some(self.error.read(cx).as_ref(), |this, error| {