From f45d607a4ce9fef64d2c88049536f45c71193c13 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Thu, 26 Feb 2026 18:00:43 -0500 Subject: [PATCH 1/7] Add nsec keys tab and rename shares to FROST shares --- keep-desktop/src/app.rs | 126 +++++++++++- keep-desktop/src/message.rs | 18 ++ keep-desktop/src/screen/layout.rs | 14 +- keep-desktop/src/screen/mod.rs | 32 +-- keep-desktop/src/screen/nsec_keys.rs | 283 +++++++++++++++++++++++++++ 5 files changed, 457 insertions(+), 16 deletions(-) create mode 100644 keep-desktop/src/screen/nsec_keys.rs diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index b94e1ce8..ebcadd0d 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -32,6 +32,7 @@ use crate::screen::export::ExportScreen; use crate::screen::export_ncryptsec::ExportNcryptsecScreen; use crate::screen::import::{ImportMode, ImportScreen}; use crate::screen::layout::SidebarState; +use crate::screen::nsec_keys::{NsecKeyEntry, NsecKeysScreen}; use crate::screen::relay::RelayScreen; use crate::screen::settings::SettingsScreen; use crate::screen::shares::{ShareEntry, ShareListScreen}; @@ -632,6 +633,7 @@ impl App { | Message::GoToImport | Message::GoToExport(..) | Message::NavigateShares + | Message::NavigateNsecKeys | Message::GoBack | Message::NavigateWallets | Message::WalletsLoaded(..) @@ -646,6 +648,11 @@ impl App { | Message::ConfirmDelete(..) | Message::CancelDelete => self.handle_share_list_message(message), + Message::ToggleNsecKeyDetails(..) + | Message::RequestDeleteNsecKey(..) + | Message::ConfirmDeleteNsecKey(..) + | Message::CancelDeleteNsecKey => self.handle_nsec_keys_message(message), + Message::CreateNameChanged(..) | Message::CreateThresholdChanged(..) | Message::CreateTotalChanged(..) @@ -890,6 +897,15 @@ impl App { self.set_share_screen(self.current_shares()); Task::none() } + Message::NavigateNsecKeys => { + if matches!(self.screen, Screen::NsecKeys(_)) { + return Task::none(); + } + self.stop_scanner(); + self.copy_feedback_until = None; + self.set_nsec_keys_screen(); + Task::none() + } Message::NavigateWallets => { if matches!(self.screen, Screen::Wallet(_)) { return Task::none(); @@ -1595,9 +1611,14 @@ impl App { Screen::ShareList(s) if !s.shares.is_empty() => Some(s.shares.len()), _ => None, }; + let nsec_count = match &self.screen { + Screen::NsecKeys(s) if !s.keys.is_empty() => Some(s.keys.len()), + _ => None, + }; let screen = self.screen.view( &sidebar_state, share_count, + nsec_count, pending_count, self.settings.kill_switch_active, ); @@ -1767,7 +1788,10 @@ impl App { let shares = self.current_shares(); self.resolve_active_share(&shares); self.refresh_identities(&shares); - if let Screen::ShareList(s) = &mut self.screen { + let is_nsec_screen = matches!(self.screen, Screen::NsecKeys(_)); + if is_nsec_screen { + self.set_nsec_keys_screen(); + } else if let Screen::ShareList(s) = &mut self.screen { s.shares = shares; s.active_share_hex = self.active_share_hex.clone(); s.delete_confirm = None; @@ -1781,6 +1805,89 @@ impl App { Screen::ShareList(ShareListScreen::new(shares, self.active_share_hex.clone())); } + fn current_nsec_keys(&self) -> Vec { + let guard = lock_keep(&self.keep); + let Some(keep) = guard.as_ref() else { + return Vec::new(); + }; + keep.list_keys() + .unwrap_or_default() + .iter() + .filter_map(NsecKeyEntry::from_record) + .collect() + } + + fn set_nsec_keys_screen(&mut self) { + let keys = self.current_nsec_keys(); + self.screen = Screen::NsecKeys(NsecKeysScreen::new(keys, self.active_share_hex.clone())); + } + + fn handle_nsec_keys_message(&mut self, message: Message) -> Task { + match message { + Message::ToggleNsecKeyDetails(i) => { + if let Screen::NsecKeys(s) = &mut self.screen { + s.expanded = if s.expanded == Some(i) { None } else { Some(i) }; + s.delete_confirm = None; + } + Task::none() + } + Message::RequestDeleteNsecKey(hex) => { + if let Screen::NsecKeys(s) = &mut self.screen { + s.delete_confirm = Some(hex); + } + Task::none() + } + Message::ConfirmDeleteNsecKey(hex) => { + if self.active_share_hex.as_deref() == Some(hex.as_str()) { + self.handle_disconnect_relay(); + self.stop_bunker(); + } + let Ok(bytes) = hex::decode(&hex) else { + return Task::none(); + }; + let Ok(pubkey_bytes) = <[u8; 32]>::try_from(bytes) else { + return Task::none(); + }; + let delete_result = { + let mut guard = lock_keep(&self.keep); + guard.as_mut().map(|keep| keep.delete_key(&pubkey_bytes)) + }; + match delete_result { + Some(Ok(())) => { + let name = if let Screen::NsecKeys(s) = &self.screen { + s.keys + .iter() + .find(|k| k.pubkey_hex == hex) + .map(|k| k.name.clone()) + .unwrap_or_default() + } else { + String::new() + }; + let shares = self.current_shares(); + self.resolve_active_share(&shares); + self.refresh_identities(&shares); + self.set_nsec_keys_screen(); + if !name.is_empty() { + self.set_toast(format!("'{name}' deleted"), ToastKind::Success); + } + } + Some(Err(e)) => { + self.set_toast(friendly_err(e), ToastKind::Error); + } + None => {} + } + Task::none() + } + Message::CancelDeleteNsecKey => { + if let Screen::NsecKeys(s) = &mut self.screen { + s.delete_confirm = None; + } + Task::none() + } + _ => Task::none(), + } + } + fn resolve_active_share(&mut self, shares: &[ShareEntry]) { let guard = lock_keep(&self.keep); let Some(keep) = guard.as_ref() else { @@ -2234,7 +2341,19 @@ impl App { &mut self, result: Result<(Vec, String), String>, ) -> Task { - self.handle_import_result(result) + match result { + Ok((shares, name)) => { + self.resolve_active_share(&shares); + self.refresh_identities(&shares); + self.set_nsec_keys_screen(); + self.set_toast( + format!("'{name}' imported successfully"), + ToastKind::Success, + ); + } + Err(e) => self.screen.set_loading_error(e), + } + Task::none() } fn handle_import_ncryptsec(&mut self) -> Task { @@ -2649,6 +2768,9 @@ impl App { Screen::Bunker(_) => { self.screen = Screen::Bunker(Box::new(self.create_bunker_screen())); } + Screen::NsecKeys(_) => { + self.set_nsec_keys_screen(); + } _ => {} } diff --git a/keep-desktop/src/message.rs b/keep-desktop/src/message.rs index 9997e933..e5041113 100644 --- a/keep-desktop/src/message.rs +++ b/keep-desktop/src/message.rs @@ -128,6 +128,7 @@ pub enum Message { GoToCreate, GoBack, NavigateShares, + NavigateNsecKeys, NavigateWallets, NavigateRelay, NavigateBunker, @@ -136,6 +137,12 @@ pub enum Message { // Share list ToggleShareDetails(usize), + + // Nsec keys list + ToggleNsecKeyDetails(usize), + RequestDeleteNsecKey(String), + ConfirmDeleteNsecKey(String), + CancelDeleteNsecKey, SetActiveShare(String), RequestDelete(ShareIdentity), ConfirmDelete(ShareIdentity), @@ -426,6 +433,7 @@ impl fmt::Debug for Message { Self::GoToCreate => f.write_str("GoToCreate"), Self::GoBack => f.write_str("GoBack"), Self::NavigateShares => f.write_str("NavigateShares"), + Self::NavigateNsecKeys => f.write_str("NavigateNsecKeys"), Self::NavigateWallets => f.write_str("NavigateWallets"), Self::NavigateRelay => f.write_str("NavigateRelay"), Self::NavigateBunker => f.write_str("NavigateBunker"), @@ -436,6 +444,16 @@ impl fmt::Debug for Message { Self::RequestDelete(id) => f.debug_tuple("RequestDelete").field(id).finish(), Self::ConfirmDelete(id) => f.debug_tuple("ConfirmDelete").field(id).finish(), Self::CancelDelete => f.write_str("CancelDelete"), + Self::ToggleNsecKeyDetails(i) => { + f.debug_tuple("ToggleNsecKeyDetails").field(i).finish() + } + Self::RequestDeleteNsecKey(k) => { + f.debug_tuple("RequestDeleteNsecKey").field(k).finish() + } + Self::ConfirmDeleteNsecKey(k) => { + f.debug_tuple("ConfirmDeleteNsecKey").field(k).finish() + } + Self::CancelDeleteNsecKey => f.write_str("CancelDeleteNsecKey"), Self::CreateNameChanged(n) => f.debug_tuple("CreateNameChanged").field(n).finish(), Self::CreateThresholdChanged(t) => { f.debug_tuple("CreateThresholdChanged").field(t).finish() diff --git a/keep-desktop/src/screen/layout.rs b/keep-desktop/src/screen/layout.rs index 5c25078c..eed1db4e 100644 --- a/keep-desktop/src/screen/layout.rs +++ b/keep-desktop/src/screen/layout.rs @@ -31,6 +31,7 @@ fn identity_color(pubkey_hex: &str) -> iced::Color { #[derive(PartialEq)] pub enum NavItem { Shares, + NsecKeys, Create, Import, Wallets, @@ -58,6 +59,7 @@ pub fn with_sidebar<'a>( content: Element<'a, Message>, sidebar_state: &SidebarState<'a>, share_count: Option, + nsec_count: Option, pending_requests: usize, kill_switch_active: bool, ) -> Element<'a, Message> { @@ -70,14 +72,24 @@ pub fn with_sidebar<'a>( Some(n) => NavBadge::Count(n), None => NavBadge::None, }; + let nsec_badge = match nsec_count { + Some(n) => NavBadge::Count(n), + None => NavBadge::None, + }; let nav_items: Vec<(&str, Message, NavItem, NavBadge)> = vec![ ( - "Shares", + "FROST Shares", Message::NavigateShares, NavItem::Shares, share_badge, ), + ( + "Nsec Keys", + Message::NavigateNsecKeys, + NavItem::NsecKeys, + nsec_badge, + ), ( "Create", Message::GoToCreate, diff --git a/keep-desktop/src/screen/mod.rs b/keep-desktop/src/screen/mod.rs index 57c5f630..7c553b3c 100644 --- a/keep-desktop/src/screen/mod.rs +++ b/keep-desktop/src/screen/mod.rs @@ -7,6 +7,7 @@ pub mod export; pub mod export_ncryptsec; pub mod import; pub mod layout; +pub mod nsec_keys; pub mod relay; pub mod scanner; pub mod settings; @@ -29,6 +30,7 @@ pub enum Screen { Wallet(wallet::WalletScreen), Relay(relay::RelayScreen), Bunker(Box), + NsecKeys(nsec_keys::NsecKeysScreen), SigningAudit(signing_audit::SigningAuditScreen), Settings(settings::SettingsScreen), } @@ -38,28 +40,31 @@ impl Screen { &'a self, sidebar_state: &SidebarState<'a>, share_count: Option, + nsec_count: Option, pending_requests: usize, kill_switch_active: bool, ) -> iced::Element<'a, Message> { - let (nav, content, count) = match self { + let (nav, content) = match self { Screen::Unlock(s) => return s.view(), - Screen::ShareList(s) => (NavItem::Shares, s.view_content(), share_count), - Screen::Create(s) => (NavItem::Create, s.view_content(), None), - Screen::Export(s) => (NavItem::Shares, s.view_content(), None), - Screen::ExportNcryptsec(s) => (NavItem::Shares, s.view_content(), None), - Screen::Import(s) => (NavItem::Import, s.view_content(), None), - Screen::Scanner(s) => (NavItem::Import, s.view_content(), None), - Screen::Wallet(s) => (NavItem::Wallets, s.view_content(), None), - Screen::Relay(s) => (NavItem::Relay, s.view_content(), None), - Screen::Bunker(s) => (NavItem::Bunker, s.view_content(), None), - Screen::SigningAudit(s) => (NavItem::Audit, s.view_content(), None), - Screen::Settings(s) => (NavItem::Settings, s.view_content(), None), + Screen::ShareList(s) => (NavItem::Shares, s.view_content()), + Screen::Create(s) => (NavItem::Create, s.view_content()), + Screen::Export(s) => (NavItem::Shares, s.view_content()), + Screen::ExportNcryptsec(s) => (NavItem::NsecKeys, s.view_content()), + Screen::Import(s) => (NavItem::Import, s.view_content()), + Screen::Scanner(s) => (NavItem::Import, s.view_content()), + Screen::Wallet(s) => (NavItem::Wallets, s.view_content()), + Screen::Relay(s) => (NavItem::Relay, s.view_content()), + Screen::Bunker(s) => (NavItem::Bunker, s.view_content()), + Screen::NsecKeys(s) => (NavItem::NsecKeys, s.view_content()), + Screen::SigningAudit(s) => (NavItem::Audit, s.view_content()), + Screen::Settings(s) => (NavItem::Settings, s.view_content()), }; layout::with_sidebar( nav, content, sidebar_state, - count, + share_count, + nsec_count, pending_requests, kill_switch_active, ) @@ -93,6 +98,7 @@ impl Screen { s.error = Some(error); } Screen::ShareList(_) + | Screen::NsecKeys(_) | Screen::Wallet(_) | Screen::Relay(_) | Screen::SigningAudit(_) diff --git a/keep-desktop/src/screen/nsec_keys.rs b/keep-desktop/src/screen/nsec_keys.rs new file mode 100644 index 00000000..ab5ee92b --- /dev/null +++ b/keep-desktop/src/screen/nsec_keys.rs @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: © 2026 PrivKey LLC +// SPDX-License-Identifier: AGPL-3.0-or-later + +use chrono::{DateTime, Utc}; +use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::{Alignment, Element, Length}; +use keep_core::keys::{bytes_to_npub, KeyRecord, KeyType}; + +use crate::message::Message; +use crate::theme; + +fn format_timestamp(ts: i64) -> String { + DateTime::::from_timestamp(ts, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct NsecKeyEntry { + pub name: String, + pub pubkey: [u8; 32], + pub pubkey_hex: String, + pub npub: String, + pub created_at: i64, + pub last_used: Option, + pub sign_count: u64, +} + +impl NsecKeyEntry { + pub fn from_record(record: &KeyRecord) -> Option { + if record.key_type != KeyType::Nostr { + return None; + } + Some(Self { + name: record.name.clone(), + pubkey: record.pubkey, + pubkey_hex: hex::encode(record.pubkey), + npub: bytes_to_npub(&record.pubkey), + created_at: record.created_at, + last_used: record.last_used, + sign_count: record.sign_count, + }) + } + + fn truncated_npub(&self) -> String { + if !self.npub.is_ascii() || self.npub.len() <= 20 { + return self.npub.clone(); + } + format!( + "{}...{}", + &self.npub[..12], + &self.npub[self.npub.len() - 6..] + ) + } + + fn created_display(&self) -> String { + format_timestamp(self.created_at) + } + + fn last_used_display(&self) -> String { + self.last_used + .map(format_timestamp) + .unwrap_or_else(|| "Never".into()) + } +} + +pub struct NsecKeysScreen { + pub keys: Vec, + pub active_key_hex: Option, + pub delete_confirm: Option, + pub expanded: Option, +} + +impl NsecKeysScreen { + pub fn new(keys: Vec, active_key_hex: Option) -> Self { + Self { + keys, + active_key_hex, + delete_confirm: None, + expanded: None, + } + } + + pub fn view_content(&self) -> Element<'_, Message> { + let title = theme::heading("Nsec Keys"); + + let mut content = column![title].spacing(theme::space::MD); + + if self.keys.is_empty() { + let import_card = container( + column![ + text("Import Nsec Key") + .size(theme::size::HEADING) + .color(theme::color::TEXT), + text("Import a nostr secret key (nsec or ncryptsec)") + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED), + Space::new().height(theme::space::SM), + button( + text("Import") + .width(Length::Fill) + .align_x(Alignment::Center), + ) + .on_press(Message::GoToImport) + .style(theme::primary_button) + .padding(theme::space::MD) + .width(Length::Fill), + ] + .spacing(theme::space::SM), + ) + .style(theme::card_style) + .padding(theme::space::LG) + .width(Length::FillPortion(1)); + + let empty = column![ + text("No nsec keys imported") + .size(theme::size::TITLE) + .color(theme::color::TEXT), + text("Import a nostr secret key to get started") + .size(theme::size::BODY) + .color(theme::color::TEXT_MUTED), + Space::new().height(theme::space::LG), + import_card, + ] + .align_x(Alignment::Center) + .spacing(theme::space::SM); + + content = content.push( + container(empty) + .center_x(Length::Fill) + .center_y(Length::Fill), + ); + } else { + let mut list = column![].spacing(theme::space::SM); + for (i, key) in self.keys.iter().enumerate() { + list = list.push(self.key_card(i, key)); + } + content = content.push(scrollable(list).height(Length::Fill)); + } + + container(content) + .padding(theme::space::XL) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + + fn key_card<'a>(&self, i: usize, key: &NsecKeyEntry) -> Element<'a, Message> { + let is_active = self.active_key_hex.as_deref() == Some(&key.pubkey_hex); + + let truncated_npub = key.truncated_npub(); + + let npub_text = text(truncated_npub) + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED); + + let arrow = if self.expanded == Some(i) { "v" } else { ">" }; + let name_btn = button( + text(format!("{arrow} {}", key.name)) + .size(theme::size::HEADING) + .color(theme::color::TEXT), + ) + .on_press(Message::ToggleNsecKeyDetails(i)) + .style(theme::text_button) + .padding(0); + + let mut header_buttons = row![].spacing(theme::space::SM).align_y(Alignment::Center); + + if !is_active { + let activate_btn = button(text("Activate").size(theme::size::SMALL)) + .on_press(Message::SetActiveShare(key.pubkey_hex.clone())) + .style(theme::secondary_button) + .padding([theme::space::XS, theme::space::MD]); + + header_buttons = header_buttons.push(activate_btn); + } + + let export_btn = button(text("Export ncryptsec").size(theme::size::SMALL)) + .on_press(Message::GoToExportNcryptsec(key.pubkey_hex.clone())) + .style(theme::primary_button) + .padding([theme::space::XS, theme::space::MD]); + + header_buttons = header_buttons.push(export_btn); + + let header_top = row![name_btn, Space::new().width(Length::Fill), header_buttons,] + .align_y(Alignment::Center); + + let mut info_row = row![].spacing(theme::space::SM).align_y(Alignment::Center); + + if is_active { + let active_badge_elem = container( + text("ACTIVE") + .size(theme::size::TINY) + .color(iced::Color::WHITE), + ) + .style(theme::active_badge) + .padding([2.0, theme::space::SM]); + + info_row = info_row.push(active_badge_elem); + } + + info_row = info_row.push(npub_text); + + let header_info = column![header_top, info_row].spacing(theme::space::XS); + + let mut card_content = column![header_info].spacing(theme::space::SM); + + if self.expanded == Some(i) { + let npub_row = row![ + text(format!("npub: {}", key.npub)) + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED), + button(text("Copy").size(theme::size::TINY)) + .on_press(Message::CopyNpub(key.npub.clone())) + .style(theme::secondary_button) + .padding([2.0, theme::space::SM]), + ] + .spacing(theme::space::SM) + .align_y(Alignment::Center); + + let details = column![ + npub_row, + text(format!("hex: {}", key.pubkey_hex)) + .size(theme::size::TINY) + .color(theme::color::TEXT_DIM), + text(format!("Created: {}", key.created_display())) + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED), + text(format!("Last used: {}", key.last_used_display())) + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED), + text(format!("Signatures: {}", key.sign_count)) + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED), + ] + .spacing(theme::space::XS); + + let actions = if self.delete_confirm.as_deref() == Some(&key.pubkey_hex) { + row![ + text(format!("Delete '{}'? This cannot be undone.", key.name)) + .size(theme::size::BODY) + .color(theme::color::ERROR), + Space::new().width(Length::Fill), + button(text("Yes").size(theme::size::BODY)) + .on_press(Message::ConfirmDeleteNsecKey(key.pubkey_hex.clone())) + .style(theme::danger_button) + .padding([theme::space::XS, theme::space::MD]), + button(text("No").size(theme::size::BODY)) + .on_press(Message::CancelDeleteNsecKey) + .style(theme::secondary_button) + .padding([theme::space::XS, theme::space::MD]), + ] + .spacing(theme::space::SM) + .align_y(Alignment::Center) + } else { + row![ + Space::new().width(Length::Fill), + button(text("Delete").size(theme::size::BODY)) + .on_press(Message::RequestDeleteNsecKey(key.pubkey_hex.clone())) + .style(theme::danger_button) + .padding([theme::space::XS, theme::space::MD]), + ] + .spacing(theme::space::SM) + .align_y(Alignment::Center) + }; + + card_content = card_content.push(details).push(actions); + } + + let card_style = if is_active { + theme::active_card_style + } else { + theme::card_style + }; + + container(card_content) + .style(card_style) + .padding(theme::space::LG) + .width(Length::Fill) + .into() + } +} From 21fa6ed7cac7bfba415eff3635554daa7722b7b7 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Thu, 26 Feb 2026 18:17:06 -0500 Subject: [PATCH 2/7] Fix nsec tab review findings: dedup utilities, navigation, and safety guards --- keep-desktop/src/app.rs | 14 +++++++++++++- keep-desktop/src/message.rs | 10 ++-------- keep-desktop/src/screen/export_ncryptsec.rs | 11 ++--------- keep-desktop/src/screen/mod.rs | 15 +++++++++++++++ keep-desktop/src/screen/nsec_keys.rs | 21 ++++----------------- keep-desktop/src/screen/shares.rs | 17 ++--------------- 6 files changed, 38 insertions(+), 50 deletions(-) diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index ebcadd0d..b9aa3416 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -892,9 +892,14 @@ impl App { if matches!(self.screen, Screen::ShareList(_)) { return Task::none(); } + let go_to_nsec = matches!(self.screen, Screen::ExportNcryptsec(_)); self.stop_scanner(); self.copy_feedback_until = None; - self.set_share_screen(self.current_shares()); + if go_to_nsec { + self.set_nsec_keys_screen(); + } else { + self.set_share_screen(self.current_shares()); + } Task::none() } Message::NavigateNsecKeys => { @@ -1838,6 +1843,13 @@ impl App { Task::none() } Message::ConfirmDeleteNsecKey(hex) => { + if let Screen::NsecKeys(s) = &self.screen { + if s.delete_confirm.as_deref() != Some(hex.as_str()) { + return Task::none(); + } + } else { + return Task::none(); + } if self.active_share_hex.as_deref() == Some(hex.as_str()) { self.handle_disconnect_relay(); self.stop_bunker(); diff --git a/keep-desktop/src/message.rs b/keep-desktop/src/message.rs index e5041113..efea3e29 100644 --- a/keep-desktop/src/message.rs +++ b/keep-desktop/src/message.rs @@ -8,6 +8,7 @@ use zeroize::Zeroizing; use crate::screen::shares::ShareEntry; use crate::screen::signing_audit::AuditDisplayEntry; +use crate::screen::truncate_npub; use crate::screen::wallet::{DescriptorProgress, WalletEntry}; #[derive(Clone, Debug, PartialEq)] @@ -30,14 +31,7 @@ pub struct Identity { impl Identity { pub fn truncated_npub(&self) -> String { - if !self.npub.is_ascii() || self.npub.len() <= 20 { - return self.npub.clone(); - } - format!( - "{}...{}", - &self.npub[..12], - &self.npub[self.npub.len() - 6..] - ) + truncate_npub(&self.npub) } } diff --git a/keep-desktop/src/screen/export_ncryptsec.rs b/keep-desktop/src/screen/export_ncryptsec.rs index 11e1b89e..7c9758dc 100644 --- a/keep-desktop/src/screen/export_ncryptsec.rs +++ b/keep-desktop/src/screen/export_ncryptsec.rs @@ -5,6 +5,7 @@ use iced::widget::{button, column, container, qr_code, row, text, text_input, Sp use iced::{Alignment, Element, Length}; use zeroize::Zeroizing; +use super::truncate_npub; use crate::app::MIN_EXPORT_PASSPHRASE_LEN; use crate::message::Message; use crate::theme; @@ -85,15 +86,7 @@ impl ExportNcryptsecScreen { .size(theme::size::HEADING) .color(theme::color::TEXT); - let truncated_npub = if self.npub.len() > 20 { - format!( - "{}...{}", - &self.npub[..12], - &self.npub[self.npub.len() - 6..] - ) - } else { - self.npub.clone() - }; + let truncated_npub = truncate_npub(&self.npub); let info = text(format!("nsec | {truncated_npub}")) .size(theme::size::SMALL) diff --git a/keep-desktop/src/screen/mod.rs b/keep-desktop/src/screen/mod.rs index 7c553b3c..90a5c71c 100644 --- a/keep-desktop/src/screen/mod.rs +++ b/keep-desktop/src/screen/mod.rs @@ -16,9 +16,24 @@ pub mod signing_audit; pub mod unlock; pub mod wallet; +use chrono::{DateTime, Utc}; + use crate::message::Message; use layout::{NavItem, SidebarState}; +pub fn format_timestamp(ts: i64) -> String { + DateTime::::from_timestamp(ts, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +pub fn truncate_npub(npub: &str) -> String { + if !npub.is_ascii() || npub.len() <= 20 { + return npub.to_owned(); + } + format!("{}...{}", &npub[..12], &npub[npub.len() - 6..]) +} + pub enum Screen { Unlock(unlock::UnlockScreen), ShareList(shares::ShareListScreen), diff --git a/keep-desktop/src/screen/nsec_keys.rs b/keep-desktop/src/screen/nsec_keys.rs index ab5ee92b..586efcd1 100644 --- a/keep-desktop/src/screen/nsec_keys.rs +++ b/keep-desktop/src/screen/nsec_keys.rs @@ -1,24 +1,18 @@ // SPDX-FileCopyrightText: © 2026 PrivKey LLC // SPDX-License-Identifier: AGPL-3.0-or-later -use chrono::{DateTime, Utc}; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Alignment, Element, Length}; use keep_core::keys::{bytes_to_npub, KeyRecord, KeyType}; +use super::{format_timestamp, truncate_npub}; use crate::message::Message; use crate::theme; -fn format_timestamp(ts: i64) -> String { - DateTime::::from_timestamp(ts, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) - .unwrap_or_else(|| ts.to_string()) -} - #[derive(Debug, Clone)] -#[allow(dead_code)] pub struct NsecKeyEntry { pub name: String, + #[allow(dead_code)] pub pubkey: [u8; 32], pub pubkey_hex: String, pub npub: String, @@ -44,14 +38,7 @@ impl NsecKeyEntry { } fn truncated_npub(&self) -> String { - if !self.npub.is_ascii() || self.npub.len() <= 20 { - return self.npub.clone(); - } - format!( - "{}...{}", - &self.npub[..12], - &self.npub[self.npub.len() - 6..] - ) + truncate_npub(&self.npub) } fn created_display(&self) -> String { @@ -169,7 +156,7 @@ impl NsecKeysScreen { if !is_active { let activate_btn = button(text("Activate").size(theme::size::SMALL)) - .on_press(Message::SetActiveShare(key.pubkey_hex.clone())) + .on_press(Message::SwitchIdentity(key.pubkey_hex.clone())) .style(theme::secondary_button) .padding([theme::space::XS, theme::space::MD]); diff --git a/keep-desktop/src/screen/shares.rs b/keep-desktop/src/screen/shares.rs index 43dc95df..4cb1648b 100644 --- a/keep-desktop/src/screen/shares.rs +++ b/keep-desktop/src/screen/shares.rs @@ -1,20 +1,14 @@ // SPDX-FileCopyrightText: © 2026 PrivKey LLC // SPDX-License-Identifier: AGPL-3.0-or-later -use chrono::{DateTime, Utc}; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Alignment, Element, Length}; use keep_core::keys::bytes_to_npub; +use super::{format_timestamp, truncate_npub}; use crate::message::{Message, ShareIdentity}; use crate::theme; -fn format_timestamp(ts: i64) -> String { - DateTime::::from_timestamp(ts, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) - .unwrap_or_else(|| ts.to_string()) -} - #[derive(Debug, Clone)] pub struct ShareEntry { pub name: String, @@ -51,14 +45,7 @@ impl ShareEntry { } pub fn truncated_npub(&self) -> String { - if !self.npub.is_ascii() || self.npub.len() <= 20 { - return self.npub.clone(); - } - format!( - "{}...{}", - &self.npub[..12], - &self.npub[self.npub.len() - 6..] - ) + truncate_npub(&self.npub) } fn last_used_display(&self) -> String { From 7c9a62e907806e301088301950bca8148005fcb3 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Thu, 26 Feb 2026 18:42:29 -0500 Subject: [PATCH 3/7] Fix nsec key deletion cleanup, import routing, and navigation --- keep-desktop/src/app.rs | 63 ++++++++++----------- keep-desktop/src/screen/export_ncryptsec.rs | 2 +- keep-desktop/src/screen/nsec_keys.rs | 1 - 3 files changed, 31 insertions(+), 35 deletions(-) diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index b9aa3416..366bce6f 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -888,14 +888,19 @@ impl App { } Task::none() } - Message::NavigateShares | Message::GoBack => { + Message::NavigateShares => { if matches!(self.screen, Screen::ShareList(_)) { return Task::none(); } - let go_to_nsec = matches!(self.screen, Screen::ExportNcryptsec(_)); self.stop_scanner(); self.copy_feedback_until = None; - if go_to_nsec { + self.set_share_screen(self.current_shares()); + Task::none() + } + Message::GoBack => { + self.stop_scanner(); + self.copy_feedback_until = None; + if matches!(self.screen, Screen::ExportNcryptsec(_)) { self.set_nsec_keys_screen(); } else { self.set_share_screen(self.current_shares()); @@ -1149,7 +1154,7 @@ impl App { Message::ImportNcryptsec => self.handle_import_ncryptsec(), Message::ImportResult(result) => self.handle_import_result(result), Message::ImportNsecResult(result) => self.handle_import_nsec_result(result), - Message::ImportNcryptsecResult(result) => self.handle_import_result(result), + Message::ImportNcryptsecResult(result) => self.handle_import_nsec_result(result), _ => Task::none(), } } @@ -1843,45 +1848,37 @@ impl App { Task::none() } Message::ConfirmDeleteNsecKey(hex) => { - if let Screen::NsecKeys(s) = &self.screen { - if s.delete_confirm.as_deref() != Some(hex.as_str()) { - return Task::none(); + let (pubkey, name) = match &self.screen { + Screen::NsecKeys(s) + if s.delete_confirm.as_deref() == Some(hex.as_str()) => + { + match s.keys.iter().find(|k| k.pubkey_hex == hex) { + Some(k) => (k.pubkey, k.name.clone()), + None => return Task::none(), + } } - } else { - return Task::none(); - } + _ => return Task::none(), + }; if self.active_share_hex.as_deref() == Some(hex.as_str()) { self.handle_disconnect_relay(); self.stop_bunker(); } - let Ok(bytes) = hex::decode(&hex) else { - return Task::none(); - }; - let Ok(pubkey_bytes) = <[u8; 32]>::try_from(bytes) else { - return Task::none(); - }; let delete_result = { let mut guard = lock_keep(&self.keep); - guard.as_mut().map(|keep| keep.delete_key(&pubkey_bytes)) + guard.as_mut().map(|keep| keep.delete_key(&pubkey)) }; match delete_result { Some(Ok(())) => { - let name = if let Screen::NsecKeys(s) = &self.screen { - s.keys - .iter() - .find(|k| k.pubkey_hex == hex) - .map(|k| k.name.clone()) - .unwrap_or_default() - } else { - String::new() - }; - let shares = self.current_shares(); - self.resolve_active_share(&shares); - self.refresh_identities(&shares); - self.set_nsec_keys_screen(); - if !name.is_empty() { - self.set_toast(format!("'{name}' deleted"), ToastKind::Success); - } + let _ = std::fs::remove_file(relay_config_path_for( + &self.keep_path, + &hex, + )); + let _ = std::fs::remove_file(bunker_relay_config_path_for( + &self.keep_path, + &hex, + )); + self.refresh_shares(); + self.set_toast(format!("'{name}' deleted"), ToastKind::Success); } Some(Err(e)) => { self.set_toast(friendly_err(e), ToastKind::Error); diff --git a/keep-desktop/src/screen/export_ncryptsec.rs b/keep-desktop/src/screen/export_ncryptsec.rs index 7c9758dc..e01704ac 100644 --- a/keep-desktop/src/screen/export_ncryptsec.rs +++ b/keep-desktop/src/screen/export_ncryptsec.rs @@ -115,7 +115,7 @@ impl ExportNcryptsecScreen { } let display = if ncryptsec.len() > 80 { - format!("{}...", &ncryptsec[..80]) + format!("{}...", ncryptsec.chars().take(80).collect::()) } else { ncryptsec.to_string() }; diff --git a/keep-desktop/src/screen/nsec_keys.rs b/keep-desktop/src/screen/nsec_keys.rs index 586efcd1..2b674155 100644 --- a/keep-desktop/src/screen/nsec_keys.rs +++ b/keep-desktop/src/screen/nsec_keys.rs @@ -12,7 +12,6 @@ use crate::theme; #[derive(Debug, Clone)] pub struct NsecKeyEntry { pub name: String, - #[allow(dead_code)] pub pubkey: [u8; 32], pub pubkey_hex: String, pub npub: String, From 6b0041b0fe06c7d6c6d5c56077ffb85907738998 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Thu, 26 Feb 2026 18:51:37 -0500 Subject: [PATCH 4/7] Fix cargo fmt formatting --- keep-desktop/src/app.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index 366bce6f..20979319 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -1849,9 +1849,7 @@ impl App { } Message::ConfirmDeleteNsecKey(hex) => { let (pubkey, name) = match &self.screen { - Screen::NsecKeys(s) - if s.delete_confirm.as_deref() == Some(hex.as_str()) => - { + Screen::NsecKeys(s) if s.delete_confirm.as_deref() == Some(hex.as_str()) => { match s.keys.iter().find(|k| k.pubkey_hex == hex) { Some(k) => (k.pubkey, k.name.clone()), None => return Task::none(), @@ -1869,10 +1867,7 @@ impl App { }; match delete_result { Some(Ok(())) => { - let _ = std::fs::remove_file(relay_config_path_for( - &self.keep_path, - &hex, - )); + let _ = std::fs::remove_file(relay_config_path_for(&self.keep_path, &hex)); let _ = std::fs::remove_file(bunker_relay_config_path_for( &self.keep_path, &hex, From 9bc46807bf48180a05ad636ebb1ef9c93359bcf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 20:40:44 -0500 Subject: [PATCH 5/7] Fix nsec delete_confirm reset on error and GoBack from Import --- keep-desktop/src/app.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index 20979319..d527d251 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -157,6 +157,7 @@ pub struct App { pub(crate) pin_mismatch_confirm: bool, pub(crate) bunker_cert_pin_failed: bool, pub(crate) active_coordinations: HashMap<[u8; 32], ActiveCoordination>, + import_return_to_nsec: bool, } pub(crate) fn lock_keep( @@ -564,6 +565,7 @@ impl App { tray_last_bunker: false, scanner_rx: None, active_coordinations: HashMap::new(), + import_return_to_nsec: false, settings, kill_switch, tray, @@ -878,6 +880,7 @@ impl App { Task::none() } Message::GoToImport => { + self.import_return_to_nsec = matches!(self.screen, Screen::NsecKeys(_)); self.screen = Screen::Import(ImportScreen::new()); Task::none() } @@ -902,6 +905,9 @@ impl App { self.copy_feedback_until = None; if matches!(self.screen, Screen::ExportNcryptsec(_)) { self.set_nsec_keys_screen(); + } else if matches!(self.screen, Screen::Import(_)) && self.import_return_to_nsec { + self.import_return_to_nsec = false; + self.set_nsec_keys_screen(); } else { self.set_share_screen(self.current_shares()); } @@ -1876,6 +1882,9 @@ impl App { self.set_toast(format!("'{name}' deleted"), ToastKind::Success); } Some(Err(e)) => { + if let Screen::NsecKeys(s) = &mut self.screen { + s.delete_confirm = None; + } self.set_toast(friendly_err(e), ToastKind::Error); } None => {} @@ -3345,6 +3354,7 @@ impl App { tray_last_bunker: false, scanner_rx: None, active_coordinations: HashMap::new(), + import_return_to_nsec: false, settings, kill_switch, tray: None, From 1746bd5157b416e5b0b02a9dcdc27238fbc2c7ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 20:48:09 -0500 Subject: [PATCH 6/7] Simplify nsec tab code and fix review findings --- keep-desktop/src/app.rs | 26 +++++++++++++------------- keep-desktop/src/screen/layout.rs | 10 ++-------- keep-desktop/src/screen/nsec_keys.rs | 8 +------- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index d527d251..6970a0d6 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -903,9 +903,9 @@ impl App { Message::GoBack => { self.stop_scanner(); self.copy_feedback_until = None; - if matches!(self.screen, Screen::ExportNcryptsec(_)) { - self.set_nsec_keys_screen(); - } else if matches!(self.screen, Screen::Import(_)) && self.import_return_to_nsec { + let return_to_nsec = matches!(self.screen, Screen::ExportNcryptsec(_)) + || (matches!(self.screen, Screen::Import(_)) && self.import_return_to_nsec); + if return_to_nsec { self.import_return_to_nsec = false; self.set_nsec_keys_screen(); } else { @@ -1804,8 +1804,7 @@ impl App { let shares = self.current_shares(); self.resolve_active_share(&shares); self.refresh_identities(&shares); - let is_nsec_screen = matches!(self.screen, Screen::NsecKeys(_)); - if is_nsec_screen { + if matches!(self.screen, Screen::NsecKeys(_)) { self.set_nsec_keys_screen(); } else if let Screen::ShareList(s) = &mut self.screen { s.shares = shares; @@ -1854,15 +1853,16 @@ impl App { Task::none() } Message::ConfirmDeleteNsecKey(hex) => { - let (pubkey, name) = match &self.screen { - Screen::NsecKeys(s) if s.delete_confirm.as_deref() == Some(hex.as_str()) => { - match s.keys.iter().find(|k| k.pubkey_hex == hex) { - Some(k) => (k.pubkey, k.name.clone()), - None => return Task::none(), - } - } - _ => return Task::none(), + let Screen::NsecKeys(s) = &self.screen else { + return Task::none(); + }; + if s.delete_confirm.as_deref() != Some(hex.as_str()) { + return Task::none(); + } + let Some(key) = s.keys.iter().find(|k| k.pubkey_hex == hex) else { + return Task::none(); }; + let (pubkey, name) = (key.pubkey, key.name.clone()); if self.active_share_hex.as_deref() == Some(hex.as_str()) { self.handle_disconnect_relay(); self.stop_bunker(); diff --git a/keep-desktop/src/screen/layout.rs b/keep-desktop/src/screen/layout.rs index eed1db4e..cb30923a 100644 --- a/keep-desktop/src/screen/layout.rs +++ b/keep-desktop/src/screen/layout.rs @@ -68,14 +68,8 @@ pub fn with_sidebar<'a>( } else { NavBadge::None }; - let share_badge = match share_count { - Some(n) => NavBadge::Count(n), - None => NavBadge::None, - }; - let nsec_badge = match nsec_count { - Some(n) => NavBadge::Count(n), - None => NavBadge::None, - }; + let share_badge = share_count.map_or(NavBadge::None, NavBadge::Count); + let nsec_badge = nsec_count.map_or(NavBadge::None, NavBadge::Count); let nav_items: Vec<(&str, Message, NavItem, NavBadge)> = vec![ ( diff --git a/keep-desktop/src/screen/nsec_keys.rs b/keep-desktop/src/screen/nsec_keys.rs index 2b674155..83222812 100644 --- a/keep-desktop/src/screen/nsec_keys.rs +++ b/keep-desktop/src/screen/nsec_keys.rs @@ -36,10 +36,6 @@ impl NsecKeyEntry { }) } - fn truncated_npub(&self) -> String { - truncate_npub(&self.npub) - } - fn created_display(&self) -> String { format_timestamp(self.created_at) } @@ -135,9 +131,7 @@ impl NsecKeysScreen { fn key_card<'a>(&self, i: usize, key: &NsecKeyEntry) -> Element<'a, Message> { let is_active = self.active_key_hex.as_deref() == Some(&key.pubkey_hex); - let truncated_npub = key.truncated_npub(); - - let npub_text = text(truncated_npub) + let npub_text = text(truncate_npub(&key.npub)) .size(theme::size::SMALL) .color(theme::color::TEXT_MUTED); From cbb18b490293d73ad8eb8d3c7684ff79f1deeaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 21:01:17 -0500 Subject: [PATCH 7/7] Fix nsec delete None arm, persistent badge counts, and import identity resolution --- keep-desktop/src/app.rs | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index 6970a0d6..7475c775 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -158,6 +158,8 @@ pub struct App { pub(crate) bunker_cert_pin_failed: bool, pub(crate) active_coordinations: HashMap<[u8; 32], ActiveCoordination>, import_return_to_nsec: bool, + cached_share_count: usize, + cached_nsec_count: usize, } pub(crate) fn lock_keep( @@ -566,6 +568,8 @@ impl App { scanner_rx: None, active_coordinations: HashMap::new(), import_return_to_nsec: false, + cached_share_count: 0, + cached_nsec_count: 0, settings, kill_switch, tray, @@ -1623,13 +1627,15 @@ impl App { switcher_open: self.identity_switcher_open, delete_confirm: self.delete_identity_confirm.as_deref(), }; - let share_count = match &self.screen { - Screen::ShareList(s) if !s.shares.is_empty() => Some(s.shares.len()), - _ => None, + let share_count = if self.cached_share_count > 0 { + Some(self.cached_share_count) + } else { + None }; - let nsec_count = match &self.screen { - Screen::NsecKeys(s) if !s.keys.is_empty() => Some(s.keys.len()), - _ => None, + let nsec_count = if self.cached_nsec_count > 0 { + Some(self.cached_nsec_count) + } else { + None }; let screen = self.screen.view( &sidebar_state, @@ -1774,6 +1780,8 @@ impl App { self.active_share_hex = None; self.active_coordinations.clear(); self.identities.clear(); + self.cached_share_count = 0; + self.cached_nsec_count = 0; self.identity_switcher_open = false; self.delete_identity_confirm = None; self.toast = None; @@ -1802,6 +1810,8 @@ impl App { fn refresh_shares(&mut self) { let shares = self.current_shares(); + self.cached_share_count = shares.len(); + self.cached_nsec_count = self.current_nsec_keys().len(); self.resolve_active_share(&shares); self.refresh_identities(&shares); if matches!(self.screen, Screen::NsecKeys(_)) { @@ -1814,6 +1824,8 @@ impl App { } fn set_share_screen(&mut self, shares: Vec) { + self.cached_share_count = shares.len(); + self.cached_nsec_count = self.current_nsec_keys().len(); self.resolve_active_share(&shares); self.refresh_identities(&shares); self.screen = @@ -1834,6 +1846,7 @@ impl App { fn set_nsec_keys_screen(&mut self) { let keys = self.current_nsec_keys(); + self.cached_nsec_count = keys.len(); self.screen = Screen::NsecKeys(NsecKeysScreen::new(keys, self.active_share_hex.clone())); } @@ -1887,7 +1900,12 @@ impl App { } self.set_toast(friendly_err(e), ToastKind::Error); } - None => {} + None => { + if let Screen::NsecKeys(s) = &mut self.screen { + s.delete_confirm = None; + } + self.set_toast("Vault locked or unavailable".into(), ToastKind::Error); + } } Task::none() } @@ -2356,7 +2374,7 @@ impl App { ) -> Task { match result { Ok((shares, name)) => { - self.resolve_active_share(&shares); + self.cached_share_count = shares.len(); self.refresh_identities(&shares); self.set_nsec_keys_screen(); self.set_toast( @@ -3355,6 +3373,8 @@ impl App { scanner_rx: None, active_coordinations: HashMap::new(), import_return_to_nsec: false, + cached_share_count: 0, + cached_nsec_count: 0, settings, kill_switch, tray: None,