commit 4abf87f4b0af09f7451c27f580ad3830d60e0a3a
parent 3417caaf02566e509e455a50f0c90d0ebb0f7413
Author: triesap <tyson@radroots.org>
Date: Fri, 20 Mar 2026 18:34:09 +0000
web: add nip-07 browser signer setup
Diffstat:
7 files changed, 366 insertions(+), 62 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -131,7 +131,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
"x11rb",
]
@@ -2000,6 +2000,19 @@ dependencies = [
]
[[package]]
+name = "nostr-browser-signer"
+version = "0.44.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e0430dc4517ea03ec055c256f06326671a214e0dfd561d46bafb224d3d31314"
+dependencies = [
+ "js-sys",
+ "nostr",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2749,6 +2762,8 @@ version = "0.1.0"
dependencies = [
"eframe",
"log",
+ "nostr",
+ "nostr-browser-signer",
"radroots-app-core",
"wasm-bindgen-futures",
"web-sys",
@@ -3001,7 +3016,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
@@ -21,6 +21,8 @@ directories = "6"
eframe = { version = "0.33.3", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
egui = { version = "0.33.3", features = ["serde"] }
log = "0.4.28"
+nostr = { version = "0.44.1", default-features = false, features = ["std"] }
+nostr-browser-signer = "0.44.1"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] }
radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] }
wasm-bindgen-futures = "0.4.50"
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -5,6 +5,13 @@ use eframe::egui;
pub const APP_NAME: &str = "Rad Roots";
#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SetupActionState {
+ pub label: String,
+ pub enabled: bool,
+ pub pending: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdentityGateState {
Missing,
Ready { account_id: String, npub: String },
@@ -13,12 +20,16 @@ pub enum IdentityGateState {
pub trait RadrootsAppBackend {
fn load_identity_state(&self) -> Result<IdentityGateState, String>;
- fn generate_new_key(&self) -> Result<IdentityGateState, String>;
+ fn setup_action_state(&self) -> SetupActionState;
+ fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String>;
+ fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
+ Ok(None)
+ }
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AppScreen {
- Setup { generate_enabled: bool },
+ Setup,
Home { account_id: String, npub: String },
}
@@ -32,17 +43,13 @@ impl RadrootsApp {
pub fn new(backend: Box<dyn RadrootsAppBackend>) -> Self {
let mut app = Self {
backend,
- screen: AppScreen::Setup {
- generate_enabled: true,
- },
+ screen: AppScreen::Setup,
status_message: None,
};
match app.backend.load_identity_state() {
Ok(state) => app.apply_identity_state(state),
Err(err) => {
- app.screen = AppScreen::Setup {
- generate_enabled: false,
- };
+ app.screen = AppScreen::Setup;
app.status_message = Some(err);
}
}
@@ -52,9 +59,7 @@ impl RadrootsApp {
fn apply_identity_state(&mut self, state: IdentityGateState) {
match state {
IdentityGateState::Missing => {
- self.screen = AppScreen::Setup {
- generate_enabled: true,
- };
+ self.screen = AppScreen::Setup;
self.status_message = None;
}
IdentityGateState::Ready { account_id, npub } => {
@@ -62,17 +67,38 @@ impl RadrootsApp {
self.status_message = None;
}
IdentityGateState::Unsupported { reason } => {
- self.screen = AppScreen::Setup {
- generate_enabled: false,
- };
+ self.screen = AppScreen::Setup;
self.status_message = Some(reason);
}
}
}
+
+ fn request_setup_action(&mut self) {
+ self.status_message = None;
+ match self.backend.request_setup_action() {
+ Ok(Some(state)) => self.apply_identity_state(state),
+ Ok(None) => {}
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
+ fn sync_backend(&mut self) {
+ match self.backend.poll_identity_state() {
+ Ok(Some(state)) => self.apply_identity_state(state),
+ Ok(None) => {}
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
}
impl eframe::App for RadrootsApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ self.sync_backend();
+
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(48.0);
@@ -80,27 +106,27 @@ impl eframe::App for RadrootsApp {
ui.add_space(12.0);
match &self.screen {
- AppScreen::Setup { generate_enabled } => {
+ AppScreen::Setup => {
+ let action = self.backend.setup_action_state();
+ if action.pending {
+ ctx.request_repaint();
+ }
+
ui.label("setup");
ui.add_space(8.0);
- ui.label("A local Nostr key is required before the app can continue.");
+ ui.label("A signing identity is required before the app can continue.");
ui.add_space(16.0);
let clicked = ui
- .add_enabled(*generate_enabled, egui::Button::new("Generate New Key"))
+ .add_enabled(action.enabled, egui::Button::new(action.label))
.clicked();
if clicked {
- match self.backend.generate_new_key() {
- Ok(state) => self.apply_identity_state(state),
- Err(err) => {
- self.status_message = Some(err);
- }
- }
+ self.request_setup_action();
}
}
AppScreen::Home { account_id, npub } => {
ui.label("home");
ui.add_space(8.0);
- ui.label("A local signing identity is configured.");
+ ui.label("A signing identity is configured.");
ui.add_space(12.0);
ui.monospace(format!("account id: {account_id}"));
ui.monospace(format!("npub: {npub}"));
@@ -126,17 +152,23 @@ mod tests {
#[derive(Clone)]
struct MockBackend {
load: Result<IdentityGateState, String>,
- generate: Rc<RefCell<VecDeque<Result<IdentityGateState, String>>>>,
+ action_state: Rc<RefCell<SetupActionState>>,
+ request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
+ poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
}
impl MockBackend {
fn new(
load: Result<IdentityGateState, String>,
- generate: Vec<Result<IdentityGateState, String>>,
+ request: Vec<Result<Option<IdentityGateState>, String>>,
+ poll: Vec<Result<Option<IdentityGateState>, String>>,
+ action_state: SetupActionState,
) -> Self {
Self {
load,
- generate: Rc::new(RefCell::new(generate.into())),
+ action_state: Rc::new(RefCell::new(action_state)),
+ request: Rc::new(RefCell::new(request.into())),
+ poll: Rc::new(RefCell::new(poll.into())),
}
}
}
@@ -146,11 +178,19 @@ mod tests {
self.load.clone()
}
- fn generate_new_key(&self) -> Result<IdentityGateState, String> {
- self.generate
+ fn setup_action_state(&self) -> SetupActionState {
+ self.action_state.borrow().clone()
+ }
+
+ fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> {
+ self.request
.borrow_mut()
.pop_front()
- .unwrap_or_else(|| Err("missing generate response".into()))
+ .unwrap_or_else(|| Err("missing request response".into()))
+ }
+
+ fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
+ self.poll.borrow_mut().pop_front().unwrap_or(Ok(None))
}
}
@@ -159,13 +199,14 @@ mod tests {
let app = RadrootsApp::new(Box::new(MockBackend::new(
Ok(IdentityGateState::Missing),
vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
)));
- assert_eq!(
- app.screen,
- AppScreen::Setup {
- generate_enabled: true
- }
- );
+ assert_eq!(app.screen, AppScreen::Setup);
assert_eq!(app.status_message, None);
}
@@ -177,6 +218,12 @@ mod tests {
npub: "npub1abc".into(),
}),
vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
)));
assert_eq!(
app.screen,
@@ -189,34 +236,70 @@ mod tests {
}
#[test]
- fn startup_unsupported_disables_generation() {
+ fn startup_unsupported_shows_reason() {
let app = RadrootsApp::new(Box::new(MockBackend::new(
Ok(IdentityGateState::Unsupported {
reason: "unsupported".into(),
}),
vec![],
+ vec![],
+ SetupActionState {
+ label: "Connect Browser Signer".into(),
+ enabled: false,
+ pending: false,
+ },
)));
+ assert_eq!(app.screen, AppScreen::Setup);
+ assert_eq!(app.status_message.as_deref(), Some("unsupported"));
+ }
+
+ #[test]
+ fn deferred_setup_action_transitions_to_home_after_poll() {
+ let mut app = RadrootsApp::new(Box::new(MockBackend::new(
+ Ok(IdentityGateState::Missing),
+ vec![Ok(None)],
+ vec![Ok(Some(IdentityGateState::Ready {
+ account_id: "abc".into(),
+ npub: "npub1abc".into(),
+ }))],
+ SetupActionState {
+ label: "Connect Browser Signer".into(),
+ enabled: true,
+ pending: false,
+ },
+ )));
+
+ app.request_setup_action();
+ assert_eq!(app.screen, AppScreen::Setup);
+
+ app.sync_backend();
+
assert_eq!(
app.screen,
- AppScreen::Setup {
- generate_enabled: false
+ AppScreen::Home {
+ account_id: "abc".into(),
+ npub: "npub1abc".into(),
}
);
- assert_eq!(app.status_message.as_deref(), Some("unsupported"));
}
#[test]
- fn generate_result_transitions_to_home() {
+ fn immediate_setup_action_transitions_to_home() {
let mut app = RadrootsApp::new(Box::new(MockBackend::new(
Ok(IdentityGateState::Missing),
- vec![Ok(IdentityGateState::Ready {
+ vec![Ok(Some(IdentityGateState::Ready {
account_id: "abc".into(),
npub: "npub1abc".into(),
- })],
+ }))],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
)));
- let state = app.backend.generate_new_key().expect("generate");
- app.apply_identity_state(state);
+ app.request_setup_action();
assert_eq!(
app.screen,
diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs
@@ -3,7 +3,9 @@
use directories::BaseDirs;
use eframe::egui;
-use radroots_app_core::{APP_NAME, IdentityGateState, RadrootsApp, RadrootsAppBackend};
+use radroots_app_core::{
+ APP_NAME, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState,
+};
#[cfg(target_os = "macos")]
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrSecretVaultOsKeyring,
@@ -85,22 +87,42 @@ impl RadrootsAppBackend for DesktopBackend {
}
}
- fn generate_new_key(&self) -> Result<IdentityGateState, String> {
+ fn setup_action_state(&self) -> SetupActionState {
+ #[cfg(target_os = "macos")]
+ {
+ return SetupActionState {
+ label: "Generate New Key".to_owned(),
+ enabled: true,
+ pending: false,
+ };
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ SetupActionState {
+ label: "Generate New Key".to_owned(),
+ enabled: false,
+ pending: false,
+ }
+ }
+ }
+
+ fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> {
#[cfg(target_os = "macos")]
{
let manager = Self::accounts_manager()?;
manager
.generate_identity(Some("local".to_owned()), true)
.map_err(|source| source.to_string())?;
- return self.load_identity_state();
+ return self.load_identity_state().map(Some);
}
#[cfg(not(target_os = "macos"))]
{
- Ok(IdentityGateState::Unsupported {
+ Ok(Some(IdentityGateState::Unsupported {
reason: "Local secure onboarding is only implemented for macOS in this slice."
.to_owned(),
- })
+ }))
}
}
}
diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml
@@ -22,6 +22,8 @@ wasm-bindgen-futures.workspace = true
web-sys.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
+nostr.workspace = true
+nostr-browser-signer.workspace = true
wgpu = { workspace = true, features = ["std", "wgsl", "webgpu", "fragile-send-sync-non-atomic-wasm"] }
[lints]
diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs
@@ -1,25 +1,167 @@
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
+use std::cell::RefCell;
+#[cfg(target_arch = "wasm32")]
+use std::rc::Rc;
+
+#[cfg(target_arch = "wasm32")]
use eframe::wasm_bindgen::JsCast as _;
#[cfg(target_arch = "wasm32")]
-use radroots_app_core::{IdentityGateState, RadrootsApp, RadrootsAppBackend};
+use nostr::nips::nip19::ToBech32;
+#[cfg(target_arch = "wasm32")]
+use nostr::signer::NostrSigner;
+#[cfg(target_arch = "wasm32")]
+use nostr_browser_signer::{BrowserSigner, Error as BrowserSignerError};
+#[cfg(target_arch = "wasm32")]
+use radroots_app_core::{IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState};
+
+#[cfg(target_arch = "wasm32")]
+#[derive(Clone)]
+struct ConnectedSigner {
+ account_id: String,
+ npub: String,
+ signer: BrowserSigner,
+}
+
+#[cfg(target_arch = "wasm32")]
+enum WebConnectionState {
+ Disconnected,
+ Connecting,
+ Ready(ConnectedSigner),
+}
#[cfg(target_arch = "wasm32")]
-struct WebBackend;
+struct WebBackendState {
+ connection: WebConnectionState,
+ pending_result: Option<Result<ConnectedSigner, String>>,
+}
+
+#[cfg(target_arch = "wasm32")]
+#[derive(Clone)]
+struct WebBackend {
+ state: Rc<RefCell<WebBackendState>>,
+}
+
+#[cfg(target_arch = "wasm32")]
+impl WebBackend {
+ fn new() -> Self {
+ Self {
+ state: Rc::new(RefCell::new(WebBackendState {
+ connection: WebConnectionState::Disconnected,
+ pending_result: None,
+ })),
+ }
+ }
+
+ fn identity_state_for_ready(connected: &ConnectedSigner) -> IdentityGateState {
+ let _ = &connected.signer;
+ IdentityGateState::Ready {
+ account_id: connected.account_id.clone(),
+ npub: connected.npub.clone(),
+ }
+ }
+
+ fn connect_error_message(err: BrowserSignerError) -> String {
+ match err {
+ BrowserSignerError::NoGlobalWindowObject | BrowserSignerError::NamespaceNotFound(_) => {
+ "No NIP-07 browser signer detected.".to_owned()
+ }
+ other => format!("Browser signer connection failed: {other}"),
+ }
+ }
+}
#[cfg(target_arch = "wasm32")]
impl RadrootsAppBackend for WebBackend {
fn load_identity_state(&self) -> Result<IdentityGateState, String> {
- Ok(IdentityGateState::Unsupported {
- reason: "Local secure onboarding is not enabled for the web target.".to_owned(),
- })
+ let state = self.state.borrow();
+ match &state.connection {
+ WebConnectionState::Ready(connected) => Ok(Self::identity_state_for_ready(connected)),
+ WebConnectionState::Disconnected | WebConnectionState::Connecting => {
+ Ok(IdentityGateState::Missing)
+ }
+ }
+ }
+
+ fn setup_action_state(&self) -> SetupActionState {
+ let state = self.state.borrow();
+ match &state.connection {
+ WebConnectionState::Connecting => SetupActionState {
+ label: "Connecting Browser Signer...".to_owned(),
+ enabled: false,
+ pending: true,
+ },
+ WebConnectionState::Disconnected => SetupActionState {
+ label: "Connect Browser Signer".to_owned(),
+ enabled: true,
+ pending: false,
+ },
+ WebConnectionState::Ready(_) => SetupActionState {
+ label: "Browser Signer Connected".to_owned(),
+ enabled: false,
+ pending: false,
+ },
+ }
}
- fn generate_new_key(&self) -> Result<IdentityGateState, String> {
- Ok(IdentityGateState::Unsupported {
- reason: "Local secure onboarding is not enabled for the web target.".to_owned(),
- })
+ fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> {
+ {
+ let state = self.state.borrow();
+ match &state.connection {
+ WebConnectionState::Connecting => return Ok(None),
+ WebConnectionState::Ready(connected) => {
+ return Ok(Some(Self::identity_state_for_ready(connected)));
+ }
+ WebConnectionState::Disconnected => {}
+ }
+ }
+
+ let signer = BrowserSigner::new().map_err(Self::connect_error_message)?;
+ {
+ let mut state = self.state.borrow_mut();
+ state.connection = WebConnectionState::Connecting;
+ state.pending_result = None;
+ }
+
+ let shared_state = Rc::clone(&self.state);
+ wasm_bindgen_futures::spawn_local(async move {
+ let result = match signer.get_public_key().await {
+ Ok(public_key) => match public_key.to_bech32() {
+ Ok(npub) => Ok(ConnectedSigner {
+ account_id: public_key.to_hex(),
+ npub,
+ signer,
+ }),
+ Err(source) => Err(format!("Failed to encode npub: {source}")),
+ },
+ Err(source) => Err(format!("Browser signer connection failed: {source}")),
+ };
+
+ let mut state = shared_state.borrow_mut();
+ state.pending_result = Some(result);
+ });
+
+ Ok(None)
+ }
+
+ fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
+ let mut state = self.state.borrow_mut();
+ let Some(result) = state.pending_result.take() else {
+ return Ok(None);
+ };
+
+ match result {
+ Ok(connected) => {
+ let identity = Self::identity_state_for_ready(&connected);
+ state.connection = WebConnectionState::Ready(connected);
+ Ok(Some(identity))
+ }
+ Err(err) => {
+ state.connection = WebConnectionState::Disconnected;
+ Err(err)
+ }
+ }
}
}
@@ -46,7 +188,7 @@ pub fn launch() {
.start(
canvas,
web_options,
- Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(WebBackend))))),
+ Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(WebBackend::new()))))),
)
.await;
diff --git a/scripts/with-wasm-toolchain.sh b/scripts/with-wasm-toolchain.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ "$#" -eq 0 ]; then
+ echo "usage: $0 <command> [args...]" >&2
+ exit 64
+fi
+
+unset NO_COLOR
+
+probe_wasm_clang() {
+ local clang_bin="$1"
+ local probe_file
+
+ if [ ! -x "$clang_bin" ]; then
+ return 1
+ fi
+
+ probe_file="$(mktemp)"
+ trap 'rm -f "$probe_file"' RETURN
+ printf 'int main(void){return 0;}\n' \
+ | "$clang_bin" --target=wasm32-unknown-unknown -x c -c - -o "$probe_file" >/dev/null 2>&1
+}
+
+if [ -z "${CC_wasm32_unknown_unknown:-}" ]; then
+ if probe_wasm_clang /opt/homebrew/opt/llvm/bin/clang; then
+ export PATH="/opt/homebrew/opt/llvm/bin:$PATH"
+ export CC_wasm32_unknown_unknown=/opt/homebrew/opt/llvm/bin/clang
+ elif command -v clang >/dev/null 2>&1 && probe_wasm_clang "$(command -v clang)"; then
+ export CC_wasm32_unknown_unknown
+ CC_wasm32_unknown_unknown="$(command -v clang)"
+ else
+ echo "no wasm-capable clang found; install llvm or set CC_wasm32_unknown_unknown" >&2
+ exit 1
+ fi
+fi
+
+exec "$@"