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:
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),
}
}