From 7181c7b70e810bb5f96195db01fc8fe45a4d2d4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:48:20 +0000 Subject: [PATCH 1/7] gui: Ask for cards and a record before creating A tester made a 2-of-3 wallet through Bails without ever seeing the printable recovery card or wallet-verification record. The create flow went straight from choosing a layout to the first card, yet its last page and every restore depend on that record. Add a "Before you start" page between the layout choice and the first card. It names how many blank cards to get, explains what the wallet record is for, and opens either printable form with Gtk.FileLauncher. If a form does not open, the page shows where it is. Move both forms into the codex32_gui package so an installed GUI can find them, and update the links. Raise the GUI size budget from 2000 to 2050 lines for the new page. Refs BenWestgate/Bails#314 --- README.md | 4 +- docs/user/gui.md | 17 ++-- docs/user/guide.md | 7 +- pyproject.toml | 2 +- src/codex32_gui/__init__.py | 1 + .../codex32_gui/forms}/recovery-card.html | 0 .../forms}/wallet-verification-record.html | 0 src/codex32_gui/pages.py | 48 +++++++++- tests/test_gui_before_you_start.py | 87 +++++++++++++++++++ tests/test_gui_boundaries.py | 2 +- 10 files changed, 152 insertions(+), 16 deletions(-) rename {docs/user => src/codex32_gui/forms}/recovery-card.html (100%) rename {docs/user => src/codex32_gui/forms}/wallet-verification-record.html (100%) create mode 100644 tests/test_gui_before_you_start.py diff --git a/README.md b/README.md index ad5d83b..e015a65 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ codex32 strings. Printable forms: -- [codex32 recovery card](docs/user/recovery-card.html) -- [wallet-verification record](docs/user/wallet-verification-record.html) +- [codex32 recovery card](src/codex32_gui/forms/recovery-card.html) +- [wallet-verification record](src/codex32_gui/forms/wallet-verification-record.html) ## For developers and reviewers diff --git a/docs/user/gui.md b/docs/user/gui.md index 019ebb4..4af59fb 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -62,15 +62,21 @@ Choose how many cards you want. Three cards where any two recover the wallet is the recommended shape: one card can be lost, burned or stolen and your bitcoin is still safe, and one card on its own tells a finder nothing. +Next, **Before you start** asks you to have one blank recovery card per card, a +pen, and one wallet record ready. Its buttons open the printable +[recovery card](../../src/codex32_gui/forms/recovery-card.html) and +[wallet record](../../src/codex32_gui/forms/wallet-verification-record.html) +forms in your browser. Press **I have them ready** to see the first card. + Each card is shown once. Copy it onto paper with a pen, then type it back from the paper with the original off the screen. That catches a slip of the pen now rather than years from now. If a group does not match, the window says which one; correct that group and try again, as many times as you like. When every card is confirmed, the window shows the master fingerprint. Write it -on your [wallet record](wallet-verification-record.html), then press **I wrote it -down**. This is a new wallet ceremony, so there is no pre-existing fingerprint -or descriptor to authenticate against. +on your wallet record, then press **I wrote it down**. This is a new wallet +ceremony, so there is no pre-existing fingerprint or descriptor to authenticate +against. Next, choose the Bitcoin Core wallet that will hold the keys. Only empty wallets are offered, so no wallet you already use can be @@ -80,9 +86,8 @@ give it a name and a passphrase, and codex32 fills it in and locks it again. Forgetting that passphrase does not lose your bitcoin. Your cards still recover the seed. It protects the wallet on this computer. -Finally, copy the wallet details onto your -[wallet record](wallet-verification-record.html) and keep it apart from every -card. The window shows exactly the fields that record asks for. +Finally, copy the wallet details onto your wallet record and keep it apart from +every card. The window shows exactly the fields that record asks for. A card never contains **B**, **I**, **O** or **1**: those four are left out of the alphabet precisely because handwriting confuses them with 8, J, L and 0. If diff --git a/docs/user/guide.md b/docs/user/guide.md index 0ccdc21..fee2ba0 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -63,8 +63,11 @@ You will need: - Bitcoin Core 32 or newer, with its local RPC server enabled and `bitcoin-cli` available on `PATH`; - codex32 installed using the [README instructions](../../README.md#install); -- one blank [codex32 recovery card](recovery-card.html) per secret or share; and -- a separately stored [wallet-verification record](wallet-verification-record.html). +- one blank + [codex32 recovery card](../../src/codex32_gui/forms/recovery-card.html) + per secret or share; and +- a separately stored + [wallet-verification record](../../src/codex32_gui/forms/wallet-verification-record.html). Before running `create` for a real wallet, have your blank cards, a pen, and wallet record ready, and choose separate trusted places for shared cards. diff --git a/pyproject.toml b/pyproject.toml index 7aa1426..6ccc768 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dev = [ where = ["src"] [tool.setuptools.package-data] -codex32_gui = ["artwork/*.png", "artwork/LICENSE"] +codex32_gui = ["artwork/*.png", "artwork/LICENSE", "forms/*.html"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/codex32_gui/__init__.py b/src/codex32_gui/__init__.py index 676e5e3..9e14f11 100644 --- a/src/codex32_gui/__init__.py +++ b/src/codex32_gui/__init__.py @@ -20,6 +20,7 @@ __version__ = "1.0.0rc1" ARTWORK = files("codex32_gui").joinpath("artwork") +FORMS = files("codex32_gui").joinpath("forms") if find_spec("gi") is not None: import gi diff --git a/docs/user/recovery-card.html b/src/codex32_gui/forms/recovery-card.html similarity index 100% rename from docs/user/recovery-card.html rename to src/codex32_gui/forms/recovery-card.html diff --git a/docs/user/wallet-verification-record.html b/src/codex32_gui/forms/wallet-verification-record.html similarity index 100% rename from docs/user/wallet-verification-record.html rename to src/codex32_gui/forms/wallet-verification-record.html diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index a73de64..93a28a4 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Literal -from gi.repository import Adw, Gtk +from gi.repository import Adw, Gio, GLib, Gtk from codex32 import ( ConfirmationResult, @@ -28,7 +28,7 @@ ) from codex32.errors import CodexError from codex32.generation import ORDINARY_INDICES -from codex32_gui import ARTWORK, reading, wallet_setup, work +from codex32_gui import ARTWORK, FORMS, reading, wallet_setup, work from codex32_gui.entry import Codex32Entry from codex32_gui.wallet_setup import BitcoinCore @@ -603,13 +603,13 @@ def begin() -> None: chosen = list(details)[_selected(buttons)] if chosen != "Something else": threshold, count = next((t, c) for t, c, label in PRESETS if label == chosen) - _begin_cards(view, core, threshold, count, SEED_SIZES[0][0]) + view.push(_ready_page(view, core, threshold, count, SEED_SIZES[0][0])) return threshold, count = int(needed.get_value()), int(total.get_value()) if count < threshold: _failure(view, "A backup cannot need more cards than it has.") return - _begin_cards(view, core, threshold, count, SEED_SIZES[size.get_selected()][0]) + view.push(_ready_page(view, core, threshold, count, SEED_SIZES[size.get_selected()][0])) content = _column( _title( @@ -625,6 +625,46 @@ def begin() -> None: ) +def _ready_page( + view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int +) -> Adw.NavigationPage: + """Ask for the cards and the wallet record before any card is shown. + + The last page asks for the wallet record, so it is asked for here, while + there is still time to fetch or print one. + """ + cards = "one blank recovery card" if count == 1 else f"{count} blank recovery cards" + status = _note("Each form opens in your browser, where you can print it.") + + def show(name: str) -> None: + def opened(launcher: Gtk.FileLauncher, result: Gio.AsyncResult) -> None: + try: + launcher.launch_finish(result) + except GLib.Error: + _say(status, f"That form did not open. It is at {FORMS.joinpath(name)}", "warning") + + launcher = Gtk.FileLauncher(file=Gio.File.new_for_path(str(FORMS.joinpath(name)))) + launcher.launch(view.get_root(), None, opened) + + content = _column( + _title("Before you start", f"Have {cards}, a pen, and one wallet record ready."), + _note( + "The wallet record is a separate sheet for the master fingerprint and the other wallet " + "details shown at the end. It cannot spend your bitcoin, but it proves later that cards " + "you restore are this wallet. Keep it apart from every card." + ), + _button("Open the recovery card form", lambda: show("recovery-card.html")), + _button("Open the wallet record form", lambda: show("wallet-verification-record.html")), + status, + ) + begin = _button( + "I have them ready", + lambda: _begin_cards(view, core, threshold, count, byte_length), + style="suggested-action", + ) + return _page("New wallet", content, actions=_actions(begin)) + + def _begin_cards( view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int ) -> None: diff --git a/tests/test_gui_before_you_start.py b/tests/test_gui_before_you_start.py new file mode 100644 index 0000000..f932bf6 --- /dev/null +++ b/tests/test_gui_before_you_start.py @@ -0,0 +1,87 @@ +"""The create flow asks for blank cards and a wallet record before the first card.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +gi = pytest.importorskip("gi") +try: + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("Gdk", "4.0") + from gi.repository import Adw, Gdk, Gtk +except (ImportError, ValueError): + pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True) +if not Gtk.init_check() or Gdk.Display.get_default() is None: + pytest.skip("no display for GTK", allow_module_level=True) +Adw.init() + +from codex32_gui import FORMS, pages + + +def _widgets(root: Gtk.Widget, kind: type) -> Iterator[Gtk.Widget]: + child = root.get_first_child() + while child is not None: + if isinstance(child, kind): + yield child + yield from _widgets(child, kind) + child = child.get_next_sibling() + + +def _button(page: Gtk.Widget, label: str) -> Gtk.Button: + return next(button for button in _widgets(page, Gtk.Button) if button.get_label() == label) + + +def _texts(page: Gtk.Widget) -> str: + return " ".join(label.get_label() for label in _widgets(page, Gtk.Label)) + + +def test_a_layout_choice_leads_to_the_checklist_not_to_a_card(monkeypatch: pytest.MonkeyPatch) -> None: + began: list[tuple[Any, ...]] = [] + monkeypatch.setattr(pages, "_begin_cards", lambda *arguments: began.append(arguments)) + view = Adw.NavigationView() + core: Any = object() + view.push(pages._layout_page(view, core)) + + _button(view.get_visible_page(), "Continue").emit("clicked") + ready = view.get_visible_page() + assert began == [] + assert "Have 3 blank recovery cards, a pen, and one wallet record ready." in _texts(ready) + + _button(ready, "I have them ready").emit("clicked") + assert began == [(view, core, 2, 3, 16)] + + +def test_a_single_card_backup_asks_for_one_card() -> None: + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 0, 1, 16) # type: ignore[arg-type] + assert "Have one blank recovery card, a pen, and one wallet record ready." in _texts(page) + + +@pytest.mark.parametrize( + ("label", "name"), + [ + ("Open the recovery card form", "recovery-card.html"), + ("Open the wallet record form", "wallet-verification-record.html"), + ], +) +def test_each_button_opens_its_shipped_form(monkeypatch: pytest.MonkeyPatch, label: str, name: str) -> None: + opened: list[str] = [] + + class Launcher: + def __init__(self, file: Any) -> None: + self.path = file.get_path() + + def launch(self, _parent: object, _cancellable: object, _callback: object) -> None: + opened.append(self.path) + + monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher) + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] + + _button(page, label).emit("clicked") + assert opened == [str(FORMS.joinpath(name))] + assert FORMS.joinpath(name).is_file() diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 2687a49..3f16e54 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2000 +BUDGET = 2050 def _package() -> Path: From 4d53a4a4267eb2cc3b72b44d823737ce065c3cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:41:39 +0000 Subject: [PATCH 2/7] gui: Guard old GTK and document the form launch Gtk.FileLauncher only exists from GTK 4.10, and the install notes accept any GTK 4. On an older GTK the form buttons now say where the form is instead of raising AttributeError. The security model and the GUI claims said the window starts no process of its own. The form buttons ask the desktop to open a bundled blank form, so say so, and check that _ready_page is the only place that hands a file to the desktop. Refs BenWestgate/Bails#314 --- docs/developer/gui.md | 4 +++- docs/security/model.md | 6 +++++- src/codex32_gui/pages.py | 10 +++++++--- tests/test_gui_before_you_start.py | 9 +++++++++ tests/test_gui_boundaries.py | 14 ++++++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/developer/gui.md b/docs/developer/gui.md index c922ce7..f4caf43 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -43,7 +43,9 @@ about 250 lines, and the rest of the difference is user-facing wording in `hashlib`, or `hmac`. Entropy belongs to `CreationCeremony`. 2. **No network.** Nothing imports `socket`, `ssl`, `urllib`, or `http`, and no module imports `subprocess`. The only child process is the `bitcoin-cli` the - library already starts. + library already starts. Separately, `_ready_page` may ask the desktop to open + a bundled blank form with `Gtk.FileLauncher`; it is the only caller, and it + passes only paths under `codex32_gui/forms/`. 3. **Nothing reaches disk.** Nothing imports `os`, `pathlib`, `io`, `tempfile`, `shutil`, `pickle`, `sqlite3`, or `logging`, and nothing calls `open`. There is no settings file, no recent list, no log, and no clipboard write. diff --git a/docs/security/model.md b/docs/security/model.md index c14f9fd..7654327 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -278,7 +278,11 @@ fingerprint, the identifier result, and the warning before the operator chooses. The program draws no entropy, opens no socket, starts no process of its own, and writes no file: no settings, no recent list, no log, and no clipboard write of -recovery text. Entered recovery text is cleared when its screen is left, subject +recovery text. One button pair is the exception to "no process": **Before you +start** can ask the desktop, through GTK's `FileLauncher`, to open one of the two +blank printable forms shipped in `codex32_gui/forms/`. The desktop chooses and +starts the viewer. Only those fixed paths are passed, never recovery text, and +this happens before any seed is drawn. Entered recovery text is cleared when its screen is left, subject to the zeroization limitation above. Two disclosure channels belong to the toolkit rather than to this program, and diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 93a28a4..c8b2ffd 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -637,14 +637,18 @@ def _ready_page( status = _note("Each form opens in your browser, where you can print it.") def show(name: str) -> None: + path = str(FORMS.joinpath(name)) + def opened(launcher: Gtk.FileLauncher, result: Gio.AsyncResult) -> None: try: launcher.launch_finish(result) except GLib.Error: - _say(status, f"That form did not open. It is at {FORMS.joinpath(name)}", "warning") + _say(status, f"That form did not open. It is at {path}", "warning") - launcher = Gtk.FileLauncher(file=Gio.File.new_for_path(str(FORMS.joinpath(name)))) - launcher.launch(view.get_root(), None, opened) + if Gtk.check_version(4, 10, 0) is not None: # FileLauncher arrived in GTK 4.10. + _say(status, f"Open this form in a browser to print it: {path}", "warning") + return + Gtk.FileLauncher(file=Gio.File.new_for_path(path)).launch(view.get_root(), None, opened) content = _column( _title("Before you start", f"Have {cards}, a pen, and one wallet record ready."), diff --git a/tests/test_gui_before_you_start.py b/tests/test_gui_before_you_start.py index f932bf6..46f0501 100644 --- a/tests/test_gui_before_you_start.py +++ b/tests/test_gui_before_you_start.py @@ -85,3 +85,12 @@ def launch(self, _parent: object, _cancellable: object, _callback: object) -> No _button(page, label).emit("clicked") assert opened == [str(FORMS.joinpath(name))] assert FORMS.joinpath(name).is_file() + + +def test_an_old_gtk_shows_where_the_form_is(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: "GTK is older than 4.10") + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] + + _button(page, "Open the wallet record form").emit("clicked") + assert str(FORMS.joinpath("wallet-verification-record.html")) in _texts(page) diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 3f16e54..b71fa67 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -153,3 +153,17 @@ def test_restore_verifies_identity_before_creating_a_destination_wallet() -> Non create = job.body[1] assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call) assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create" + + +def test_only_the_checklist_hands_a_file_to_the_desktop() -> None: + """The one external launch opens a bundled blank form, before any seed exists.""" + tree = ast.parse((_package() / "pages.py").read_text()) + launching = { + function.name + for function in tree.body + if isinstance(function, ast.FunctionDef) + and any( + isinstance(node, ast.Attribute) and node.attr == "FileLauncher" for node in ast.walk(function) + ) + } + assert launching == {"_ready_page"} From b91c007b9f7195e9e65e9d3ceb306c8ae90be618 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:40:12 +0000 Subject: [PATCH 3/7] gui: Let a launcher name a readable forms folder On Tails, Tor Browser may only read ~/Tor Browser and its persistent twin, so it shows an error page for a form inside the Persistent Storage checkout. FileLauncher reports only that an application started, so the path fallback never appears. Read CODEX32_FORMS_DIR, if set, as the folder holding the forms. Bails can copy them under ~/Tor Browser before starting the GUI and set it. Refs BenWestgate/Bails#314 --- docs/developer/gui.md | 5 +++-- docs/security/model.md | 5 +++-- src/codex32_gui/pages.py | 5 ++++- tests/test_gui_before_you_start.py | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/developer/gui.md b/docs/developer/gui.md index f4caf43..e31f661 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -44,8 +44,9 @@ about 250 lines, and the rest of the difference is user-facing wording in 2. **No network.** Nothing imports `socket`, `ssl`, `urllib`, or `http`, and no module imports `subprocess`. The only child process is the `bitcoin-cli` the library already starts. Separately, `_ready_page` may ask the desktop to open - a bundled blank form with `Gtk.FileLauncher`; it is the only caller, and it - passes only paths under `codex32_gui/forms/`. + a bundled blank form with `Gtk.FileLauncher`; it is the only caller. It + passes a path under `codex32_gui/forms/`, or under `CODEX32_FORMS_DIR` when a + launcher has copied the forms where a confined browser can read them. 3. **Nothing reaches disk.** Nothing imports `os`, `pathlib`, `io`, `tempfile`, `shutil`, `pickle`, `sqlite3`, or `logging`, and nothing calls `open`. There is no settings file, no recent list, no log, and no clipboard write. diff --git a/docs/security/model.md b/docs/security/model.md index 7654327..4b11d95 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -281,8 +281,9 @@ writes no file: no settings, no recent list, no log, and no clipboard write of recovery text. One button pair is the exception to "no process": **Before you start** can ask the desktop, through GTK's `FileLauncher`, to open one of the two blank printable forms shipped in `codex32_gui/forms/`. The desktop chooses and -starts the viewer. Only those fixed paths are passed, never recovery text, and -this happens before any seed is drawn. Entered recovery text is cleared when its screen is left, subject +starts the viewer. Only those forms are passed, never recovery text, and this +happens before any seed is drawn. A launcher may set `CODEX32_FORMS_DIR` to a +copy of the forms that a confined browser can read; Bails does this on Tails. Entered recovery text is cleared when its screen is left, subject to the zeroization limitation above. Two disclosure channels belong to the toolkit rather than to this program, and diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index c8b2ffd..36e7fd6 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -637,7 +637,10 @@ def _ready_page( status = _note("Each form opens in your browser, where you can print it.") def show(name: str) -> None: - path = str(FORMS.joinpath(name)) + # A confined browser (Tor Browser on Tails) may not read the package, so a + # launcher can copy the forms somewhere it can and name that folder here. + folder = GLib.getenv("CODEX32_FORMS_DIR") + path = f"{folder}/{name}" if folder else str(FORMS.joinpath(name)) def opened(launcher: Gtk.FileLauncher, result: Gio.AsyncResult) -> None: try: diff --git a/tests/test_gui_before_you_start.py b/tests/test_gui_before_you_start.py index 46f0501..b664299 100644 --- a/tests/test_gui_before_you_start.py +++ b/tests/test_gui_before_you_start.py @@ -94,3 +94,22 @@ def test_an_old_gtk_shows_where_the_form_is(monkeypatch: pytest.MonkeyPatch) -> _button(page, "Open the wallet record form").emit("clicked") assert str(FORMS.joinpath("wallet-verification-record.html")) in _texts(page) + + +def test_a_launcher_can_point_the_buttons_at_a_readable_copy(monkeypatch: pytest.MonkeyPatch) -> None: + opened: list[str] = [] + + class Launcher: + def __init__(self, file: Any) -> None: + opened.append(file.get_path()) + + def launch(self, _parent: object, _cancellable: object, _callback: object) -> None: + pass + + monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher) + monkeypatch.setenv("CODEX32_FORMS_DIR", "/home/amnesia/Tor Browser/codex32 forms") + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] + + _button(page, "Open the recovery card form").emit("clicked") + assert opened == ["/home/amnesia/Tor Browser/codex32 forms/recovery-card.html"] From c1863df7608f1e31df2b91bedce5f0daa476ca1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:42:08 +0000 Subject: [PATCH 4/7] gui: Ask for marked look-alikes on cards Three testers misread their own cards: 5 and S, 6 and G, and 0 against the letter O. 5, S, 6, G, 2 and Z are all in the bech32 alphabet, so read-back cannot tell them apart the way it flags O, B, I and 1. Show a handwriting key on the page where each card is written, and repeat it on the printable recovery card: slash 0, cross 7 and Z, draw S like $ (the Codex32 book's marks) and dot the loop of 6, which the book leaves open. Refs BenWestgate/Bails#314 --- docs/user/gui.md | 5 +++++ src/codex32_gui/forms/recovery-card.html | 3 ++- src/codex32_gui/pages.py | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/user/gui.md b/docs/user/gui.md index 4af59fb..22cb285 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -94,6 +94,11 @@ the alphabet precisely because handwriting confuses them with 8, J, L and 0. If you type one, the window says so and names what the card probably says, rather than quietly swallowing it. +Some characters that are left in still look alike in handwriting: 5 and S, 6 +and G, 2 and Z. While you write, the window asks you to mark them: slash every +0, cross 7 and Z, draw S with a line through it like $, and put a dot inside the +loop of 6. The recovery card form repeats this key. + ## A card that is damaged Type what you can still read, and `?` for each character you cannot make out. diff --git a/src/codex32_gui/forms/recovery-card.html b/src/codex32_gui/forms/recovery-card.html index 361e153..7194140 100644 --- a/src/codex32_gui/forms/recovery-card.html +++ b/src/codex32_gui/forms/recovery-card.html @@ -47,7 +47,8 @@

Protected codex32 text — copy exactly in four-character groups

-

Leave unused boxes blank.

+

Leave unused boxes blank. Mark the look-alikes: slash every 0, cross 7 and Z, + draw S with a line through it like $, and put a dot inside the loop of 6.

Offline recovery

    diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 36e7fd6..1220590 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -46,6 +46,10 @@ (0, 1, "One card"), ) CREATE_WALLET = "Create a new wallet" +HANDWRITING = ( + "Mark the look-alikes as you write: slash every 0, cross 7 and Z, draw S with a line through it " + "like $, and put a dot inside the loop of 6. Then 5 and S, 6 and G, and 2 and Z stay apart." +) NO_CAMERA = ( "Do not photograph this and do not type it into any website, chat or password manager. " "Paper and pen only." @@ -456,6 +460,7 @@ def _write_page( content = _column( _title("Write it down", where), _note("Use pen on a card you can keep dry. Copy each shaded group exactly, left to right."), + _note(HANDWRITING), shown, _note(f"Label this card {letter}. The letter after {name} is the card's name."), _note(NO_CAMERA, "warning"), From 4d65adc1894b49b20f1895b1d7684389e7ac469d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:40:46 +0000 Subject: [PATCH 5/7] docs: Record the GUI budget raise to 2,050 The guard in tests/test_gui_boundaries.py moved to 2,050 for the new page, but gui.md still said 2,000 and api.md still said 1,800. api.md requires a budget change to land with its documentation, so update both. The raise itself still needs the maintainer's approval. Refs BenWestgate/Bails#314 --- docs/developer/api.md | 2 +- docs/developer/gui.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 6c7c801..1b829ef 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -113,7 +113,7 @@ documentation and enforcement update. installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the surface above and of the private Core adapter; nothing in `src/codex32/` imports it, and the base install keeps its property of having no third-party runtime -dependency. It carries its own budget of 1,800 logical review lines, separate +dependency. It carries its own budget of 2,050 logical review lines, separate from the 5,000 above. Its own boundaries are documented in [`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`. diff --git a/docs/developer/gui.md b/docs/developer/gui.md index e31f661..b3f7ce8 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -28,12 +28,13 @@ without a display and run in ordinary CI. `tools/gui_walkthrough.py` drives the real widgets through every task under a throwaway X server and is the cheapest way to see the screens without a desktop. -The package carries its own budget of 2,000 logical lines, separate from the +The package carries its own budget of 2,050 logical lines, separate from the 5,000 the installed library keeps, and `tests/test_gui_boundaries.py` enforces it. The plan proposed 1,000 before the screens were written and the budget was 1,800 before the security review of 2026-09-19; that review's remediations are about 250 lines, and the rest of the difference is user-facing wording in -`pages.py`, which is the first priority this program was built for. +`pages.py`, which is the first priority this program was built for. It was +2,000 until the **Before you start** page and handwriting key of 2026-10-03. ## Claims, and how to check each one From aaa0e67f11f411b3957eb8ad6789b1a58a154bef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:44:27 +0000 Subject: [PATCH 6/7] test: Pin the GTK branch in form launch tests The launch tests replaced Gtk.FileLauncher but left the real Gtk.check_version in charge, so on GTK before 4.10 they would raise or take the fallback branch. Pin check_version to a modern GTK and allow adding the attribute where it is missing. Refs BenWestgate/Bails#314 --- tests/test_gui_before_you_start.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_gui_before_you_start.py b/tests/test_gui_before_you_start.py index b664299..36ce9c5 100644 --- a/tests/test_gui_before_you_start.py +++ b/tests/test_gui_before_you_start.py @@ -78,7 +78,8 @@ def __init__(self, file: Any) -> None: def launch(self, _parent: object, _cancellable: object, _callback: object) -> None: opened.append(self.path) - monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher) + monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher, raising=False) + monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: None) view = Adw.NavigationView() page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] @@ -106,7 +107,8 @@ def __init__(self, file: Any) -> None: def launch(self, _parent: object, _cancellable: object, _callback: object) -> None: pass - monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher) + monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher, raising=False) + monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: None) monkeypatch.setenv("CODEX32_FORMS_DIR", "/home/amnesia/Tor Browser/codex32 forms") view = Adw.NavigationView() page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] From 175cf4c01b92fa908591d09d5ac7c5b91fb7096d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:18:51 +0000 Subject: [PATCH 7/7] gui: Say to print the forms blank Printers and the CUPS spool can keep a copy of a printed page, so a printed card would leave the share behind. The forms hold nothing secret until written on, so tell the user to print them blank and fill them in by hand, ideally in archival ink. Refs BenWestgate/Bails#314 --- docs/user/gui.md | 4 +++- src/codex32_gui/pages.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/user/gui.md b/docs/user/gui.md index 22cb285..d493275 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -66,7 +66,9 @@ Next, **Before you start** asks you to have one blank recovery card per card, a pen, and one wallet record ready. Its buttons open the printable [recovery card](../../src/codex32_gui/forms/recovery-card.html) and [wallet record](../../src/codex32_gui/forms/wallet-verification-record.html) -forms in your browser. Press **I have them ready** to see the first card. +forms in your browser. Print them blank and fill them in by hand, ideally in +archival ink. Never print a filled-in card: printers and print queues can keep +a copy of what they printed. Press **I have them ready** to see the first card. Each card is shown once. Copy it onto paper with a pen, then type it back from the paper with the original off the screen. That catches a slip of the pen now diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 1220590..f41d3f2 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -639,7 +639,10 @@ def _ready_page( there is still time to fetch or print one. """ cards = "one blank recovery card" if count == 1 else f"{count} blank recovery cards" - status = _note("Each form opens in your browser, where you can print it.") + status = _note( + "Each form opens in your browser. Print it blank, then fill it in by hand in archival ink. " + "Never print a filled-in card: a printer can keep a copy." + ) def show(name: str) -> None: # A confined browser (Tor Browser on Tails) may not read the package, so a