Skip to content
Open
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
11 changes: 1 addition & 10 deletions src/codex32_gui/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""The window: one navigation view, one stylesheet, and no command arguments."""

from __future__ import annotations

import sys
Expand All @@ -15,8 +13,6 @@


class Application(Adw.Application):
"""One non-unique window with a stable desktop identity and no recent list or files."""

def __init__(self) -> None:
super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.NON_UNIQUE)

Expand All @@ -31,17 +27,12 @@ def do_activate(self) -> None:
view = Adw.NavigationView()
view.push(pages.home(view))
window = Adw.ApplicationWindow(
application=self,
title="codex32",
default_width=880,
default_height=620,
content=view,
application=self, title="codex32", default_width=880, default_height=620, content=view
)
window.present()


def main(argv: Sequence[str] | None = None) -> int:
"""Open the window. Arguments are refused so that no secret can be passed in one."""
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments == ["--version"]:
print(__version__)
Expand Down
52 changes: 36 additions & 16 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
_ICON_STYLE = {DONE_ICON: "success", "dialog-error-symbolic": "error"}
SEED_SIZES = ((16, "128-bit seed, 48 characters"), (32, "256-bit seed, 74 characters"))
PRESETS = (
(2, 3, "Three cards, any two recover (recommended)"),
(2, 3, "Three cards, any two recover"),
(3, 5, "Five cards, any three recover"),
(0, 1, "One card"),
)
Expand Down Expand Up @@ -79,7 +79,7 @@
)
CARDS_SAFE = (
"Your cards are unharmed and still recover this wallet. Nothing was written onto them and "
"nothing about them changed. When Bitcoin Core is ready, choose \u201cRestore my wallet\u201d "
"nothing about them changed. When Bitcoin Core is ready, choose “Restore my wallet” "
"and enter them. Do not set up a new wallet: that would make a different backup."
)

Expand Down Expand Up @@ -185,17 +185,18 @@ def _card(
*,
highlight_class: str = "guessed",
) -> Gtk.FlowBox:
"""Show one card the way wallets.md asks: uppercase, in four-character windows."""
"""Show one card as uppercase four-character groups, wrapping only when needed."""
text = text.upper()
groups = tuple(text[start : start + reading.GROUP] for start in range(0, len(text), reading.GROUP))
flow = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE, column_spacing=8, row_spacing=8)
flow.set_homogeneous(True)
flow.set_min_children_per_line(4)
flow.set_max_children_per_line(4)
flow.set_min_children_per_line(1)
flow.set_max_children_per_line(len(groups))
flow.set_hexpand(True)
for start in range(0, len(text), reading.GROUP):
label = Gtk.Label(label=text[start : start + reading.GROUP], halign=Gtk.Align.CENTER)
for position, group in enumerate(groups):
label = Gtk.Label(label=group, halign=Gtk.Align.CENTER)
label.add_css_class("card-group")
if start // reading.GROUP in highlighted:
if position in highlighted:
label.add_css_class(highlight_class)
flow.append(label)
return flow
Expand All @@ -220,6 +221,24 @@ def _rows(title: str, values: Sequence[tuple[str, str]]) -> Adw.PreferencesGroup
return group


def _compact_rows(title: str, values: Sequence[tuple[str, str]]) -> Gtk.Box:
"""Show a dense two-column record without hiding any selectable values."""
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
heading = Gtk.Label(label=title, xalign=0.0)
heading.add_css_class("heading")
box.append(heading)
grid = Gtk.Grid(column_spacing=18, row_spacing=3)
grid.set_hexpand(True)
for position, (label, value) in enumerate(values):
name = Gtk.Label(label=label, xalign=0.0)
name.add_css_class("dim-label")
detail = Gtk.Label(label=value, xalign=1.0, selectable=True, hexpand=True)
grid.attach(name, 0, position, 1, 1)
grid.attach(detail, 1, position, 1, 1)
box.append(grid)
return box


def _forget_when_gone(view: Adw.NavigationView, page: Adw.NavigationPage, clear: Callable[[], None]) -> None:
"""Clear recovery text as soon as its page leaves the navigation stack."""
handlers: list[int] = []
Expand Down Expand Up @@ -904,17 +923,18 @@ def go() -> None:
dialog.present(view)

content = _column(
_title("Create a wallet for these keys", "Bitcoin Core makes it; codex32 fills it in."),
_title("Create a wallet for these keys", "Choose whether to encrypt the Bitcoin Core wallet file."),
group,
status,
_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."
"Wallet encryption protects this Bitcoin Core wallet file while it is locked. It does not "
"protect your recovery cards or make a compromised computer safe."
),
_note(
"Forgetting this passphrase does not lose your bitcoin: your cards still recover the seed. "
"It protects the wallet on this computer.",
"success",
"If you forget the wallet passphrase, Bitcoin Core cannot unlock this wallet file. Your "
"codex32 recovery cards can recreate the wallet's keys in a new Bitcoin Core wallet, so keep "
"the cards safe and separate.",
"warning",
),
)
page = _page("New wallet", content, actions=_actions(_button("Create", go, style="suggested-action")))
Expand Down Expand Up @@ -970,7 +990,7 @@ def go() -> None:

content = _column(
_title(
f"The wallet \u201c{wallet.name}\u201d is locked",
f"The wallet “{wallet.name}” is locked",
"Bitcoin Core needs its passphrase before your keys can be written into it.",
),
group,
Expand Down Expand Up @@ -1035,7 +1055,7 @@ def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = F
dated = () if restoring else (("Approximate creation date", time.strftime("%Y-%m-%d")),)
content = _column(
_title(heading, asked, DONE_ICON),
_rows(
_compact_rows(
"Wallet identity",
(
("Backup identifier", record.identifier),
Expand Down
19 changes: 10 additions & 9 deletions tests/test_gui_boundaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,29 @@ def _imports(tree: ast.AST) -> set[str]:

@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name)
def test_the_gui_draws_no_entropy_opens_no_socket_and_touches_no_file(path: Path) -> None:
imported = _imports(ast.parse(path.read_text()))
imported = _imports(ast.parse(path.read_text(encoding="utf-8")))
assert not {name.split(".")[0] for name in imported} & FORBIDDEN, sorted(imported)


@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name)
def test_nothing_reads_or_writes_a_file(path: Path) -> None:
called = {
node.func.id
for node in ast.walk(ast.parse(path.read_text()))
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
}
assert "open" not in called and "eval" not in called and "exec" not in called


@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name)
def test_only_one_module_speaks_to_bitcoin_core(path: Path) -> None:
imported = _imports(ast.parse(path.read_text()))
imported = _imports(ast.parse(path.read_text(encoding="utf-8")))
assert (CORE_ADAPTER in imported) == (path.name == "wallet_setup.py"), sorted(imported)


def test_the_accessibility_bus_is_turned_off_before_gtk_starts() -> None:
"""GTK otherwise publishes every label and entry, seed and passphrase included."""
source = (_package() / "__init__.py").read_text()
source = (_package() / "__init__.py").read_text(encoding="utf-8")
assert 'GLib.setenv("GTK_A11Y", "none", False)' in source
tree = ast.parse(source)
settings = [node for node in ast.walk(tree) if isinstance(node, ast.Import | ast.ImportFrom)]
Expand All @@ -90,34 +90,35 @@ def test_the_accessibility_bus_is_turned_off_before_gtk_starts() -> None:
@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name)
def test_nothing_but_the_version_pin_may_import_a_module_by_name(path: Path) -> None:
"""`importlib` would reach any of the forbidden modules without naming one."""
imported = {name.split(".")[0] for name in _imports(ast.parse(path.read_text()))}
imported = {name.split(".")[0] for name in _imports(ast.parse(path.read_text(encoding="utf-8")))}
assert "importlib" not in imported or path.name == "__init__.py", sorted(imported)


def test_the_parts_that_decide_something_need_no_toolkit() -> None:
for name in ("reading.py", "wallet_setup.py", "style.py"):
imported = _imports(ast.parse((_package() / name).read_text()))
imported = _imports(ast.parse((_package() / name).read_text(encoding="utf-8")))
assert not any(item == "gi" or item.startswith("gi.") for item in imported), name


def test_the_library_does_not_depend_on_the_gui() -> None:
library = Path(importlib.import_module("codex32").__file__ or "").parent
for path in library.rglob("*.py"):
assert "codex32_gui" not in path.read_text(), path
assert "codex32_gui" not in path.read_text(encoding="utf-8"), path


def test_the_gui_keeps_its_own_size_budget() -> None:
counts = {
path.name: sum(
bool(line.strip()) and not line.lstrip().startswith("#") for line in path.read_text().splitlines()
bool(line.strip()) and not line.lstrip().startswith("#")
for line in path.read_text(encoding="utf-8").splitlines()
)
for path in _modules()
}
assert sum(counts.values()) < BUDGET, counts


def test_read_only_poll_threads_do_not_keep_the_process_alive() -> None:
source = (_package() / "work.py").read_text()
source = (_package() / "work.py").read_text(encoding="utf-8")
tree = ast.parse(source)
functions = {node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)}

Expand Down
Loading