1
0
forked from GRIN/grim

goblin: lock wallet keeps relays alive behind an unlock screen

The Lock wallet button now calls wallet.lock() instead of wallet.close(),
so the wallet stays open and its nostr relays stay connected (payments keep
arriving and queue up) while the money seed is sealed. A full-surface unlock
screen replaces the whole wallet UI whenever is_locked() is set: it verifies
the typed password against the live nostr identity (no re-open) and calls
unlock() on success, or fully closes the wallet as a fallback. Because the
screen is an early return from GoblinWalletView::ui it covers every tab,
overlay, modal and pending deep link, and system/Android back is swallowed
while locked, so no in-app money action is reachable. lock() also stops the
Foreign API receive listener (its /v2/foreign receive_tx is a seed op); the
sync loop restarts it on unlock, guarded by !is_locked().

Adds goblin.lock.{title,subtitle,unlock,close_wallet} to all 10 locales.

(cherry picked from commit ee27be3020592d9c1228df9d0f23451920ca5035)
This commit is contained in:
2ro
2026-07-14 21:31:09 -04:00
parent b7ad5d669c
commit 6b7aaf5d4c
14 changed files with 699 additions and 6413 deletions
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "Gib dein Wallet-Passwort zum Bestätigen ein."
confirm_hold: "Halten zum Bestätigen"
encrypt_desc: "Eine verschlüsselte Bestellnachricht, die eine Zahlung zusichert."
lock:
title: "Wallet gesperrt"
subtitle: "Zahlungen kommen auch im gesperrten Zustand weiter an. Gib dein Passwort ein, um zu entsperren und dein Guthaben zu nutzen."
unlock: "Entsperren"
close_wallet: "Wallet schließen"
trusted_sites:
title: "Vertrauenswürdige Seiten"
intro: "Seiten, denen du in dieser Sitzung vertraut hast, können risikoarme Aktionen ohne Nachfrage signieren. Bei Geld wird immer gefragt. Beende jede Sitzung hier."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "Enter your wallet password to confirm."
confirm_hold: "Hold to confirm"
encrypt_desc: "An encrypted order message that commits to a payment."
lock:
title: "Wallet locked"
subtitle: "Payments keep arriving while locked. Enter your password to unlock and use your funds."
unlock: "Unlock"
close_wallet: "Close wallet"
trusted_sites:
title: "Trusted Sites"
intro: "Sites you trusted this session can sign low-risk actions without asking. Money always asks. End any session here."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "Ingresa tu contraseña de billetera para confirmar."
confirm_hold: "Mantén presionado para confirmar"
encrypt_desc: "Un mensaje de pedido cifrado que compromete un pago."
lock:
title: "Billetera bloqueada"
subtitle: "Los pagos siguen llegando mientras está bloqueada. Ingresa tu contraseña para desbloquear y usar tus fondos."
unlock: "Desbloquear"
close_wallet: "Cerrar billetera"
trusted_sites:
title: "Sitios de confianza"
intro: "Los sitios en los que confiaste esta sesión pueden firmar acciones de bajo riesgo sin preguntar. El dinero siempre pregunta. Termina cualquier sesión aquí."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "Saisissez le mot de passe de votre portefeuille pour confirmer."
confirm_hold: "Maintenez pour confirmer"
encrypt_desc: "Un message de commande chiffré qui engage un paiement."
lock:
title: "Portefeuille verrouillé"
subtitle: "Les paiements continuent d'arriver pendant le verrouillage. Saisissez votre mot de passe pour déverrouiller et utiliser vos fonds."
unlock: "Déverrouiller"
close_wallet: "Fermer le portefeuille"
trusted_sites:
title: "Sites de confiance"
intro: "Les sites auxquels vous avez fait confiance cette session peuvent signer des actions à faible risque sans demander. L'argent demande toujours. Terminez toute session ici."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "確認するにはウォレットのパスワードを入力してください。"
confirm_hold: "長押しで確認"
encrypt_desc: "支払いを確定する、暗号化された注文メッセージです。"
lock:
title: "ウォレットをロック中"
subtitle: "ロック中も支払いは届き続けます。パスワードを入力してロックを解除し、残高を使えるようにします。"
unlock: "ロック解除"
close_wallet: "ウォレットを閉じる"
trusted_sites:
title: "信頼済みサイト"
intro: "このセッションで信頼したサイトは、リスクの低い操作を確認なしで署名できます。お金が動く操作は必ず確認します。ここでいつでもセッションを終了できます。"
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "확인하려면 지갑 비밀번호를 입력하세요."
confirm_hold: "길게 눌러서 확인"
encrypt_desc: "결제를 확정하는 암호화된 주문 메시지입니다."
lock:
title: "지갑 잠김"
subtitle: "잠긴 동안에도 결제는 계속 도착합니다. 비밀번호를 입력해 잠금을 풀고 자금을 사용하세요."
unlock: "잠금 해제"
close_wallet: "지갑 닫기"
trusted_sites:
title: "신뢰하는 사이트"
intro: "이번 세션에 신뢰한 사이트는 확인 없이 위험도가 낮은 작업에 서명할 수 있습니다. 돈이 오갈 때는 항상 확인합니다. 여기에서 언제든 세션을 종료할 수 있습니다."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "Введите пароль кошелька для подтверждения."
confirm_hold: "Удерживайте для подтверждения"
encrypt_desc: "Зашифрованное сообщение заказа, фиксирующее платёж."
lock:
title: "Кошелёк заблокирован"
subtitle: "Платежи продолжают поступать, пока кошелёк заблокирован. Введите пароль, чтобы разблокировать и распоряжаться средствами."
unlock: "Разблокировать"
close_wallet: "Закрыть кошелёк"
trusted_sites:
title: "Доверенные сайты"
intro: "Сайты, которым вы доверились в этой сессии, могут подписывать действия с низким риском без запроса. Деньги всегда запрашивают подтверждение. Завершите любую сессию здесь."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "Onaylamak için cüzdan parolanızı girin."
confirm_hold: "Onaylamak için basılı tutun"
encrypt_desc: "Bir ödemeyi bağlayan şifreli sipariş mesajı."
lock:
title: "Cüzdan kilitli"
subtitle: "Kilitliyken ödemeler gelmeye devam eder. Kilidi açıp bakiyeni kullanmak için parolanı gir."
unlock: "Kilidi aç"
close_wallet: "Cüzdanı kapat"
trusted_sites:
title: "Güvenilen Siteler"
intro: "Bu oturumda güvendiğiniz siteler, düşük riskli eylemleri sormadan imzalayabilir. Para her zaman sorar. Herhangi bir oturumu buradan sonlandırın."
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "输入钱包密码以确认。"
confirm_hold: "长按以确认"
encrypt_desc: "一条锁定付款的加密订单消息。"
lock:
title: "钱包已锁定"
subtitle: "锁定期间付款仍会到账。输入密码即可解锁并使用资金。"
unlock: "解锁"
close_wallet: "关闭钱包"
trusted_sites:
title: "受信任网站"
intro: "本次会话中你信任的网站可在不询问的情况下签署低风险操作。资金操作始终询问。可在此结束任意会话。"
+5
View File
@@ -770,6 +770,11 @@ goblin:
pass_prompt: "輸入錢包密碼以確認。"
confirm_hold: "長按以確認"
encrypt_desc: "一條鎖定付款的加密訂單訊息。"
lock:
title: "錢包已鎖定"
subtitle: "鎖定期間付款仍會到帳。輸入密碼即可解鎖並使用資金。"
unlock: "解鎖"
close_wallet: "關閉錢包"
trusted_sites:
title: "受信任網站"
intro: "本次會話中你信任的網站可在不詢問的情況下籤署低風險操作。資金操作始終詢問。可在此結束任意會話。"
+127 -1
View File
@@ -546,7 +546,11 @@ impl GoblinWalletView {
)
.clicked()
{
wallet.close();
// Lock — NOT close. The wallet stays open and its nostr relays
// stay connected (payments keep arriving and queue up), but the
// whole surface is replaced by the unlock screen and the money
// seed is sealed until the password is re-entered.
wallet.lock();
}
ui.add_space(10.0);
if w::outlined_icon_action(
@@ -563,4 +567,126 @@ impl GoblinWalletView {
ui.add_space(20.0);
});
}
/// The money-path unlock screen, shown full-surface whenever the wallet is
/// locked (`wallet.is_locked()`). It is drawn as an early return from
/// [`GoblinWalletView::ui`], so it covers every tab, header, overlay and
/// pending deep link — no money action is reachable behind it. It offers
/// exactly two ways out: unlock in place (the relays stayed connected, so any
/// payments that queued while locked drain right after), or fully close the
/// wallet (the old lock-button behavior: stops the nostr service and seals
/// the seed to disk).
pub(super) fn lock_screen_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
ScrollArea::vertical()
.id_salt("goblin_lock_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.add_space(48.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(crate::gui::icons::LOCK_KEY)
.font(FontId::new(56.0, fonts::regular()))
.color(t.surface_text),
);
ui.add_space(16.0);
ui.label(
RichText::new(t!("goblin.lock.title"))
.size(22.0)
.color(Colors::title(false)),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.lock.subtitle"))
.size(14.0)
.color(Colors::gray()),
);
});
ui.add_space(24.0);
let mut field =
TextEdit::new(egui::Id::new("goblin_lock_pass")).password();
field.ui(ui, &mut self.lock_pass, cb);
// Wrong-password line, cleared the moment the field empties.
if self.lock_pass.is_empty() {
self.lock_wrong = false;
} else if self.lock_wrong {
ui.add_space(10.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.size(15.0)
.color(Colors::red()),
);
});
}
ui.add_space(18.0);
// Enter submits, matching the wallet-open password modal.
let mut unlock = ui.input(|i| i.key_pressed(egui::Key::Enter))
&& !self.lock_pass.is_empty();
ui.vertical_centered_justified(|ui| {
View::colored_text_button(
ui,
t!("goblin.lock.unlock").to_string(),
t.accent_ink,
t.accent,
|| {
unlock = true;
},
);
});
ui.add_space(10.0);
ui.vertical_centered_justified(|ui| {
View::button(
ui,
t!("goblin.lock.close_wallet"),
Colors::white_or_black(false),
|| {
self.lock_pass.clear();
self.lock_wrong = false;
wallet.close();
},
);
});
ui.add_space(24.0);
if unlock {
if self.lock_pass.is_empty()
|| !wallet.verify_nostr_password(&self.lock_pass)
{
self.lock_wrong = true;
} else {
// Verified WITHOUT re-opening — the wallet is still
// open, only the money path was sealed. Clearing the
// flag lets the service loop drain queued payments and
// the sync loop restart the Foreign API listener.
wallet.unlock();
self.lock_pass.clear();
self.lock_wrong = false;
}
}
});
});
});
}
}
+486 -6410
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -412,6 +412,19 @@ impl WalletsContent {
}
return false;
} else if self.showing_wallet() {
// While the money path is locked the unlock screen owns the whole
// surface: system back must not escape it — not to a sub-page, and
// not via the double-back deselect to the wallet switcher. Swallow it
// entirely so the only ways out stay the on-screen Unlock / Close.
if self
.wallets
.selected()
.as_ref()
.map(|w| w.is_locked())
.unwrap_or(false)
{
return false;
}
// Go back at stack; on Home with nothing open, back is a no-op.
// Leaving the wallet is intentional-only (switch/lock controls),
// never a back fallback to the chooser.
+23 -2
View File
@@ -1423,17 +1423,34 @@ impl Wallet {
/// relays stay connected so payments keep arriving, but no incoming slatepack
/// is received/finalized until [`Wallet::unlock`]. A no-op on a wallet that
/// is not open.
///
/// This also stops the local Foreign API receive listener: its `/v2/foreign`
/// `receive_tx` builds outputs with the keychain mask (a money-seed
/// operation), so it must not answer while locked. The sync loop restarts it
/// on unlock (its start is guarded by `!is_locked()`).
pub fn lock(&self) {
if self.is_open() {
self.locked.store(true, Ordering::Relaxed);
// Seal the Foreign API listener too — stop it now, immediately.
let running = { self.foreign_api_server.read().is_some() };
if running {
let mut w_api_server = self.foreign_api_server.write();
if let Some(server) = w_api_server.as_mut() {
server.0.stop();
}
*w_api_server = None;
}
}
}
/// Clear the money-path lock. The nostr service loop then drains any incoming
/// payments that arrived and were buffered while locked, completing them
/// normally.
/// normally, and the sync loop restarts the Foreign API receive listener.
/// Waking the sync thread makes that restart prompt rather than waiting out
/// the next sync interval.
pub fn unlock(&self) {
self.locked.store(false, Ordering::Relaxed);
self.sync();
}
/// Check if wallet is closing.
@@ -2787,7 +2804,11 @@ fn start_sync(wallet: Wallet) -> Thread {
sync_wallet_data(&wallet, false);
}
if wallet.is_open() && !wallet.is_closing() {
// The Foreign API receive listener performs money-seed operations
// (receive_tx builds outputs with the keychain mask), so it stays
// stopped while the wallet is locked; lock() stops any running
// instance and this guard keeps it from restarting until unlock.
if wallet.is_open() && !wallet.is_closing() && !wallet.is_locked() {
// Start Foreign API listener if not running.
let api_server_running = { wallet.foreign_api_server.read().is_some() };
if !api_server_running {