From 26d3d417900998d39c9b6ce33f2f4bbd6b44cc8f Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:42:39 -0400 Subject: [PATCH] Revert "goblin: remove the Import identity option from onboarding" This reverts commit b1baa14b85935973f858f8cab60fb07a50cd7fde. --- locales/de.yml | 5 + locales/en.yml | 5 + locales/es.yml | 5 + locales/fr.yml | 5 + locales/ko.yml | 5 + locales/ru.yml | 5 + locales/tr.yml | 5 + locales/zh-CN.yml | 5 + src/gui/views/goblin/onboarding.rs | 276 ++++++++++++++++++++++++++++- 9 files changed, 315 insertions(+), 1 deletion(-) diff --git a/locales/de.yml b/locales/de.yml index cb3b5df3..838c62a7 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -820,6 +820,8 @@ goblin: title: "Richte dein Wallet ein" create_new: "Neu erstellen" restore_from_seed: "Aus Seed wiederherstellen" + import_identity: "Identität importieren" + import_identity_hint: "Nach diesem Schritt bringst du deinen vorhandenen Schlüssel mit (eine .backup-Datei oder nsec)." name_hint: "Wallet-Name" password_hint: "Passwort" repeat_password_hint: "Passwort wiederholen" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet." open_wallet: "Mein Wallet öffnen" skip_for_now: "Vorerst überspringen" + import_existing: "Schon eine Goblin-Identität? Importieren" + import_title: "Identität importieren" + import_blurb: "Füge deinen nsec ein oder wähle eine .backup-Datei, um deinen vorhandenen Schlüssel und Benutzernamen zu behalten statt diesen neuen." errors: cant_open: "Wallet konnte nicht geöffnet werden: %{err}" cant_create: "Wallet konnte nicht erstellt werden: %{err}" diff --git a/locales/en.yml b/locales/en.yml index 7cb94508..b6f9d5d5 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -820,6 +820,8 @@ goblin: title: "Set up your wallet" create_new: "Create new" restore_from_seed: "Restore from seed" + import_identity: "Import identity" + import_identity_hint: "You'll bring in your existing key (a .backup file or nsec) after this step." name_hint: "Wallet name" password_hint: "Password" repeat_password_hint: "Repeat password" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet." open_wallet: "Open my wallet" skip_for_now: "Skip for now" + import_existing: "Already have a Goblin identity? Import it" + import_title: "Import your identity" + import_blurb: "Paste your nsec or pick a .backup file to keep your existing key and username instead of this new one." errors: cant_open: "Couldn't open the wallet: %{err}" cant_create: "Couldn't create the wallet: %{err}" diff --git a/locales/es.yml b/locales/es.yml index 4db6c0e6..276b58f7 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -820,6 +820,8 @@ goblin: title: "Configura tu billetera" create_new: "Crear nueva" restore_from_seed: "Restaurar desde la frase semilla" + import_identity: "Importar identidad" + import_identity_hint: "Traerás tu clave existente (un archivo .backup o nsec) después de este paso." name_hint: "Nombre de la billetera" password_hint: "Contraseña" repeat_password_hint: "Repite la contraseña" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "Tus amigos ya pueden pagarte por nombre. Todo listo — abre tu billetera." open_wallet: "Abrir mi billetera" skip_for_now: "Saltar por ahora" + import_existing: "¿Ya tienes una identidad de Goblin? Impórtala" + import_title: "Importa tu identidad" + import_blurb: "Pega tu nsec o elige un archivo .backup para conservar tu clave y nombre de usuario existentes en lugar de este nuevo." errors: cant_open: "No se pudo abrir la billetera: %{err}" cant_create: "No se pudo crear la billetera: %{err}" diff --git a/locales/fr.yml b/locales/fr.yml index 9e349b08..85e8e9d8 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -820,6 +820,8 @@ goblin: title: "Configurez votre portefeuille" create_new: "Créer un nouveau" restore_from_seed: "Restaurer depuis la phrase" + import_identity: "Importer une identité" + import_identity_hint: "Vous importerez votre clé existante (un fichier .backup ou un nsec) après cette étape." name_hint: "Nom du portefeuille" password_hint: "Mot de passe" repeat_password_hint: "Répéter le mot de passe" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille." open_wallet: "Ouvrir mon portefeuille" skip_for_now: "Passer pour l'instant" + import_existing: "Vous avez déjà une identité Goblin ? Importez-la" + import_title: "Importer votre identité" + import_blurb: "Collez votre nsec ou choisissez un fichier .backup pour conserver votre clé et votre nom existants au lieu de ce nouveau." errors: cant_open: "Impossible d'ouvrir le portefeuille : %{err}" cant_create: "Impossible de créer le portefeuille : %{err}" diff --git a/locales/ko.yml b/locales/ko.yml index 803e4708..05936bc4 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -820,6 +820,8 @@ goblin: title: "지갑을 설정하세요" create_new: "새로 만들기" restore_from_seed: "복구 문구로 복원" + import_identity: "신원 가져오기" + import_identity_hint: "이 단계 이후에 기존 키(.backup 파일 또는 nsec)를 가져오게 됩니다." name_hint: "지갑 이름" password_hint: "비밀번호" repeat_password_hint: "비밀번호 확인" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "이제 친구들이 이름으로 결제할 수 있습니다. 모든 준비가 끝났습니다 — 지갑을 열어 보세요." open_wallet: "내 지갑 열기" skip_for_now: "지금은 건너뛰기" + import_existing: "이미 Goblin 신원이 있나요? 가져오기" + import_title: "신원 가져오기" + import_blurb: "nsec을 붙여넣거나 .backup 파일을 선택해서, 새 신원 대신 기존 키와 사용자 이름을 유지하세요." errors: cant_open: "지갑을 열 수 없습니다: %{err}" cant_create: "지갑을 만들 수 없습니다: %{err}" diff --git a/locales/ru.yml b/locales/ru.yml index e69f9586..9e85683f 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -820,6 +820,8 @@ goblin: title: "Настройте кошелёк" create_new: "Создать новый" restore_from_seed: "Восстановить из seed" + import_identity: "Импорт личности" + import_identity_hint: "После этого шага вы добавите свой существующий ключ (файл .backup или nsec)." name_hint: "Имя кошелька" password_hint: "Пароль" repeat_password_hint: "Повторите пароль" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк." open_wallet: "Открыть кошелёк" skip_for_now: "Пропустить пока" + import_existing: "Уже есть личность Goblin? Импортируйте её" + import_title: "Импорт личности" + import_blurb: "Вставьте свой nsec или выберите файл .backup, чтобы сохранить существующий ключ и имя вместо нового." errors: cant_open: "Не удалось открыть кошелёк: %{err}" cant_create: "Не удалось создать кошелёк: %{err}" diff --git a/locales/tr.yml b/locales/tr.yml index 38ec35b9..3e5bc3ee 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -820,6 +820,8 @@ goblin: title: "Cüzdanını kur" create_new: "Yeni oluştur" restore_from_seed: "Tohumdan geri yükle" + import_identity: "Kimliği içe aktar" + import_identity_hint: "Bu adımdan sonra mevcut anahtarını (bir .backup dosyası veya nsec) getireceksin." name_hint: "Cüzdan adı" password_hint: "Parola" repeat_password_hint: "Parolayı tekrarla" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç." open_wallet: "Cüzdanımı aç" skip_for_now: "Şimdilik atla" + import_existing: "Zaten bir Goblin kimliğin var mı? İçe aktar" + import_title: "Kimliğini içe aktar" + import_blurb: "Bu yeni anahtar yerine mevcut anahtarını ve kullanıcı adını korumak için nsec'ini yapıştır veya bir .backup dosyası seç." errors: cant_open: "Cüzdan açılamadı: %{err}" cant_create: "Cüzdan oluşturulamadı: %{err}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index c4910088..c7170360 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -820,6 +820,8 @@ goblin: title: "设置你的钱包" create_new: "新建" restore_from_seed: "从助记词恢复" + import_identity: "导入身份" + import_identity_hint: "此步骤之后,你将导入现有密钥(.backup 文件或 nsec)。" name_hint: "钱包名称" password_hint: "密码" repeat_password_hint: "重复密码" @@ -864,6 +866,9 @@ goblin: claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。" open_wallet: "打开我的钱包" skip_for_now: "暂时跳过" + import_existing: "已有 Goblin 身份?导入它" + import_title: "导入你的身份" + import_blurb: "粘贴你的 nsec 或选择一个 .backup 文件,保留你现有的密钥和用户名,而不是这个新的。" errors: cant_open: "无法打开钱包:%{err}" cant_create: "无法创建钱包:%{err}" diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 4b32ce6c..2f5b2fcc 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -28,6 +28,7 @@ use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult}; use crate::gui::views::wallets::creation::MnemonicSetup; use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View}; use crate::node::Node; +use crate::nostr::NostrIdentity; use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize}; use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList}; @@ -57,6 +58,11 @@ pub struct OnboardingContent { ext_url: String, /// Wallet setup inputs. restore: bool, + /// The user chose "Import identity" on the wallet step: after the wallet is + /// created the identity step opens the import panel straight away (bringing + /// over an existing key from a `.backup` or nsec), instead of the fresh + /// random key. Composes with either seed choice above it. + want_import: bool, name: String, pass: String, pass2: String, @@ -70,10 +76,34 @@ pub struct OnboardingContent { wallet: Option, /// Optional username claim state (same machinery as Settings). claim: ClaimState, + /// Optional "import an existing identity" sub-flow, opened from the identity + /// step so a returning user can keep their old npub + username instead of the + /// freshly-generated random key. + import: Option, /// Moment the recovery phrase was copied, for the transient "Copied" check. words_copied: Option, } +/// Onboarding identity-import state. Reuses the wallet password the user just +/// set, so it only needs the backup file / nsec (and the backup's own password +/// when restoring a sealed `.backup`). +#[derive(Default)] +struct OnbImport { + /// 0 = form, 1 = working, 2 = error. + stage: u8, + /// Pasted nsec or the read-in contents of a `.backup` / identity JSON file. + nsec: String, + /// Password the backup was sealed under (blank for a bare nsec, or when it + /// matches this wallet's password). + backup_password: String, + /// Last import error, shown on stage 2. + error: String, + /// A native file pick is in flight (Android resolves the path asynchronously). + picking: bool, + /// Worker result: Ok(new npub) or Err(message). + result: std::sync::Arc>>>, +} + impl Default for OnboardingContent { fn default() -> Self { Self { @@ -83,6 +113,7 @@ impl Default for OnboardingContent { integrated: false, ext_url: "https://grincoin.org".to_string(), restore: false, + want_import: false, name: "Main wallet".to_string(), pass: String::new(), pass2: String::new(), @@ -91,6 +122,7 @@ impl Default for OnboardingContent { scan_modal: None, wallet: None, claim: ClaimState::default(), + import: None, words_copied: None, } } @@ -388,6 +420,37 @@ impl OnboardingContent { ui.add_space(10.0); } }); + ui.add_space(10.0); + + // First-class "Import identity" option, standing alongside Create/Restore: + // bring an existing key over (from a .backup file or a bare nsec). It + // composes with either seed choice above — the import panel itself opens + // on the identity step, once the wallet exists. + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(ui.available_width(), 44.0), + )), + |ui| { + if w::chip( + ui, + &t!("goblin.onboarding.wallet.import_identity"), + self.want_import, + ) + .clicked() + { + self.want_import = !self.want_import; + } + }, + ); + if self.want_import { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.onboarding.wallet.import_identity_hint")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + } ui.add_space(14.0); w::field_well(ui, |ui| { @@ -838,7 +901,17 @@ impl OnboardingContent { .nostr_service() .map(|s| s.identity.read().nip05.is_some()) .unwrap_or(false); - if !registered { + // Came in via the wallet step's "Import identity" button: open the import + // panel straight away (offers both a .backup file and a bare nsec), rather + // than the fresh-key claim card. + if self.want_import && self.import.is_none() { + self.import = Some(OnbImport::default()); + self.want_import = false; + } + if self.import.is_some() { + // Returning user is swapping the random key for their existing identity. + self.import_ui(ui, &wallet, cb); + } else if !registered { w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.label( @@ -916,6 +989,14 @@ impl OnboardingContent { } } }); + ui.add_space(10.0); + // Returning user? A centered, first-class "Import identity" button + // (was a left-aligned text link) restores their existing identity + // from a .backup file or a bare nsec instead of the fresh random key. + // The .backup-or-nsec choice lives behind it, in import_ui. + if w::big_action(ui, &t!("goblin.onboarding.wallet.import_identity"), true).clicked() { + self.import = Some(OnbImport::default()); + } ui.add_space(16.0); } else { // Claimed: show a clear success confirmation so the user knows the @@ -967,6 +1048,199 @@ impl OnboardingContent { None } + /// Onboarding identity-import sub-flow: paste an nsec or pick a `.backup` + /// file to swap the freshly-generated random key for the user's existing + /// identity (keeping their npub and any claimed username). Reuses the wallet + /// password the user just set; a sealed `.backup` may carry its own password. + fn import_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + // Poll the worker first, WITHOUT holding a borrow across the reset below. + if self.import.as_ref().map(|i| i.stage) == Some(1) { + let res = self.import.as_ref().unwrap().result.lock().unwrap().take(); + if let Some(res) = res { + match res { + // Identity replaced: drop the sub-flow; the identity card and the + // claim/success state re-render from the new service next frame. + Ok(_) => { + self.import = None; + return; + } + Err(e) => { + let imp = self.import.as_mut().unwrap(); + imp.error = e; + imp.stage = 2; + } + } + } + } + let wallet_pass = self.pass.clone(); + let imp = self.import.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match imp.stage { + 1 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.importing")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 2 => { + ui.label( + RichText::new(t!("goblin.settings.import_failed")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&imp.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new(t!("goblin.onboarding.identity.import_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.import_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + // Native ".backup file" picker. Desktop returns the path now; + // Android resolves it asynchronously (poll picked_file()). + if imp.picking { + if let Some(path) = cb.picked_file() { + imp.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(contents) => imp.nsec = contents.trim().to_string(), + Err(_) => { + imp.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file")) + .clicked() + { + imp.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => { + match std::fs::read_to_string(&path) { + Ok(contents) => imp.nsec = contents.trim().to_string(), + Err(_) => { + imp.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + // Empty string = Android async pick in flight. + Some(_) => imp.picking = true, + None => {} + } + } + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_import_nsec")) + .focus(false) + .hint_text(t!("goblin.settings.import_nsec_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut imp.nsec, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_import_bpw")) + .focus(false) + .hint_text(t!("goblin.settings.backup_password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut imp.backup_password, cb); + }); + if !imp.error.is_empty() { + ui.add_space(6.0); + ui.label( + RichText::new(&imp.error) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(10.0); + let pasted = imp.nsec.trim(); + // Only an nsec paste or a sealed .backup file — nothing else. + let armed = + pasted.starts_with("nsec1") || NostrIdentity::is_encrypted_backup(pasted); + 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() + { + close = true; + } + }, + ); + 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| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, &t!("goblin.settings.import_btn"), false) + .clicked() + { + imp.stage = 1; + let slot = imp.result.clone(); + let nsec = std::mem::take(&mut imp.nsec); + let bpw = std::mem::take(&mut imp.backup_password); + let bpw = if bpw.is_empty() { None } else { Some(bpw) }; + let wallet = wallet.clone(); + let pass = wallet_pass.clone(); + std::thread::spawn(move || { + let res = wallet.import_nostr_identity(nsec, pass, bpw); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + } + }); + if close { + self.import = None; + } + } + /// Recovery-phrase QR scan modal content. fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) { if let Some(content) = self.scan_modal.as_mut() {