From 2592eadc9fcbc474d13005f31416bba7d19c8b65 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:50:42 -0400 Subject: [PATCH] goblin: delete identity (double-gated) and unified import (Build 146) DELETE: each held identity row gains a trash affordance. Step 1 is a danger card naming the identity, stating the removal is PERMANENT and prominently reminding the user to back it up first (unrecoverable without its nsec or .backup); step 2 is the existing wallet-password modal, which executes Wallet::delete_nostr_identity. Delete drops the entry from the held-identity index, removes its on-disk encrypted file, and rebuilds the service without it, so its pubkey leaves the multi-pubkey gift-wrap subscription and its key leaves the unlocked in-memory set. The shared balance and all other identities are untouched. Edge cases: the LAST identity can never be deleted (the trash only renders with more than one held); deleting the ACTIVE identity promotes a survivor to active first; deleting the legacy primary (identity.json) promotes a survivor INTO identity.json so init_nostr's fallback and an older build's rollback anchor still resolve, never leaving a hole that would mint a fresh key. IMPORT: the add panel's "Import nsec" is now just "Import" and offers both a .backup file picker and a paste-an-nsec field. The .backup path reuses the same from_encrypted_backup + re-encrypt-under-this-wallet's-password flow as the existing identity import (name and history restored), through the same NIP-49 encrypted store; the pasted-nsec path is unchanged. New strings in all six locales; drift green. --- locales/de.yml | 9 +- locales/en.yml | 9 +- locales/fr.yml | 9 +- locales/ru.yml | 9 +- locales/tr.yml | 9 +- locales/zh-CN.yml | 9 +- src/gui/views/goblin/mod.rs | 242 ++++++++++++++++++++++++++++++++---- src/wallet/wallet.rs | 144 ++++++++++++++++++++- 8 files changed, 410 insertions(+), 30 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index 67834567..6cbcbb42 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -686,8 +686,15 @@ goblin: add: "Identität hinzufügen" add_title: "Eine Identität hinzufügen" generate: "Neu erzeugen" - import: "Nsec importieren" + import: "Importieren" nsec_hint: "Nsec einfügen" + choose_backup: "Eine .backup-Datei wählen" + backup_selected: "Backup-Datei ausgewählt" + delete_short: "Identität löschen" + delete_title: "%{name} löschen?" + delete_blurb: "Dies entfernt die Identität dauerhaft aus dieser Wallet. Bereits erhaltenes Geld bleibt in deinem Guthaben." + delete_backup_note: "Sichere sie zuerst — ohne ihren nsec oder ihre .backup-Datei ist sie nicht wiederherstellbar." + delete_confirm: "Löschen" add_confirm: "Hinzufügen" pass_prompt: "Dein Wallet-Passwort verschlüsselt und entsperrt jede Identität. Gib es ein, um fortzufahren." working: "Arbeite…" diff --git a/locales/en.yml b/locales/en.yml index 5b4f92e4..bd1560fd 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -686,8 +686,15 @@ goblin: add: "Add identity" add_title: "Add an identity" generate: "Generate new" - import: "Import nsec" + import: "Import" nsec_hint: "Paste an nsec" + choose_backup: "Choose a .backup file" + backup_selected: "Backup file selected" + delete_short: "Delete identity" + delete_title: "Delete %{name}?" + delete_blurb: "This permanently removes the identity from this wallet. Money already received stays in your balance." + delete_backup_note: "Back it up first — without its nsec or .backup file it cannot be recovered." + delete_confirm: "Delete" add_confirm: "Add" pass_prompt: "Your wallet password encrypts and unlocks each identity. Enter it to continue." working: "Working…" diff --git a/locales/fr.yml b/locales/fr.yml index 0561aa11..4dc42fbc 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -686,8 +686,15 @@ goblin: add: "Ajouter une identité" add_title: "Ajouter une identité" generate: "En générer une" - import: "Importer un nsec" + import: "Importer" nsec_hint: "Coller un nsec" + choose_backup: "Choisir un fichier .backup" + backup_selected: "Fichier de sauvegarde sélectionné" + delete_short: "Supprimer l'identité" + delete_title: "Supprimer %{name} ?" + delete_blurb: "Cela supprime définitivement l'identité de ce portefeuille. L'argent déjà reçu reste dans votre solde." + delete_backup_note: "Sauvegardez-la d'abord — sans son nsec ou son fichier .backup, elle est irrécupérable." + delete_confirm: "Supprimer" add_confirm: "Ajouter" pass_prompt: "Le mot de passe de votre portefeuille chiffre et déverrouille chaque identité. Saisissez-le pour continuer." working: "En cours…" diff --git a/locales/ru.yml b/locales/ru.yml index edfad1b6..dd711436 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -686,8 +686,15 @@ goblin: add: "Добавить личность" add_title: "Добавить личность" generate: "Создать новую" - import: "Импорт nsec" + import: "Импорт" nsec_hint: "Вставьте nsec" + choose_backup: "Выбрать файл .backup" + backup_selected: "Файл резервной копии выбран" + delete_short: "Удалить личность" + delete_title: "Удалить %{name}?" + delete_blurb: "Это навсегда удалит личность из этого кошелька. Уже полученные деньги останутся на балансе." + delete_backup_note: "Сначала сделайте резервную копию — без её nsec или файла .backup её невозможно восстановить." + delete_confirm: "Удалить" add_confirm: "Добавить" pass_prompt: "Пароль кошелька шифрует и разблокирует каждую личность. Введите его, чтобы продолжить." working: "Обработка…" diff --git a/locales/tr.yml b/locales/tr.yml index 645723bf..c4065201 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -686,8 +686,15 @@ goblin: add: "Kimlik ekle" add_title: "Bir kimlik ekle" generate: "Yeni oluştur" - import: "Nsec içe aktar" + import: "İçe aktar" nsec_hint: "Bir nsec yapıştır" + choose_backup: "Bir .backup dosyası seç" + backup_selected: "Yedek dosyası seçildi" + delete_short: "Kimliği sil" + delete_title: "%{name} silinsin mi?" + delete_blurb: "Bu, kimliği bu cüzdandan kalıcı olarak kaldırır. Zaten alınan para bakiyende kalır." + delete_backup_note: "Önce yedeğini al — nsec’i veya .backup dosyası olmadan geri getirilemez." + delete_confirm: "Sil" add_confirm: "Ekle" pass_prompt: "Cüzdan parolan her kimliği şifreler ve açar. Devam etmek için gir." working: "Çalışıyor…" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index dc2da4c9..e7107694 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -686,8 +686,15 @@ goblin: add: "添加身份" add_title: "添加一个身份" generate: "新建" - import: "导入 nsec" + import: "导入" nsec_hint: "粘贴 nsec" + choose_backup: "选择 .backup 文件" + backup_selected: "已选择备份文件" + delete_short: "删除身份" + delete_title: "删除 %{name}?" + delete_blurb: "这将从此钱包中永久移除该身份。已收到的钱仍保留在你的余额中。" + delete_backup_note: "请先备份——没有它的 nsec 或 .backup 文件将无法恢复。" + delete_confirm: "删除" add_confirm: "添加" pass_prompt: "钱包密码用于加密和解锁每个身份。输入以继续。" working: "处理中…" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index e271b51e..703a7887 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -321,11 +321,16 @@ impl Default for ImportState { /// mirroring the wallet-open password modal. const IDENTITY_PASS_MODAL: &str = "goblin_identity_pass_modal"; -/// A pending add the wallet-password modal is gating: generate a fresh key -/// (`None`) or import an nsec (`Some`). Switching no longer uses the modal (it is -/// instant and local), so this is the only action the modal encrypts for. +/// A password-gated identity action the modal executes. Switching no longer uses +/// the modal (it is instant and local); only these need the wallet password. #[derive(Clone)] -struct PendingAdd(Option); +enum PendingPassAction { + /// Add a held identity: generate a fresh key (`None`) or import an nsec / a + /// sealed .backup blob (`Some`). + Add(Option), + /// Permanently delete this held identity (pubkey hex). + Delete(String), +} /// Identity switcher page state: the add-identity sub-form and the wallet-password /// modal buffer (the encrypt step for ADDING an identity — switching is instant @@ -335,12 +340,19 @@ struct PendingAdd(Option); struct IdentitySwitchState { /// Add-identity sub-form is open. adding: bool, - /// Add mode is import-an-nsec (else generate a fresh one). + /// Add mode is import (else generate a fresh one). Import offers a .backup + /// file picker AND a paste-an-nsec field. import: bool, /// Pasted nsec when importing. nsec: String, - /// The add the password modal is currently gating. - pending: Option, + /// Contents of a selected .backup file (import path); wins over `nsec`. + backup_input: String, + /// A native .backup file pick is in flight (Android returns it asynchronously). + picking: bool, + /// A held identity awaiting the step-1 delete confirmation (pubkey hex). + confirm_delete: Option, + /// The password-gated action the modal is currently gating. + pending: Option, /// Password typed into the modal; cleared as soon as it is consumed. pass: String, /// The modal password didn't unlock — show the wrong-password line. @@ -4858,6 +4870,8 @@ impl GoblinWalletView { self.identity_switch.adding = false; self.identity_switch.import = false; self.identity_switch.nsec.clear(); + self.identity_switch.backup_input.clear(); + self.identity_switch.confirm_delete = None; } Err(e) => { self.identity_switch.error = e; @@ -4899,13 +4913,18 @@ impl GoblinWalletView { w::kicker(ui, &t!("goblin.identities.held")); ui.add_space(8.0); let busy = self.identity_switch.busy; + // A wallet must keep at least one identity, so delete is only offered + // when more than one is held. + let can_delete = identities.len() > 1 && !busy; let mut switch_to: Option = None; + let mut delete_target: Option = None; for id in &identities { // A claimed name (no leading @, per the project convention), else // the truncated npub. Never a placeholder word. let short = data::short_npub(&id.pubkey_hex); let title = id.name.clone().unwrap_or_else(|| short.clone()); let named = id.name.is_some(); + let mut delete_this = false; let row = w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { @@ -4941,24 +4960,50 @@ impl GoblinWalletView { .color(t.surface_text_dim), ); } + // A trash affordance to the LEFT of that indicator; its + // own tap target so it doesn't trigger the row switch. + if can_delete { + ui.add_space(8.0); + let (r, resp) = + ui.allocate_exact_size(Vec2::splat(28.0), Sense::click()); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + crate::gui::icons::TRASH, + FontId::new(16.0, fonts::regular()), + t.neg, + ); + if resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + delete_this = true; + } + } }); }) .response .rect }); + // Tap anywhere else on a non-active row = switch (skip when the + // trash was tapped, whose rect overlaps the row interact). if !id.active && !busy { let hit = ui.interact( row, egui::Id::new(("id_switch", id.pubkey_hex.as_str())), Sense::click(), ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() + if !delete_this + && hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() { switch_to = Some(id.pubkey_hex.clone()); } } + if delete_this { + delete_target = Some(id.pubkey_hex.clone()); + } ui.add_space(6.0); } @@ -4970,6 +5015,91 @@ impl GoblinWalletView { self.identity_switch.error = e; } } + // Tapping the trash opens the step-1 danger confirmation. + if let Some(target) = delete_target { + self.identity_switch.error.clear(); + self.identity_switch.confirm_delete = Some(target); + } + + // Step-1 delete confirmation: a clear danger card naming the + // identity, stating the delete is PERMANENT, and reminding the user + // to back it up first. Continuing opens the wallet-password modal + // (step 2), which actually executes the delete. + if let Some(target) = self.identity_switch.confirm_delete.clone() { + let display = identities + .iter() + .find(|i| i.pubkey_hex == target) + .map(|i| { + i.name + .clone() + .unwrap_or_else(|| data::short_npub(&i.pubkey_hex)) + }) + .unwrap_or_else(|| data::short_npub(&target)); + ui.add_space(8.0); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(t!("goblin.identities.delete_title", name => display)) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.identities.delete_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.identities.delete_backup_note")) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(10.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) + .clicked() + { + self.identity_switch.confirm_delete = None; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card_ink( + ui, + &t!("goblin.identities.delete_confirm"), + t.neg, + ) + .clicked() + { + // Step 2: the wallet-password modal executes it. + self.identity_switch.pass.clear(); + self.identity_switch.wrong_pass = false; + self.identity_switch.pending = + Some(PendingPassAction::Delete(target.clone())); + Modal::new(IDENTITY_PASS_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.identities.delete_short")) + .show(); + } + }, + ); + }); + }); + } ui.add_space(8.0); @@ -5041,6 +5171,55 @@ impl GoblinWalletView { }); if self.identity_switch.import { ui.add_space(8.0); + // (a) Select a .backup file. Desktop returns the path now; + // Android returns it asynchronously (poll picked_file()). + if self.identity_switch.picking { + if let Some(path) = cb.picked_file() { + self.identity_switch.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(c) => { + self.identity_switch.backup_input = + c.trim().to_string() + } + Err(_) => { + self.identity_switch.error = + t!("goblin.settings.backup_read_failed") + .to_string() + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + let file_label = if self.identity_switch.backup_input.is_empty() { + t!("goblin.identities.choose_backup").to_string() + } else { + t!("goblin.identities.backup_selected").to_string() + }; + if w::big_action_on_card(ui, &file_label).clicked() { + self.identity_switch.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => { + match std::fs::read_to_string(&path) { + Ok(c) => { + self.identity_switch.backup_input = + c.trim().to_string() + } + Err(_) => { + self.identity_switch.error = + t!("goblin.settings.backup_read_failed") + .to_string() + } + } + } + Some(_) => self.identity_switch.picking = true, + None => {} + } + } + ui.add_space(8.0); + // (b) Or paste an nsec. w::field_well(ui, |ui| { TextEdit::new(egui::Id::from("identity_add_nsec")) .focus(false) @@ -5053,11 +5232,11 @@ impl GoblinWalletView { } ui.add_space(10.0); let import = self.identity_switch.import; - // Generate is always ready; import needs a plausible nsec. The - // password is entered in the modal (opened on Add), not here. - let armed = (!import - || self.identity_switch.nsec.trim().starts_with("nsec1")) - && !self.identity_switch.busy; + // Generate is always ready; import needs either a selected + // .backup or a pasted nsec. The password is entered in the modal. + let has_import = !self.identity_switch.backup_input.trim().is_empty() + || self.identity_switch.nsec.trim().starts_with("nsec1"); + let armed = (!import || has_import) && !self.identity_switch.busy; ui.horizontal(|ui| { let half = (ui.available_width() - 10.0) / 2.0; ui.scope_builder( @@ -5072,6 +5251,7 @@ impl GoblinWalletView { self.identity_switch.adding = false; self.identity_switch.import = false; self.identity_switch.nsec.clear(); + self.identity_switch.backup_input.clear(); self.identity_switch.error.clear(); } }, @@ -5093,9 +5273,21 @@ impl GoblinWalletView { { // Open the password modal to encrypt+store the // new identity. It is added WITHOUT switching; - // the user activates it later via tap → modal. - let import_nsec = if import { - Some(self.identity_switch.nsec.trim().to_string()) + // the user activates it later by tapping it. The + // import blob is the selected .backup if any, + // else the pasted nsec. + let import_blob = if import { + let b = self.identity_switch.backup_input.trim(); + if b.is_empty() { + Some( + self.identity_switch + .nsec + .trim() + .to_string(), + ) + } else { + Some(b.to_string()) + } } else { None }; @@ -5103,7 +5295,7 @@ impl GoblinWalletView { self.identity_switch.pass.clear(); self.identity_switch.wrong_pass = false; self.identity_switch.pending = - Some(PendingAdd(import_nsec)); + Some(PendingPassAction::Add(import_blob)); Modal::new(IDENTITY_PASS_MODAL) .position(ModalPosition::CenterTop) .title(t!("goblin.identities.add_title")) @@ -5211,16 +5403,22 @@ impl GoblinWalletView { if go && !self.identity_switch.pass.is_empty() { if !wallet.verify_nostr_password(&self.identity_switch.pass) { self.identity_switch.wrong_pass = true; - } else if let Some(PendingAdd(import_nsec)) = self.identity_switch.pending.take() { + } else if let Some(action) = self.identity_switch.pending.take() { let password = std::mem::take(&mut self.identity_switch.pass); self.identity_switch.wrong_pass = false; self.identity_switch.error.clear(); self.identity_switch.busy = true; let slot = self.identity_switch.result.clone(); let w = wallet.clone(); - // Add only — never auto-switch into the new identity. std::thread::spawn(move || { - let r = w.add_nostr_identity(import_nsec, password); + let r = match action { + // Add only — never auto-switch into the new identity. + PendingPassAction::Add(import) => w.add_nostr_identity(import, password), + // Delete returns the surviving active npub for the result slot. + PendingPassAction::Delete(hex) => w + .delete_nostr_identity(hex, password) + .map(|_| String::new()), + }; *slot.lock().unwrap() = Some(r); }); Modal::close(); diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 08e72dee..4448469c 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -843,9 +843,34 @@ impl Wallet { .read() .unlock(&password) .map_err(|_| "Wrong password".to_string())?; + // The import blob may be a bare nsec OR a sealed GOBLIN-*.backup file + // (reusing the same open path as `import_nostr_identity`). Either becomes a + // held identity re-encrypted under THIS wallet's password. let (identity, _keys) = match import { - Some(nsec) => NostrIdentity::create_imported(nsec.trim(), &password) - .map_err(|_| "Invalid nsec".to_string())?, + Some(blob) => { + let blob = blob.trim(); + if NostrIdentity::is_encrypted_backup(blob) { + // A .backup: open it (same wallet password, since a backup made + // by this wallet is sealed under it), then re-encrypt under the + // wallet password and restore its name/history. + let (backup, keys) = NostrIdentity::from_encrypted_backup(blob, &password) + .map_err(|_| { + "Couldn't open the backup — it may have been made under a \ + different wallet password." + .to_string() + })?; + let mut ident = + NostrIdentity::from_unlocked_keys(&keys, &password, backup.source) + .map_err(|e| format!("re-encryption failed: {e}"))?; + ident.nip05 = backup.nip05.clone(); + ident.anonymous = backup.anonymous; + ident.prev_npubs = backup.prev_npubs.clone(); + (ident, keys) + } else { + NostrIdentity::create_imported(blob, &password) + .map_err(|_| "Invalid nsec".to_string())? + } + } None => NostrIdentity::create_random(&password) .map_err(|e| format!("key generation failed: {e}"))?, }; @@ -913,6 +938,121 @@ impl Wallet { Ok(new_npub) } + /// PERMANENTLY delete a held identity: drop it from the held-identity index, + /// delete its on-disk encrypted file, and rebuild the running service without + /// it (so its pubkey leaves the multi-pubkey gift-wrap subscription and its key + /// leaves the in-memory set). The one shared grin balance and every other + /// identity are untouched. Unrecoverable unless its nsec/.backup was saved. + /// + /// Edge cases: + /// - Refuses to delete the LAST identity (a wallet must keep >= 1). + /// - Deleting the ACTIVE identity first switches active to a survivor. + /// - Deleting the legacy primary (the `identity.json` entry, which `init_nostr` + /// falls back to and an older build opens) PROMOTES a survivor into + /// `identity.json` so that fallback still resolves — never leaving a hole + /// that would spawn a fresh identity on next open. + pub fn delete_nostr_identity( + &self, + target_hex: String, + password: String, + ) -> Result<(), String> { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + if !self.verify_nostr_password(&password) { + return Err("Wrong password".to_string()); + } + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + let mut index = HeldIdentities::load(&nostr_dir) + .ok_or_else(|| "identity index unavailable".to_string())?; + // (a) Never delete the only identity. + if index.len() <= 1 { + return Err("You must keep at least one identity".to_string()); + } + let entry = index + .entry(&target_hex) + .cloned() + .ok_or_else(|| "identity not held by this wallet".to_string())?; + // A survivor to take over the active pointer / primary role. + let survivor = index + .identities + .iter() + .find(|e| e.pubkey != target_hex) + .map(|e| e.pubkey.clone()) + .ok_or_else(|| "no other identity".to_string())?; + + // (c) Deleting the legacy primary: promote the survivor into identity.json + // so init_nostr's fallback/rollback anchor still resolves. Otherwise just + // delete the target's own file. + if entry.path == "identity.json" { + let survivor_entry = index + .entry(&survivor) + .cloned() + .ok_or_else(|| "survivor missing".to_string())?; + let survivor_id = survivor_entry + .load(&nostr_dir) + .ok_or_else(|| "survivor unreadable".to_string())?; + // Overwrite identity.json with the survivor (its old content = the + // deleted key is gone), and repoint the survivor entry to identity.json. + survivor_id + .save(&nostr_dir) + .map_err(|e| format!("promote failed: {e}"))?; + if survivor_entry.path != "identity.json" { + let old_abs = survivor_entry.abs_path(&nostr_dir); + for e in index.identities.iter_mut() { + if e.pubkey == survivor { + e.path = "identity.json".to_string(); + } + } + let _ = std::fs::remove_file(old_abs); + } + } else { + let _ = std::fs::remove_file(entry.abs_path(&nostr_dir)); + } + + // Remove the target from the index (entries + order); repoint active if it + // was the deleted one. + index.identities.retain(|e| e.pubkey != target_hex); + index.order.retain(|h| h != &target_hex); + if index.active == target_hex { + index.active = survivor.clone(); + } + index + .save(&nostr_dir) + .map_err(|e| format!("index save failed: {e}"))?; + + // (b) If the deleted identity was active, the survivor becomes active. + let active_hex = if svc.public_key().to_hex() == target_hex { + survivor + } else { + svc.public_key().to_hex() + }; + + // Rebuild the service WITHOUT the deleted identity: unlock_all_identities now + // excludes it, so it leaves both the subscription and the in-memory key set. + let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path())); + let store = NostrStore::new(config.get_nostr_db_path()); + let (recv, _) = self + .unlock_all_identities(&nostr_dir, &password) + .ok_or_else(|| "identity unlock failed".to_string())?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: deleted held identity {target_hex}"); + Ok(()) + } + /// Get keychain mask [`SecretKey`]. pub fn keychain_mask(&self) -> Option { let r_key = self.keychain_mask.read();