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.
This commit is contained in:
+8
-1
@@ -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…"
|
||||
|
||||
+8
-1
@@ -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…"
|
||||
|
||||
+8
-1
@@ -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…"
|
||||
|
||||
+8
-1
@@ -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: "Обработка…"
|
||||
|
||||
+8
-1
@@ -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…"
|
||||
|
||||
+8
-1
@@ -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: "处理中…"
|
||||
|
||||
+220
-22
@@ -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<String>);
|
||||
enum PendingPassAction {
|
||||
/// Add a held identity: generate a fresh key (`None`) or import an nsec / a
|
||||
/// sealed .backup blob (`Some`).
|
||||
Add(Option<String>),
|
||||
/// 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<String>);
|
||||
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<PendingAdd>,
|
||||
/// 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<String>,
|
||||
/// The password-gated action the modal is currently gating.
|
||||
pending: Option<PendingPassAction>,
|
||||
/// 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<String> = None;
|
||||
let mut delete_target: Option<String> = 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();
|
||||
|
||||
+142
-2
@@ -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<SecretKey> {
|
||||
let r_key = self.keychain_mask.read();
|
||||
|
||||
Reference in New Issue
Block a user