commit 0039b24e288574ccdc5bea5fc954f96823791797
parent 67ba7c42812f4e2161844188bf80f4c0aae55769
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 12:33:47 +0000
desktop: add remove-key action for local identities
- add a shared home-screen remove action contract with confirmation handling in the app core
- expose the remove-key action from the macos desktop backend only in this slice
- remove the selected local account on macos and return the app to setup after confirmation
- add shared-core tests for the new home remove action flow
Diffstat:
2 files changed, 207 insertions(+), 1 deletion(-)
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 HomeActionState {
+ pub label: String,
+ pub enabled: bool,
+ pub pending: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdentityGateState {
Missing,
Ready { account_id: String, npub: String },
@@ -22,6 +29,12 @@ 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_remove_action_state(&self) -> Option<HomeActionState> {
+ None
+ }
+ fn request_home_remove_action(&self) -> Result<Option<IdentityGateState>, String> {
+ Ok(None)
+ }
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
Ok(None)
}
@@ -37,6 +50,7 @@ pub struct RadrootsApp {
backend: Box<dyn RadrootsAppBackend>,
screen: AppScreen,
status_message: Option<String>,
+ home_remove_confirmation: bool,
}
impl RadrootsApp {
@@ -45,6 +59,7 @@ impl RadrootsApp {
backend,
screen: AppScreen::Setup,
status_message: None,
+ home_remove_confirmation: false,
};
match app.backend.load_identity_state() {
Ok(state) => app.apply_identity_state(state),
@@ -61,14 +76,17 @@ impl RadrootsApp {
IdentityGateState::Missing => {
self.screen = AppScreen::Setup;
self.status_message = None;
+ self.home_remove_confirmation = false;
}
IdentityGateState::Ready { account_id, npub } => {
self.screen = AppScreen::Home { account_id, npub };
self.status_message = None;
+ self.home_remove_confirmation = false;
}
IdentityGateState::Unsupported { reason } => {
self.screen = AppScreen::Setup;
self.status_message = Some(reason);
+ self.home_remove_confirmation = false;
}
}
}
@@ -84,6 +102,17 @@ impl RadrootsApp {
}
}
+ fn request_home_remove_action(&mut self) {
+ self.status_message = None;
+ match self.backend.request_home_remove_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),
@@ -130,6 +159,41 @@ impl eframe::App for RadrootsApp {
ui.add_space(12.0);
ui.monospace(format!("account id: {account_id}"));
ui.monospace(format!("npub: {npub}"));
+
+ if let Some(action) = self.backend.home_remove_action_state() {
+ ui.add_space(20.0);
+ if action.pending {
+ ctx.request_repaint();
+ }
+
+ if self.home_remove_confirmation {
+ ui.vertical_centered(|ui| {
+ ui.set_max_width(ui.available_width().min(560.0));
+ ui.label(
+ "This removes the current key 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_remove_action();
+ }
+
+ if ui.button("Cancel").clicked() {
+ self.home_remove_confirmation = false;
+ self.status_message = None;
+ }
+ });
+ });
+ } else if ui.button(action.label).clicked() {
+ self.home_remove_confirmation = true;
+ }
+ }
}
}
@@ -153,7 +217,9 @@ mod tests {
struct MockBackend {
load: Result<IdentityGateState, String>,
action_state: Rc<RefCell<SetupActionState>>,
+ home_remove_action_state: Rc<RefCell<Option<HomeActionState>>>,
request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
+ remove_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
}
@@ -167,10 +233,22 @@ mod tests {
Self {
load,
action_state: Rc::new(RefCell::new(action_state)),
+ home_remove_action_state: Rc::new(RefCell::new(None)),
request: Rc::new(RefCell::new(request.into())),
+ remove_request: Rc::new(RefCell::new(VecDeque::new())),
poll: Rc::new(RefCell::new(poll.into())),
}
}
+
+ fn with_home_remove_action(
+ self,
+ action_state: Option<HomeActionState>,
+ request: Vec<Result<Option<IdentityGateState>, String>>,
+ ) -> Self {
+ *self.home_remove_action_state.borrow_mut() = action_state;
+ *self.remove_request.borrow_mut() = request.into();
+ self
+ }
}
impl RadrootsAppBackend for MockBackend {
@@ -189,6 +267,17 @@ mod tests {
.unwrap_or_else(|| Err("missing request response".into()))
}
+ fn home_remove_action_state(&self) -> Option<HomeActionState> {
+ self.home_remove_action_state.borrow().clone()
+ }
+
+ fn request_home_remove_action(&self) -> Result<Option<IdentityGateState>, String> {
+ self.remove_request
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or_else(|| Err("missing remove response".into()))
+ }
+
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
self.poll.borrow_mut().pop_front().unwrap_or(Ok(None))
}
@@ -309,4 +398,72 @@ mod tests {
}
);
}
+
+ #[test]
+ fn home_remove_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_remove_action(
+ Some(HomeActionState {
+ label: "Remove Key From This Device".into(),
+ enabled: true,
+ pending: false,
+ }),
+ vec![Ok(Some(IdentityGateState::Missing))],
+ ),
+ ));
+
+ app.home_remove_confirmation = true;
+ app.request_home_remove_action();
+
+ assert_eq!(app.screen, AppScreen::Setup);
+ assert_eq!(app.status_message, None);
+ assert!(!app.home_remove_confirmation);
+ }
+
+ #[test]
+ fn failed_home_remove_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_remove_action(
+ Some(HomeActionState {
+ label: "Remove Key From This Device".into(),
+ enabled: true,
+ pending: false,
+ }),
+ vec![Err("remove failed".into())],
+ ),
+ ));
+
+ app.home_remove_confirmation = true;
+ 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);
+ }
}
diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs
@@ -6,7 +6,7 @@ use eframe::egui;
#[cfg(target_os = "macos")]
use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault};
use radroots_app_core::{
- APP_NAME, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState,
+ APP_NAME, HomeActionState, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState,
};
#[cfg(target_os = "macos")]
use radroots_nostr_accounts::prelude::{
@@ -96,6 +96,26 @@ impl DesktopBackend {
},
}
}
+
+ #[cfg(target_os = "macos")]
+ fn remove_selected_local_identity(
+ manager: &RadrootsNostrAccountsManager,
+ ) -> Result<IdentityGateState, String> {
+ let Some(account_id) = manager
+ .selected_account_id()
+ .map_err(|source| source.to_string())?
+ else {
+ return Ok(IdentityGateState::Missing);
+ };
+
+ 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))
+ }
}
impl RadrootsAppBackend for DesktopBackend {
@@ -156,6 +176,35 @@ impl RadrootsAppBackend for DesktopBackend {
}))
}
}
+
+ fn home_remove_action_state(&self) -> Option<HomeActionState> {
+ #[cfg(target_os = "macos")]
+ {
+ return Some(HomeActionState {
+ label: "Remove Key From This Device".to_owned(),
+ enabled: true,
+ pending: false,
+ });
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+ }
+
+ fn request_home_remove_action(&self) -> Result<Option<IdentityGateState>, String> {
+ #[cfg(target_os = "macos")]
+ {
+ let manager = Self::accounts_manager()?;
+ return Self::remove_selected_local_identity(&manager).map(Some);
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ Ok(None)
+ }
+ }
}
fn main() -> eframe::Result<()> {