Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 25 additions & 42 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
10 changes: 4 additions & 6 deletions src/codex32_gui/wallet_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"parse_creation_date",
"parse_fingerprint",
"relock",
"require_unlocked",
"unlock",
"unlocked",
"verify",
"version_text",
]
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions src/codex32_gui/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import contextlib
import threading
from collections.abc import Callable

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions tests/test_gui_wallet_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading