commit 21f4bbd6c4dad28c59eb18655b1a10c6866da8d4
parent e7157c55f9b970b5aa71e6ced52e33a2506f0e3b
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 16:41:13 +0000
desktop: add device-reset action for local state
- add the shared home reset action contract and confirmation handling in the app core
- expose a macos-only `Reset This Device` action from the desktop backend in this slice
- remove all local identities and the desktop accounts file before returning the app to setup
- add shared-core and desktop tests for the reset action flow and local file cleanup
Diffstat:
2 files changed, 297 insertions(+), 16 deletions(-)
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -35,6 +35,12 @@ pub trait RadrootsAppBackend {
fn request_home_remove_action(&self) -> Result<Option<IdentityGateState>, String> {
Ok(None)
}
+ fn home_reset_action_state(&self) -> Option<HomeActionState> {
+ None
+ }
+ fn request_home_reset_action(&self) -> Result<Option<IdentityGateState>, String> {
+ Ok(None)
+ }
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
Ok(None)
}
@@ -46,11 +52,17 @@ enum AppScreen {
Home { account_id: String, npub: String },
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PendingHomeConfirmation {
+ RemoveKey,
+ ResetDevice,
+}
+
pub struct RadrootsApp {
backend: Box<dyn RadrootsAppBackend>,
screen: AppScreen,
status_message: Option<String>,
- home_remove_confirmation: bool,
+ pending_home_confirmation: Option<PendingHomeConfirmation>,
}
impl RadrootsApp {
@@ -59,7 +71,7 @@ impl RadrootsApp {
backend,
screen: AppScreen::Setup,
status_message: None,
- home_remove_confirmation: false,
+ pending_home_confirmation: None,
};
match app.backend.load_identity_state() {
Ok(state) => app.apply_identity_state(state),
@@ -76,17 +88,17 @@ impl RadrootsApp {
IdentityGateState::Missing => {
self.screen = AppScreen::Setup;
self.status_message = None;
- self.home_remove_confirmation = false;
+ self.pending_home_confirmation = None;
}
IdentityGateState::Ready { account_id, npub } => {
self.screen = AppScreen::Home { account_id, npub };
self.status_message = None;
- self.home_remove_confirmation = false;
+ self.pending_home_confirmation = None;
}
IdentityGateState::Unsupported { reason } => {
self.screen = AppScreen::Setup;
self.status_message = Some(reason);
- self.home_remove_confirmation = false;
+ self.pending_home_confirmation = None;
}
}
}
@@ -113,6 +125,17 @@ impl RadrootsApp {
}
}
+ fn request_home_reset_action(&mut self) {
+ self.status_message = None;
+ match self.backend.request_home_reset_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),
@@ -166,7 +189,9 @@ impl eframe::App for RadrootsApp {
ctx.request_repaint();
}
- if self.home_remove_confirmation {
+ if self.pending_home_confirmation
+ == Some(PendingHomeConfirmation::RemoveKey)
+ {
ui.vertical_centered(|ui| {
ui.set_max_width(ui.available_width().min(560.0));
ui.label(
@@ -185,13 +210,56 @@ impl eframe::App for RadrootsApp {
}
if ui.button("Cancel").clicked() {
- self.home_remove_confirmation = false;
+ self.pending_home_confirmation = None;
self.status_message = None;
}
});
});
- } else if ui.button(action.label).clicked() {
- self.home_remove_confirmation = true;
+ } else if self.pending_home_confirmation.is_none()
+ && ui.button(action.label).clicked()
+ {
+ self.pending_home_confirmation =
+ Some(PendingHomeConfirmation::RemoveKey);
+ }
+ }
+
+ if let Some(action) = self.backend.home_reset_action_state() {
+ ui.add_space(12.0);
+ if action.pending {
+ ctx.request_repaint();
+ }
+
+ if self.pending_home_confirmation
+ == Some(PendingHomeConfirmation::ResetDevice)
+ {
+ ui.vertical_centered(|ui| {
+ ui.set_max_width(ui.available_width().min(560.0));
+ ui.label(
+ "This removes all local identity and app state from this device and returns the app to setup.",
+ );
+ ui.add_space(8.0);
+ ui.horizontal_centered(|ui| {
+ let confirm_clicked = ui
+ .add_enabled(
+ action.enabled,
+ egui::Button::new(action.label.clone()),
+ )
+ .clicked();
+ if confirm_clicked {
+ self.request_home_reset_action();
+ }
+
+ if ui.button("Cancel").clicked() {
+ self.pending_home_confirmation = None;
+ self.status_message = None;
+ }
+ });
+ });
+ } else if self.pending_home_confirmation.is_none()
+ && ui.button(action.label).clicked()
+ {
+ self.pending_home_confirmation =
+ Some(PendingHomeConfirmation::ResetDevice);
}
}
}
@@ -218,8 +286,10 @@ mod tests {
load: Result<IdentityGateState, String>,
action_state: Rc<RefCell<SetupActionState>>,
home_remove_action_state: Rc<RefCell<Option<HomeActionState>>>,
+ home_reset_action_state: Rc<RefCell<Option<HomeActionState>>>,
request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
remove_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
+ reset_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
}
@@ -234,8 +304,10 @@ mod tests {
load,
action_state: Rc::new(RefCell::new(action_state)),
home_remove_action_state: Rc::new(RefCell::new(None)),
+ home_reset_action_state: Rc::new(RefCell::new(None)),
request: Rc::new(RefCell::new(request.into())),
remove_request: Rc::new(RefCell::new(VecDeque::new())),
+ reset_request: Rc::new(RefCell::new(VecDeque::new())),
poll: Rc::new(RefCell::new(poll.into())),
}
}
@@ -249,6 +321,16 @@ mod tests {
*self.remove_request.borrow_mut() = request.into();
self
}
+
+ fn with_home_reset_action(
+ self,
+ action_state: Option<HomeActionState>,
+ request: Vec<Result<Option<IdentityGateState>, String>>,
+ ) -> Self {
+ *self.home_reset_action_state.borrow_mut() = action_state;
+ *self.reset_request.borrow_mut() = request.into();
+ self
+ }
}
impl RadrootsAppBackend for MockBackend {
@@ -278,6 +360,17 @@ mod tests {
.unwrap_or_else(|| Err("missing remove response".into()))
}
+ fn home_reset_action_state(&self) -> Option<HomeActionState> {
+ self.home_reset_action_state.borrow().clone()
+ }
+
+ fn request_home_reset_action(&self) -> Result<Option<IdentityGateState>, String> {
+ self.reset_request
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or_else(|| Err("missing reset response".into()))
+ }
+
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
self.poll.borrow_mut().pop_front().unwrap_or(Ok(None))
}
@@ -425,12 +518,12 @@ mod tests {
),
));
- app.home_remove_confirmation = true;
+ app.pending_home_confirmation = Some(PendingHomeConfirmation::RemoveKey);
app.request_home_remove_action();
assert_eq!(app.screen, AppScreen::Setup);
assert_eq!(app.status_message, None);
- assert!(!app.home_remove_confirmation);
+ assert_eq!(app.pending_home_confirmation, None);
}
#[test]
@@ -459,11 +552,85 @@ mod tests {
),
));
- app.home_remove_confirmation = true;
+ app.pending_home_confirmation = Some(PendingHomeConfirmation::RemoveKey);
app.request_home_remove_action();
assert!(matches!(app.screen, AppScreen::Home { .. }));
assert_eq!(app.status_message.as_deref(), Some("remove failed"));
- assert!(app.home_remove_confirmation);
+ assert_eq!(
+ app.pending_home_confirmation,
+ Some(PendingHomeConfirmation::RemoveKey)
+ );
+ }
+
+ #[test]
+ fn home_reset_action_transitions_to_setup() {
+ 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_reset_action(
+ Some(HomeActionState {
+ label: "Reset This Device".into(),
+ enabled: true,
+ pending: false,
+ }),
+ vec![Ok(Some(IdentityGateState::Missing))],
+ ),
+ ));
+
+ app.pending_home_confirmation = Some(PendingHomeConfirmation::ResetDevice);
+ app.request_home_reset_action();
+
+ assert_eq!(app.screen, AppScreen::Setup);
+ assert_eq!(app.status_message, None);
+ assert_eq!(app.pending_home_confirmation, None);
+ }
+
+ #[test]
+ fn failed_home_reset_action_keeps_home_screen_and_message() {
+ 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_reset_action(
+ Some(HomeActionState {
+ label: "Reset This Device".into(),
+ enabled: true,
+ pending: false,
+ }),
+ vec![Err("reset failed".into())],
+ ),
+ ));
+
+ app.pending_home_confirmation = Some(PendingHomeConfirmation::ResetDevice);
+ app.request_home_reset_action();
+
+ assert!(matches!(app.screen, AppScreen::Home { .. }));
+ assert_eq!(app.status_message.as_deref(), Some("reset failed"));
+ assert_eq!(
+ app.pending_home_confirmation,
+ Some(PendingHomeConfirmation::ResetDevice)
+ );
}
}
diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs
@@ -5,9 +5,9 @@ use directories::BaseDirs;
use eframe::egui;
use image::ImageFormat;
#[cfg(target_os = "macos")]
-use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault};
+use radroots_app_apple_security::{RadrootsAppleKeychainVault, APPLE_NOSTR_SERVICE};
use radroots_app_core::{
- APP_NAME, HomeActionState, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState,
+ HomeActionState, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState, APP_NAME,
};
#[cfg(target_os = "macos")]
use radroots_nostr_accounts::prelude::{
@@ -89,8 +89,13 @@ impl DesktopBackend {
}
#[cfg(target_os = "macos")]
+ fn accounts_path() -> Result<PathBuf, String> {
+ Ok(Self::app_data_root()?.join("nostr").join("accounts.json"))
+ }
+
+ #[cfg(target_os = "macos")]
fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> {
- let accounts_path = Self::app_data_root()?.join("nostr").join("accounts.json");
+ let accounts_path = Self::accounts_path()?;
if let Some(parent) = accounts_path.parent() {
Self::ensure_private_directory_tree(parent)?;
}
@@ -131,6 +136,48 @@ impl DesktopBackend {
.map_err(|source| source.to_string())?;
Ok(Self::map_status(status))
}
+
+ #[cfg(target_os = "macos")]
+ fn remove_all_local_identities(
+ manager: &RadrootsNostrAccountsManager,
+ ) -> Result<IdentityGateState, String> {
+ let account_ids = manager
+ .list_accounts()
+ .map_err(|source| source.to_string())?
+ .into_iter()
+ .map(|record| record.account_id)
+ .collect::<Vec<_>>();
+
+ for account_id in account_ids {
+ manager
+ .remove_account(&account_id)
+ .map_err(|source| source.to_string())?;
+ }
+
+ let status = manager
+ .selected_account_status()
+ .map_err(|source| source.to_string())?;
+ Ok(Self::map_status(status))
+ }
+
+ #[cfg(target_os = "macos")]
+ fn remove_accounts_file_if_present(accounts_path: &Path) -> Result<(), String> {
+ match std::fs::remove_file(accounts_path) {
+ Ok(()) => Ok(()),
+ Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(source) => Err(format!("failed to remove accounts file: {source}")),
+ }
+ }
+
+ #[cfg(target_os = "macos")]
+ fn reset_local_device_state(
+ manager: &RadrootsNostrAccountsManager,
+ accounts_path: &Path,
+ ) -> Result<IdentityGateState, String> {
+ let state = Self::remove_all_local_identities(manager)?;
+ Self::remove_accounts_file_if_present(accounts_path)?;
+ Ok(state)
+ }
}
impl RadrootsAppBackend for DesktopBackend {
@@ -220,6 +267,36 @@ impl RadrootsAppBackend for DesktopBackend {
Ok(None)
}
}
+
+ fn home_reset_action_state(&self) -> Option<HomeActionState> {
+ #[cfg(target_os = "macos")]
+ {
+ return Some(HomeActionState {
+ label: "Reset This Device".to_owned(),
+ enabled: true,
+ pending: false,
+ });
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+ }
+
+ fn request_home_reset_action(&self) -> Result<Option<IdentityGateState>, String> {
+ #[cfg(target_os = "macos")]
+ {
+ let manager = Self::accounts_manager()?;
+ let accounts_path = Self::accounts_path()?;
+ return Self::reset_local_device_state(&manager, accounts_path.as_path()).map(Some);
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ Ok(None)
+ }
+ }
}
fn main() -> eframe::Result<()> {
@@ -302,4 +379,41 @@ mod tests {
None
);
}
+
+ #[test]
+ fn remove_all_local_identities_clears_every_account() {
+ let manager =
+ radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager::new_in_memory();
+
+ manager
+ .generate_identity(Some("first".into()), true)
+ .expect("generate first");
+ manager
+ .generate_identity(Some("second".into()), false)
+ .expect("generate second");
+
+ let state = DesktopBackend::remove_all_local_identities(&manager).expect("reset state");
+
+ assert_eq!(state, radroots_app_core::IdentityGateState::Missing);
+ assert_eq!(manager.list_accounts().expect("list accounts").len(), 0);
+ assert_eq!(manager.selected_account_id().expect("selected"), None);
+ }
+
+ #[test]
+ fn remove_accounts_file_if_present_deletes_existing_file() {
+ let unique = format!(
+ "radroots-desktop-reset-{}-{}.json",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system time")
+ .as_nanos()
+ );
+ let path = std::env::temp_dir().join(unique);
+ std::fs::write(&path, b"{}").expect("write accounts file");
+
+ DesktopBackend::remove_accounts_file_if_present(path.as_path()).expect("remove file");
+
+ assert!(!path.exists());
+ }
}