app

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

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:
Mcrates/core/src/lib.rs | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/desktop/src/main.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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()); + } }