app

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

commit e5a0f2f70a62605d01c730c27afa8f7be0725911
parent edbbbddd06916b5253ebef4fa077aa3ccb4cd668
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 18:03:55 +0000

core: add backup and recovery action contract

- add the shared backup and recovery action contract in the app core
- add inert setup recovery entry and home recovery-key reveal ui state without changing platform behavior yet
- rebase the desktop ios android and web backends onto the richer shared action result model
- document local managed backup recovery capabilities and wasm external-signer limits in identity lifecycle docs

Diffstat:
Mcrates/android/src/lib.rs | 26+++++++++++++-------------
Mcrates/core/src/lib.rs | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/desktop/src/main.rs | 24+++++++++++-------------
Mcrates/ios/src/lib.rs | 18++++++++++--------
Mcrates/web/src/lib.rs | 17+++++++++--------
5 files changed, 313 insertions(+), 64 deletions(-)

diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -6,10 +6,12 @@ use android_logger::Config; use eframe::egui::ViewportBuilder; #[cfg(any(target_os = "android", test))] use radroots_app_core::RadrootsAppBackend; -#[cfg(any(target_os = "android", test))] -use radroots_app_core::{HomeActionKind, HomeActionState, IdentityGateState, SetupActionState}; #[cfg(target_os = "android")] -use radroots_app_core::{RadrootsApp, APP_NAME}; +use radroots_app_core::{APP_NAME, RadrootsApp}; +#[cfg(any(target_os = "android", test))] +use radroots_app_core::{ + HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, SetupActionState, +}; #[cfg(test)] use radroots_identity::RadrootsIdentity; #[cfg(test)] @@ -98,29 +100,27 @@ impl RadrootsAppBackend for AndroidBackend { } } - fn request_home_action( - &self, - action: HomeActionKind, - ) -> Result<Option<IdentityGateState>, String> { + fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { #[cfg(target_os = "android")] { let manager = Self::accounts_manager()?; return match action { - HomeActionKind::RemoveLocalKey => { - Self::remove_selected_local_identity(&manager).map(Some) - } + HomeActionKind::BackupRecoveryKey => Ok(HomeActionResult::None), + HomeActionKind::RemoveLocalKey => Self::remove_selected_local_identity(&manager) + .map(HomeActionResult::IdentityState), HomeActionKind::ResetDevice => { let accounts_path = storage::accounts_path()?; - Self::reset_local_device_state(&manager, accounts_path.as_path()).map(Some) + Self::reset_local_device_state(&manager, accounts_path.as_path()) + .map(HomeActionResult::IdentityState) } - HomeActionKind::DisconnectSigner => Ok(None), + HomeActionKind::DisconnectSigner => Ok(HomeActionResult::None), }; } #[cfg(not(target_os = "android"))] { let _ = action; - Ok(None) + Ok(HomeActionResult::None) } } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -12,6 +12,13 @@ pub struct SetupActionState { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryActionState { + pub label: String, + pub enabled: bool, + pub pending: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct HomeActionState { pub kind: HomeActionKind, pub label: String, @@ -21,12 +28,20 @@ pub struct HomeActionState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HomeActionKind { + BackupRecoveryKey, RemoveLocalKey, ResetDevice, DisconnectSigner, } #[derive(Debug, Clone, PartialEq, Eq)] +pub enum HomeActionResult { + None, + IdentityState(IdentityGateState), + RevealRecoveryKey { nsec: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum IdentityGateState { Missing, Ready { account_id: String, npub: String }, @@ -37,15 +52,21 @@ pub trait RadrootsAppBackend { fn load_identity_state(&self) -> Result<IdentityGateState, String>; fn setup_action_state(&self) -> SetupActionState; fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String>; - fn home_action_states(&self) -> Vec<HomeActionState> { - Vec::new() + fn recovery_action_state(&self) -> Option<RecoveryActionState> { + None } - fn request_home_action( + fn request_recovery_action( &self, - _action: HomeActionKind, + _recovery_key: &str, ) -> Result<Option<IdentityGateState>, String> { Ok(None) } + fn home_action_states(&self) -> Vec<HomeActionState> { + Vec::new() + } + fn request_home_action(&self, _action: HomeActionKind) -> Result<HomeActionResult, String> { + Ok(HomeActionResult::None) + } fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { Ok(None) } @@ -62,6 +83,9 @@ pub struct RadrootsApp { screen: AppScreen, status_message: Option<String>, pending_home_confirmation: Option<HomeActionKind>, + pending_recovery_entry: bool, + recovery_key_input: String, + revealed_recovery_key: Option<String>, } impl RadrootsApp { @@ -71,6 +95,9 @@ impl RadrootsApp { screen: AppScreen::Setup, status_message: None, pending_home_confirmation: None, + pending_recovery_entry: false, + recovery_key_input: String::new(), + revealed_recovery_key: None, }; match app.backend.load_identity_state() { Ok(state) => app.apply_identity_state(state), @@ -88,22 +115,32 @@ impl RadrootsApp { self.screen = AppScreen::Setup; self.status_message = None; self.pending_home_confirmation = None; + self.pending_recovery_entry = false; + self.recovery_key_input.clear(); + self.revealed_recovery_key = None; } IdentityGateState::Ready { account_id, npub } => { self.screen = AppScreen::Home { account_id, npub }; self.status_message = None; self.pending_home_confirmation = None; + self.pending_recovery_entry = false; + self.recovery_key_input.clear(); + self.revealed_recovery_key = None; } IdentityGateState::Unsupported { reason } => { self.screen = AppScreen::Setup; self.status_message = Some(reason); self.pending_home_confirmation = None; + self.pending_recovery_entry = false; + self.recovery_key_input.clear(); + self.revealed_recovery_key = None; } } } fn request_setup_action(&mut self) { self.status_message = None; + self.revealed_recovery_key = None; match self.backend.request_setup_action() { Ok(Some(state)) => self.apply_identity_state(state), Ok(None) => {} @@ -113,9 +150,13 @@ impl RadrootsApp { } } - fn request_home_action(&mut self, action: HomeActionKind) { + fn request_recovery_action(&mut self) { self.status_message = None; - match self.backend.request_home_action(action) { + self.revealed_recovery_key = None; + match self + .backend + .request_recovery_action(self.recovery_key_input.trim()) + { Ok(Some(state)) => self.apply_identity_state(state), Ok(None) => {} Err(err) => { @@ -124,8 +165,31 @@ impl RadrootsApp { } } + fn request_home_action(&mut self, action: HomeActionKind) { + self.status_message = None; + self.revealed_recovery_key = None; + match self.backend.request_home_action(action) { + Ok(HomeActionResult::IdentityState(state)) => self.apply_identity_state(state), + Ok(HomeActionResult::RevealRecoveryKey { nsec }) => { + self.revealed_recovery_key = Some(nsec); + self.pending_home_confirmation = None; + } + Ok(HomeActionResult::None) => {} + Err(err) => { + self.status_message = Some(err); + } + } + } + + fn home_action_requires_confirmation(action: HomeActionKind) -> bool { + !matches!(action, HomeActionKind::BackupRecoveryKey) + } + fn home_action_confirmation_message(action: HomeActionKind) -> &'static str { match action { + HomeActionKind::BackupRecoveryKey => { + "This reveals the current local recovery key for backup. Do not share it." + } HomeActionKind::RemoveLocalKey => { "This removes the current key from this device and returns the app to setup." } @@ -165,6 +229,12 @@ impl eframe::App for RadrootsApp { if action.pending { ctx.request_repaint(); } + let recovery_action = self.backend.recovery_action_state(); + if let Some(recovery_action) = &recovery_action { + if recovery_action.pending { + ctx.request_repaint(); + } + } ui.label("setup"); ui.add_space(8.0); @@ -176,6 +246,45 @@ impl eframe::App for RadrootsApp { if clicked { self.request_setup_action(); } + + if let Some(recovery_action) = recovery_action { + ui.add_space(12.0); + if self.pending_recovery_entry { + ui.vertical_centered(|ui| { + ui.set_max_width(ui.available_width().min(560.0)); + ui.label( + "Recover an existing local identity by entering its nsec recovery key.", + ); + ui.add_space(8.0); + ui.add( + egui::TextEdit::singleline(&mut self.recovery_key_input) + .hint_text("nsec1...") + .desired_width(ui.available_width()), + ); + ui.add_space(8.0); + ui.horizontal_centered(|ui| { + let confirm_clicked = ui + .add_enabled( + recovery_action.enabled, + egui::Button::new(recovery_action.label.clone()), + ) + .clicked(); + if confirm_clicked { + self.request_recovery_action(); + } + + if ui.button("Cancel").clicked() { + self.pending_recovery_entry = false; + self.recovery_key_input.clear(); + self.status_message = None; + } + }); + }); + } else if ui.button(recovery_action.label).clicked() { + self.pending_recovery_entry = true; + self.status_message = None; + } + } } AppScreen::Home { account_id, npub } => { ui.label("home"); @@ -192,7 +301,9 @@ impl eframe::App for RadrootsApp { ctx.request_repaint(); } - if self.pending_home_confirmation == Some(action.kind) { + if Self::home_action_requires_confirmation(action.kind) + && self.pending_home_confirmation == Some(action.kind) + { ui.vertical_centered(|ui| { ui.set_max_width(ui.available_width().min(560.0)); ui.label(Self::home_action_confirmation_message(action.kind)); @@ -214,10 +325,31 @@ impl eframe::App for RadrootsApp { } }); }); - } else if self.pending_home_confirmation.is_none() - && ui.button(action.label).clicked() + } else if Self::home_action_requires_confirmation(action.kind) + && self.pending_home_confirmation.is_none() + && ui.button(action.label.clone()).clicked() { self.pending_home_confirmation = Some(action.kind); + } else if !Self::home_action_requires_confirmation(action.kind) + && ui + .add_enabled( + action.enabled, + egui::Button::new(action.label.clone()), + ) + .clicked() + { + self.request_home_action(action.kind); + } + } + + if let Some(nsec) = &self.revealed_recovery_key { + ui.add_space(20.0); + ui.label("Recovery key"); + ui.add_space(8.0); + ui.monospace(nsec); + ui.add_space(8.0); + if ui.button("Dismiss Recovery Key").clicked() { + self.revealed_recovery_key = None; } } } @@ -243,9 +375,11 @@ mod tests { struct MockBackend { load: Result<IdentityGateState, String>, action_state: Rc<RefCell<SetupActionState>>, + recovery_action_state: Rc<RefCell<Option<RecoveryActionState>>>, home_action_states: Rc<RefCell<Vec<HomeActionState>>>, request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, - home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<Option<IdentityGateState>, String>)>>>, + recovery_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, + home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>, poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, } @@ -259,22 +393,38 @@ mod tests { Self { load, action_state: Rc::new(RefCell::new(action_state)), + recovery_action_state: Rc::new(RefCell::new(None)), home_action_states: Rc::new(RefCell::new(Vec::new())), request: Rc::new(RefCell::new(request.into())), + recovery_request: Rc::new(RefCell::new(VecDeque::new())), home_request: Rc::new(RefCell::new(VecDeque::new())), poll: Rc::new(RefCell::new(poll.into())), } } + fn with_recovery_action( + self, + action_state: RecoveryActionState, + request: Vec<Result<Option<IdentityGateState>, String>>, + ) -> Self { + *self.recovery_action_state.borrow_mut() = Some(action_state); + self.recovery_request.borrow_mut().extend(request); + self + } + fn with_home_action( self, action_state: HomeActionState, - request: Vec<Result<Option<IdentityGateState>, String>>, + request: Vec<Result<HomeActionResult, String>>, ) -> Self { - self.home_action_states.borrow_mut().push(action_state.clone()); - self.home_request + self.home_action_states .borrow_mut() - .extend(request.into_iter().map(|result| (action_state.kind, result))); + .push(action_state.clone()); + self.home_request.borrow_mut().extend( + request + .into_iter() + .map(|result| (action_state.kind, result)), + ); self } } @@ -295,14 +445,25 @@ mod tests { .unwrap_or_else(|| Err("missing request response".into())) } - fn home_action_states(&self) -> Vec<HomeActionState> { - self.home_action_states.borrow().clone() + fn recovery_action_state(&self) -> Option<RecoveryActionState> { + self.recovery_action_state.borrow().clone() } - fn request_home_action( + fn request_recovery_action( &self, - action: HomeActionKind, + _recovery_key: &str, ) -> Result<Option<IdentityGateState>, String> { + self.recovery_request + .borrow_mut() + .pop_front() + .unwrap_or(Ok(None)) + } + + fn home_action_states(&self) -> Vec<HomeActionState> { + self.home_action_states.borrow().clone() + } + + fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { let Some((expected_action, response)) = self.home_request.borrow_mut().pop_front() else { return Err("missing home action response".into()); @@ -460,7 +621,9 @@ mod tests { enabled: true, pending: false, }, - vec![Ok(Some(IdentityGateState::Missing))], + vec![Ok(HomeActionResult::IdentityState( + IdentityGateState::Missing, + ))], ), )); @@ -504,7 +667,10 @@ mod tests { assert!(matches!(app.screen, AppScreen::Home { .. })); assert_eq!(app.status_message.as_deref(), Some("remove failed")); - assert_eq!(app.pending_home_confirmation, Some(HomeActionKind::RemoveLocalKey)); + assert_eq!( + app.pending_home_confirmation, + Some(HomeActionKind::RemoveLocalKey) + ); } #[test] @@ -530,7 +696,9 @@ mod tests { enabled: true, pending: false, }, - vec![Ok(Some(IdentityGateState::Missing))], + vec![Ok(HomeActionResult::IdentityState( + IdentityGateState::Missing, + ))], ), )); @@ -574,6 +742,86 @@ mod tests { assert!(matches!(app.screen, AppScreen::Home { .. })); assert_eq!(app.status_message.as_deref(), Some("reset failed")); - assert_eq!(app.pending_home_confirmation, Some(HomeActionKind::ResetDevice)); + assert_eq!( + app.pending_home_confirmation, + Some(HomeActionKind::ResetDevice) + ); + } + + #[test] + fn recovery_action_transitions_to_home() { + let mut app = RadrootsApp::new(Box::new( + MockBackend::new( + Ok(IdentityGateState::Missing), + vec![], + vec![], + SetupActionState { + label: "Generate New Key".into(), + enabled: true, + pending: false, + }, + ) + .with_recovery_action( + RecoveryActionState { + label: "Recover Existing Key".into(), + enabled: true, + pending: false, + }, + vec![Ok(Some(IdentityGateState::Ready { + account_id: "abc".into(), + npub: "npub1abc".into(), + }))], + ), + )); + + app.pending_recovery_entry = true; + app.recovery_key_input = "nsec1example".into(); + app.request_recovery_action(); + + assert_eq!( + app.screen, + AppScreen::Home { + account_id: "abc".into(), + npub: "npub1abc".into(), + } + ); + assert_eq!(app.pending_recovery_entry, false); + assert_eq!(app.recovery_key_input, ""); + } + + #[test] + fn backup_home_action_reveals_recovery_key_without_leaving_home() { + let mut app = RadrootsApp::new(Box::new( + MockBackend::new( + Ok(IdentityGateState::Ready { + account_id: "abc".into(), + npub: "npub1abc".into(), + }), + vec![], + vec![], + SetupActionState { + label: "Generate New Key".into(), + enabled: true, + pending: false, + }, + ) + .with_home_action( + HomeActionState { + kind: HomeActionKind::BackupRecoveryKey, + label: "Back Up Recovery Key".into(), + enabled: true, + pending: false, + }, + vec![Ok(HomeActionResult::RevealRecoveryKey { + nsec: "nsec1example".into(), + })], + ), + )); + + app.request_home_action(HomeActionKind::BackupRecoveryKey); + + assert!(matches!(app.screen, AppScreen::Home { .. })); + assert_eq!(app.pending_home_confirmation, None); + assert_eq!(app.revealed_recovery_key.as_deref(), Some("nsec1example")); } } diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -5,10 +5,10 @@ use directories::BaseDirs; use eframe::egui; use image::ImageFormat; #[cfg(target_os = "macos")] -use radroots_app_apple_security::{RadrootsAppleKeychainVault, APPLE_NOSTR_SERVICE}; +use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault}; use radroots_app_core::{ - HomeActionKind, HomeActionState, IdentityGateState, RadrootsApp, RadrootsAppBackend, - SetupActionState, APP_NAME, + APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, RadrootsApp, + RadrootsAppBackend, SetupActionState, }; #[cfg(target_os = "macos")] use radroots_nostr_accounts::prelude::{ @@ -265,29 +265,27 @@ impl RadrootsAppBackend for DesktopBackend { } } - fn request_home_action( - &self, - action: HomeActionKind, - ) -> Result<Option<IdentityGateState>, String> { + fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { #[cfg(target_os = "macos")] { let manager = Self::accounts_manager()?; return match action { - HomeActionKind::RemoveLocalKey => { - Self::remove_selected_local_identity(&manager).map(Some) - } + HomeActionKind::BackupRecoveryKey => Ok(HomeActionResult::None), + HomeActionKind::RemoveLocalKey => Self::remove_selected_local_identity(&manager) + .map(HomeActionResult::IdentityState), HomeActionKind::ResetDevice => { let accounts_path = Self::accounts_path()?; - Self::reset_local_device_state(&manager, accounts_path.as_path()).map(Some) + Self::reset_local_device_state(&manager, accounts_path.as_path()) + .map(HomeActionResult::IdentityState) } - HomeActionKind::DisconnectSigner => Ok(None), + HomeActionKind::DisconnectSigner => Ok(HomeActionResult::None), }; } #[cfg(not(target_os = "macos"))] { let _ = action; - Ok(None) + Ok(HomeActionResult::None) } } } diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -6,7 +6,8 @@ use eframe::egui::ViewportBuilder; use radroots_app_core::IdentityGateState; #[cfg(target_os = "ios")] use radroots_app_core::{ - HomeActionKind, HomeActionState, RadrootsApp, RadrootsAppBackend, SetupActionState, APP_NAME, + APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, RadrootsApp, RadrootsAppBackend, + SetupActionState, }; #[cfg(any(target_os = "ios", test))] use radroots_nostr_accounts::prelude::{ @@ -148,18 +149,19 @@ impl RadrootsAppBackend for IosBackend { ] } - fn request_home_action( - &self, - action: HomeActionKind, - ) -> Result<Option<IdentityGateState>, String> { + fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { let manager = Self::accounts_manager()?; match action { - HomeActionKind::RemoveLocalKey => Self::remove_selected_local_identity(&manager).map(Some), + HomeActionKind::BackupRecoveryKey => Ok(HomeActionResult::None), + HomeActionKind::RemoveLocalKey => { + Self::remove_selected_local_identity(&manager).map(HomeActionResult::IdentityState) + } HomeActionKind::ResetDevice => { let accounts_path = storage::accounts_path()?; - Self::reset_local_device_state(&manager, accounts_path.as_path()).map(Some) + Self::reset_local_device_state(&manager, accounts_path.as_path()) + .map(HomeActionResult::IdentityState) } - HomeActionKind::DisconnectSigner => Ok(None), + HomeActionKind::DisconnectSigner => Ok(HomeActionResult::None), } } } diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs @@ -15,8 +15,8 @@ use nostr::signer::NostrSigner; use nostr_browser_signer::{BrowserSigner, Error as BrowserSignerError}; #[cfg(target_arch = "wasm32")] use radroots_app_core::{ - HomeActionKind, HomeActionState, IdentityGateState, RadrootsApp, RadrootsAppBackend, - SetupActionState, + HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, RadrootsApp, + RadrootsAppBackend, SetupActionState, }; #[cfg(target_arch = "wasm32")] @@ -168,13 +168,14 @@ impl RadrootsAppBackend for WebBackend { } } - fn request_home_action( - &self, - action: HomeActionKind, - ) -> Result<Option<IdentityGateState>, String> { + fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { match action { - HomeActionKind::DisconnectSigner => Ok(Some(self.disconnect_signer())), - HomeActionKind::RemoveLocalKey | HomeActionKind::ResetDevice => Ok(None), + HomeActionKind::DisconnectSigner => { + Ok(HomeActionResult::IdentityState(self.disconnect_signer())) + } + HomeActionKind::BackupRecoveryKey + | HomeActionKind::RemoveLocalKey + | HomeActionKind::ResetDevice => Ok(HomeActionResult::None), } }