app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 19+++++++++++++++++--
MCargo.toml | 2++
Mcrates/core/src/lib.rs | 173++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/desktop/src/main.rs | 32+++++++++++++++++++++++++++-----
Mcrates/web/Cargo.toml | 2++
Mcrates/web/src/lib.rs | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Ascripts/with-wasm-toolchain.sh | 38++++++++++++++++++++++++++++++++++++++
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 "$@"