From ea568a9968a0749b2ca3a505dfbb92e9549f1319 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 10 Nov 2026 22:05:58 +0000 Subject: [PATCH] gui: Let a mistyped passphrase be typed again A wrong wallet passphrase ended on the "Stopped" page, whose buttons both dropped the seed: after a new backup, "Restore my wallet" meant entering every card again. The command line never loses the seed this way. Once cards exist, a failed Bitcoin Core step now goes back to the page that asked, with the error in a dialog, so the operator just tries again. A spinner page or home is never gone back to; those still stop. The unlock page shrinks to one passphrase field and one Unlock button, and fits without scrolling. An unlock done in Bitcoin Core's console is noticed by polling, as the command line waits, which replaces the "I unlocked it myself" button and the expander. Claude-Session: https://claude.ai/code/session_01N8jLK5fxFGLYhRkgPfn86t --- src/codex32_gui/pages.py | 67 ++++++++++++--------------------- src/codex32_gui/wallet_setup.py | 10 ++--- src/codex32_gui/work.py | 32 ++++++++++++++++ tests/test_gui_wallet_setup.py | 7 ++++ 4 files changed, 68 insertions(+), 48 deletions(-) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 6ed80813..95f008a9 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -277,6 +277,7 @@ def _working(view: Adw.NavigationView, title: str, message: str) -> Adw.Navigati spinner = Gtk.Spinner(halign=Gtk.Align.CENTER, width_request=32, height_request=32) spinner.start() page = _page(title, _column(spinner, _title(message)), can_pop=False) + page.working = True # What _then never goes back to. view.push(page) return page @@ -290,7 +291,17 @@ def _then[Result]( """Turn a worker result into the next page, the failure page, or a step of its own.""" def deliver(outcome: Result | Exception) -> None: + # Once cards exist, a failure returns to the page that asked, which still holds + # the seed, so a mistyped passphrase or a busy Bitcoin Core never costs the cards. + # Home holds no seed, and a page that is itself working cannot be done again. if isinstance(outcome, Exception): + back = view.get_previous_page(page) + if advice and back and view.get_previous_page(back) and not getattr(back, "working", False): + view.pop_to_page(back) + dialog = Adw.AlertDialog(heading="That did not work", body=str(outcome)) + dialog.add_response("again", "Try again") + dialog.present(view) + return _failure(view, outcome, advice) return following = follow(outcome) @@ -933,64 +944,36 @@ def _unlock_page( expected: bytes | None, restoring: bool = False, ) -> Adw.NavigationPage: - """Unlock one already encrypted wallet, or step aside and let Bitcoin Core do it.""" + """Take the passphrase here, or carry on by itself once the wallet is unlocked in Bitcoin Core.""" field = Adw.PasswordEntryRow(title="Wallet passphrase") group = Adw.PreferencesGroup() group.add(field) - manual = Adw.ExpanderRow( - title="I would rather unlock it in Bitcoin Core myself", - subtitle="Then codex32 never sees the passphrase, exactly as the command line works.", + hint = _note( + f"Or unlock it in Bitcoin Core: in Window \u25b8 Console, select {wallet.name} and type " + f'walletpassphrase "YOUR PASSPHRASE" {wallet_setup.UNLOCK_SECONDS}. codex32 carries on by itself.' ) - manual.add_row( - Adw.ActionRow( - title="In Bitcoin Core, open Window ▸ Console", - subtitle=( - f"Select the wallet {wallet.name}, then type: " - f'walletpassphrase "YOUR PASSPHRASE" {wallet_setup.UNLOCK_SECONDS}' - ), - subtitle_selectable=True, - use_markup=False, - ) - ) - group.add(manual) - - def check() -> None: - page = _working(view, "Bitcoin Core", "Checking whether the wallet is unlocked…") + hint.set_selectable(True) - def job() -> Record: - wallet_setup.require_unlocked(core, wallet.name) - return _record(core, secret, wallet.name, timestamp, expected) - - work.run( - view, - page, - job, - _then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE), - ) - - def go() -> None: - _import(view, core, secret, wallet.name, field.get_text(), timestamp, expected, restoring) + def go(passphrase: str) -> None: + _import(view, core, secret, wallet.name, passphrase, timestamp, expected, restoring) + field.connect("entry-activated", lambda _row: go(field.get_text())) content = _column( _title( - f"The wallet \u201c{wallet.name}\u201d is locked", + f"Unlock \u201c{wallet.name}\u201d", "Bitcoin Core needs its passphrase before your keys can be written into it.", ), group, - _note( - "Your passphrase goes straight to Bitcoin Core on standard input and nowhere else. It is " - "never saved, never written to a file, and never shown in the list of running programs. " - "codex32 locks the wallet again as soon as it has finished." - ), + hint, ) page = _page( "Unlock", content, - actions=_actions( - _button("I unlocked it myself", check), - _button("Unlock and finish", go, style="suggested-action"), - ), + actions=_actions(_button("Unlock", lambda: go(field.get_text()), style="suggested-action")), ) + # A refused passphrase brings the operator back here to type it again. + page.connect("showing", lambda _page: _empty(field)) + work.watch(view, page, lambda: wallet_setup.unlocked(core, wallet.name), lambda: go("")) _forget_when_gone(view, page, lambda: _empty(field)) return page diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index b8e540d6..1f6c01c7 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -50,8 +50,8 @@ "parse_creation_date", "parse_fingerprint", "relock", - "require_unlocked", "unlock", + "unlocked", "verify", "version_text", ] @@ -282,11 +282,9 @@ def unlock(core: BitcoinCore, name: str, passphrase: str) -> None: ) -def require_unlocked(core: BitcoinCore, name: str) -> None: - """Confirm the operator unlocked the wallet in Bitcoin Core themselves.""" - state = [item for item in eligible(core) if item.name == name] - if not state or state[0].locked: - raise BitcoinCoreError("That wallet is still locked in Bitcoin Core.") +def unlocked(core: BitcoinCore, name: str) -> bool: + """Report whether the operator has unlocked this still empty wallet in Bitcoin Core.""" + return core._target(name) == (True, False) # Encrypted, and no longer locked. def initialize( diff --git a/src/codex32_gui/work.py b/src/codex32_gui/work.py index 3bcfa947..7ba2beaa 100644 --- a/src/codex32_gui/work.py +++ b/src/codex32_gui/work.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import threading from collections.abc import Callable @@ -74,6 +75,37 @@ def deliver(outcome: Result | Exception) -> bool: threading.Thread(target=worker).start() +def watch( + view: Adw.NavigationView, page: Adw.NavigationPage, ready: Callable[[], bool], then: Callable[[], None] +) -> None: + """Ask `ready` off the main loop every two seconds while `page` is in front; once it says yes, `then`.""" + # This is how the command line waits for something done in Bitcoin Core's own + # window, so the operator never has to come back and say it is done. It goes on + # once only: should that fail, the page must not try it again unasked. + + def check() -> None: + found = False + with contextlib.suppress(BitcoinCoreError, OSError, ValueError, TypeError): + found = ready() + GLib.idle_add(arrive, found) + + def arrive(found: bool) -> bool: + if found and view.get_visible_page() is page and not _running: + then() + elif showing(view, page): + GLib.timeout_add_seconds(2, ask) + return False + + def ask() -> bool: + # One question at a time: the next is only scheduled once this one is answered. + if view.get_visible_page() is page and not _running: + threading.Thread(target=check, daemon=True).start() + return False + return showing(view, page) + + GLib.timeout_add_seconds(2, ask) + + def _busy[Result]( view: Adw.NavigationView, page: Adw.NavigationPage, diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index fcda8da9..024cd7ab 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -361,3 +361,10 @@ def test_the_chain_the_operator_chose_is_the_one_that_is_used( def test_the_version_is_reported_the_way_bitcoin_core_reports_it() -> None: assert wallet_setup.version_text(BitcoinCore("bitcoin-cli", "main", 320100)) == "32.1.0" + + +def test_an_unlock_done_in_bitcoin_core_is_noticed(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + assert not wallet_setup.unlocked(core, "fresh") + fake.wallets["fresh"].locked = False + assert wallet_setup.unlocked(core, "fresh")