diff --git a/meshterm/tools/info.py b/meshterm/tools/info.py index 1cadf5d..c13b42b 100644 --- a/meshterm/tools/info.py +++ b/meshterm/tools/info.py @@ -41,15 +41,37 @@ class InfoTool(Tool): Returns: A :class:`ToolResult` summarizing the device name. """ - from ..ui.config_editor import cached_snapshot, config_table + from rich.console import Group + + from ..ui.config_editor import cached_snapshot, config_table, has_pin device = await ctx.device() snapshot = await cached_snapshot(ctx, device) custom = await device.get_custom_vars() # A live status panel (role, firmware, battery, clock, radio/packet statistics) - # above every current setting with a short explanation of each. - ctx.ui.show(await _status_panel(device, snapshot), Text(""), config_table(snapshot, custom)) + # above every current setting with a short explanation of each. The panel is read + # once; only the table differs between the two states, and it is cheap to rebuild. + panel = await _status_panel(device, snapshot) + + def page(reveal_pin: bool) -> Group: + return Group(panel, Text(""), config_table(snapshot, custom, reveal_pin=reveal_pin)) + + # In the menu the page is its own screen, because it has a key of its own: the + # pairing PIN is masked until ``p`` uncovers it. On the CLI there is no keyboard to + # press, so the dump prints as it always did — with the PIN masked there too, and + # ``meshterm config get device_pin`` as the way to name it deliberately. + session = getattr(ctx.ui, "session", None) + if session is None: + ctx.ui.show(page(False)) + else: + from ..ui.device_info_screen import DeviceInfoScreen + + await session.run_screen( + DeviceInfoScreen( + session, page, title=self.title, conceals=has_pin(snapshot) + ) + ) return ToolResult(summary={"name": snapshot.get("name")}) diff --git a/meshterm/ui/config_editor.py b/meshterm/ui/config_editor.py index 9ff1154..4449afd 100644 --- a/meshterm/ui/config_editor.py +++ b/meshterm/ui/config_editor.py @@ -56,6 +56,7 @@ from ..core.device_config import ( settings_by_category, ) from ..platforms import Platform, on_platform +from .marks import MASK_MARK from .menus import ( confirm_discard, exit_rows, @@ -205,6 +206,8 @@ def config_table( snapshot: dict, custom: dict[str, str], pending: Optional[dict[str, Any]] = None, + *, + reveal_pin: bool = False, ) -> Table: """Build the full configuration table, overlaying any staged changes. @@ -212,6 +215,13 @@ def config_table( snapshot: Device snapshot from ``build_snapshot``. custom: Current custom variables. pending: Optional staged changes (setting key -> new value). + reveal_pin: Whether to print the BLE pairing PIN. It is concealed by default — + this table is the whole-device dump, the thing that gets read over a + shoulder, screenshotted, and pasted into a bug report, and a pairing code is + the one value on it that lets someone else's phone onto the radio. The + Device info page reveals it on a keypress (see + :class:`~meshterm.ui.device_info_screen.DeviceInfoScreen`); the CLI names it + one value at a time with ``meshterm config get device_pin``. Returns: A Rich :class:`Table` of every setting's current (and staged) value plus custom @@ -259,10 +269,10 @@ def config_table( header.append("") table.add_row(*row_of(header, "")) for spec in specs: - row = [ - _fold_label(spec.label, describe), - _fold_value(format_value(spec, spec.getter(snapshot)), describe), - ] + current = format_value(spec, spec.getter(snapshot)) + if spec.key == PIN_KEY and not reveal_pin: + current = conceal(current) + row = [_fold_label(spec.label, describe), _fold_value(current, describe)] if show_staged: row.append( _fold_value(format_value(spec, pending[spec.key]), describe) @@ -282,6 +292,36 @@ def config_table( return table +#: The one setting this table holds back: the BLE pairing PIN (the firmware reports it as +#: ``ble_pin``; :data:`~meshterm.core.device_config.SETTINGS` keys it ``device_pin``). +PIN_KEY = "device_pin" + + +#: How wide a concealed PIN draws: the width of the *field*, taken from the setting's own +#: upper bound (999999), not the length of the value sitting in it. A typed password masks +#: per character because the typist needs to count what they have entered; a stored one +#: shows its field, so the row can't be read for how many digits to guess — and a PIN of +#: ``0`` behind a single bullet would have read as a stray dot rather than a covered value. +_PIN_WIDTH = len(str(get_spec(PIN_KEY).maximum)) + + +def conceal(value: str) -> str: + """``value`` as a row of :data:`_PIN_WIDTH` mask bullets. + + A value the device could not report is not a secret, it is an absence: ``format_value`` + already renders that as ``?``, and a ``?`` behind bullets would claim there is + something to reveal when there is nothing. So only a real value is concealed, and + :func:`has_pin` asks the same question the other way round — a page with nothing to + conceal advertises no key for it. + """ + return value if value == "?" else MASK_MARK * _PIN_WIDTH + + +def has_pin(snapshot: dict) -> bool: + """Whether ``snapshot`` carries a PIN at all — i.e. whether concealing it means anything.""" + return get_spec(PIN_KEY).getter(snapshot) is not None + + #: Cell caps for the table's two narrow lanes, indent included. Sized to the widest label #: and value the settings actually carry, less the few longest — the four that overflow #: fold onto a second line, which costs nothing on rows whose description was wrapping diff --git a/meshterm/ui/device_info_screen.py b/meshterm/ui/device_info_screen.py new file mode 100644 index 0000000..cc72038 --- /dev/null +++ b/meshterm/ui/device_info_screen.py @@ -0,0 +1,117 @@ +"""The Device info page: everything the companion reports, its pairing PIN concealed. + +The page itself is the ``info`` tool's — a live status panel over the full configuration +table (see :mod:`meshterm.tools.info`). What lives here is the one thing that page does +*beyond* scrolling: it holds the BLE pairing PIN back behind a row of mask bullets until +the reader asks for it. + +Why this page and not the editor: this is the whole-device dump, the screen someone opens +to read their radio out to somebody else, screenshots for a forum post, or leaves up while +a colleague looks over their shoulder — and the pairing PIN is the one value on it that +lets another phone onto the radio. The editor one menu over still lists it plainly: you go +there to *change* it, a row at a time, and its letters already belong to find-as-you-type +so there is no key left to reveal with. +""" + +from __future__ import annotations + +from typing import Callable + +from rich.console import RenderableType + +from .tui.screen import ScrollScreen + +#: The key that reveals the PIN, and the word the footer names it by. A bare letter is the +#: idiom for a read-only page's own verb (the Time Machine's ``w``); ``p`` is the value it +#: uncovers, and nothing else on a scrolling body claims it — the page has no +#: find-as-you-type, and ``^P`` (paths) is a different key. +REVEAL_KEY = "p" + + +class DeviceInfoScreen(ScrollScreen): + """The Device info result window, plus a keypress that uncovers the pairing PIN. + + Everything else is :class:`~meshterm.ui.tui.screen.ScrollScreen`: the body scrolls, + Esc closes, the pager rides F4/F5. The reveal is a *toggle* on one key and one chip — + press it again and the bullets come back — because a reader who uncovered a secret to + read it out wants to put it away without leaving the page. + + The body is rebuilt through the caller's ``build`` rather than patched in place, so + this screen never has to know what the page is made of. Both states are kept once + built: the two differ by six cells and nothing reflows, so the scroll position stays + exactly where the reader left it across a press. + """ + + def __init__( + self, + session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle + build: Callable[[bool], RenderableType], + *, + title: str = "", + conceals: bool = True, + ) -> None: + """Wrap the page in its screen. + + Args: + session: The running TUI session (repainted when the PIN is toggled). + build: Renders the page body for ``reveal_pin``; called once per state. + title: Heading for the window. + conceals: Whether there is a PIN to conceal at all. ``False`` — a device that + never reported one — leaves the page with nothing to reveal, so neither + the footer nor the lane offers a key that would do nothing. + """ + super().__init__(build(False), title=title) + self._session = session + self._build = build + self._conceals = conceals + self._revealed = False + self._bodies: dict[bool, RenderableType] = {False: self._renderable} + + @property + def footer_hint(self) -> str: # type: ignore[override] + """Scrolling, then the reveal, then Esc — the standard's order. + + The reveal atom names what the press does *now* (``show``/``hide``), not what the + screen is showing, so it is never a restatement of the bullets already on screen. + """ + atoms = [] + if self.content_overflows: + atoms.append("↑↓ PgUp/PgDn scroll") + if self._conceals: + atoms.append(f"{REVEAL_KEY} {'hide' if self._revealed else 'show'} PIN") + atoms.append("Esc close" if self.floating else "Esc back") + return " · ".join(atoms) + + @property + def fkey_lane(self): + """The shared pager, plus the reveal on F3 — this screen's own verb slot. + + The chip is the only place the PicoCalc can learn the key exists, and it names the + action rather than its object: ``Reveal`` while the PIN is masked, ``Hide`` once it + is out, both inside the six cells a chip has. A device with no PIN leaves the slot + *empty* rather than dim — the action isn't a thing on this page at all. + """ + from .tui.fkeys import FPair, default_lane + + lane = list(default_lane(nav=self.content_overflows)) + if self._conceals: + lane[2] = FPair("Hide" if self._revealed else "Reveal", "reveal") + return lane + + def handle(self, action: str, data: str = "") -> None: + """Toggle the PIN, or scroll/dismiss as any result window does. + + The key and the lane's chip dispatch into the same branch — one behaviour, two ways + in, so the chip is not a second implementation of the letter. + """ + if action == "reveal" or (action == "text" and data.lower() == REVEAL_KEY): + if not self._conceals: + return + self._revealed = not self._revealed + body = self._bodies.get(self._revealed) + if body is None: + body = self._bodies[self._revealed] = self._build(self._revealed) + self.replace_content(body) + self._session.invalidate() + return + super().handle(action, data) diff --git a/meshterm/ui/marks.py b/meshterm/ui/marks.py index 418f053..9c74704 100644 --- a/meshterm/ui/marks.py +++ b/meshterm/ui/marks.py @@ -38,6 +38,13 @@ REPEATER_MARK = ("▲", "#a78bfa") NODE_MARK = ("●", "#f472b6") UNKNOWN_MARK = ("○", "node.unknown") +#: THE concealed character: one bullet stands in for one character of something the screen +#: is holding back — a password being typed (``ui/tui/prompt.py``), the BLE pairing PIN on +#: the Device info page. One glyph for the concept, so a row of them reads the same way +#: wherever it appears, and it is deliberately *not* the ``●`` of an unread message or a +#: node: those are marks that mean something, this one means "not shown". +MASK_MARK = "•" + #: Sentinels naming a path's endpoints on the route graph. ``\x00`` never appears in a #: node hash, so they can share the node-id namespace without colliding with one. SRC_NODE = "\x00src" diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index f69b003..3c3b4ff 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -13,6 +13,7 @@ from rich.cells import cell_len from rich.console import Group, RenderableType from rich.text import Text +from ..marks import MASK_MARK from .render import render_lines, right_aligned_tail from .screen import Screen from .spinner import Spinner @@ -189,7 +190,7 @@ class _LineEditor: """ if slots is not None: return self._render_slots(slots) - shown = "•" * len(self.text) if mask else self.text + shown = MASK_MARK * len(self.text) if mask else self.text text = Text("› ", style="accent") for i, ch in enumerate(shown): over = overflow_at is not None and i >= overflow_at @@ -213,7 +214,7 @@ class _LineEditor: for i in range(slots): if i: text.append(" ") - glyph = "•" if i < filled else "·" + glyph = MASK_MARK if i < filled else "·" if i == self.cursor: text.append(glyph, style="reverse") # the active slot elif i < filled: diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 813a811..c35c3de 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -641,6 +641,18 @@ class ScrollScreen(Screen): verb = "Esc close" if self.floating else "Esc back" return f"↑↓ PgUp/PgDn scroll · {verb}" if self.content_overflows else verb + def replace_content(self, renderable: RenderableType) -> None: + """Swap the body in place, keeping the reader where they were. + + The select list's :meth:`~meshterm.ui.tui.select.SelectScreen.replace_items` for a + read-only body: the same page with something in it changed — a value uncovered, a + figure refreshed — is still the page the reader was part-way down, so the scroll + offset stands. (It is clamped on the next scroll anyway, so a body that did change + height cannot strand the viewport past its end.) Rebuild the screen instead when + what is on it is genuinely a different subject. + """ + self._renderable = renderable + def render_body(self, width: int) -> list[str]: """Render the wrapped content to ANSI lines and remember the total count.""" lines = render_lines(self._renderable, width) diff --git a/tests/test_config_editor.py b/tests/test_config_editor.py index 48e72c9..daf57f2 100644 --- a/tests/test_config_editor.py +++ b/tests/test_config_editor.py @@ -27,11 +27,14 @@ from meshterm.ui.config_editor import ( _parse_coords, _valid_coords, config_table, + has_pin, contact_share_url, device_actions, edit_config, send_advert, ) +from meshterm.ui.device_info_screen import DeviceInfoScreen +from meshterm.ui.marks import MASK_MARK from meshterm.ui.theme import active_theme from meshterm.ui.tui.prompt import TypedConfirmDialog from meshterm.ui.tui.screen import CANCEL @@ -65,7 +68,7 @@ _SNAPSHOT = { "name": "Homestead-Hub", "adv_lat": 45.5017, "adv_lon": -73.5673, - "pin_code": 123456, + "ble_pin": 123456, "radio_freq": 869525, "radio_bw": 250, "radio_sf": 11, @@ -146,6 +149,115 @@ def test_config_table_drops_the_description_lane_beside_a_staged_column() -> Non assert "STAGED" in header and "DESCRIPTION" not in header +# -- the concealed pairing PIN -------------------------------------------------- + + +class _StubSession: + """A minimal session: the screen only ever asks it to repaint.""" + + def __init__(self) -> None: + self.repaints = 0 + + def invalidate(self) -> None: + self.repaints += 1 + + +def _pin_row(rendered) -> str: # noqa: ANN001 - lines or an already-joined render + """The Device PIN row out of a rendered table or screen body.""" + return next(line for line in _plain(rendered).split("\n") if "Device PIN" in line) + + +def _info_screen(snapshot: dict) -> DeviceInfoScreen: + return DeviceInfoScreen( + _StubSession(), + lambda reveal: config_table(snapshot, {}, reveal_pin=reveal), + title="Device info", + conceals=has_pin(snapshot), + ) + + +def test_the_pin_is_bullets_until_it_is_asked_for() -> None: + # One bullet per digit — the app's own mask glyph, the one a password prompt types in. + masked = _pin_row(_table_lines(72)) + assert f"{MASK_MARK * 6}" in masked + assert "123456" not in masked + assert "123456" in _pin_row(_table_lines(72, reveal_pin=True)) + + +def test_the_mask_shows_the_field_not_the_pins_length() -> None: + # A stored secret draws its field: a one-digit PIN behind one bullet would have told + # you how many digits to guess (and read as a stray dot besides). + short = {**_SNAPSHOT, "ble_pin": 0} + console = Console( + width=72, file=io.StringIO(), theme=active_theme(), legacy_windows=False + ) + with console.capture() as capture: + console.print(config_table(short, {})) + assert MASK_MARK * 6 in _pin_row(capture.get()) + + +def test_a_device_with_no_pin_has_nothing_to_conceal() -> None: + # "?" is an absence, not a secret: masking it would claim there is something behind it. + snapshot = {k: v for k, v in _SNAPSHOT.items() if k != "ble_pin"} + console = Console( + width=72, file=io.StringIO(), theme=active_theme(), legacy_windows=False + ) + with console.capture() as capture: + console.print(config_table(snapshot, {})) + assert "?" in _pin_row(_plain(capture.get()).split("\n")) + assert not has_pin(snapshot) + + +def test_p_uncovers_the_pin_and_puts_it_back() -> None: + screen = _info_screen(_SNAPSHOT) + assert MASK_MARK in _pin_row(screen.render_body(72)) + + screen.handle("text", "p") + assert "123456" in _pin_row(screen.render_body(72)) + + screen.handle("text", "p") # the same key puts it away again + assert MASK_MARK in _pin_row(screen.render_body(72)) + + +def test_the_lane_chip_and_the_key_are_one_behaviour() -> None: + # The PicoCalc's chip dispatches the action the letter does, not a second copy of it. + screen = _info_screen(_SNAPSHOT) + assert screen.fkey_lane[2].label == "Reveal" + + screen.handle("reveal") + assert "123456" in _pin_row(screen.render_body(72)) + assert screen.fkey_lane[2].label == "Hide" + + +def test_the_footer_names_what_the_press_would_do_now() -> None: + screen = _info_screen(_SNAPSHOT) + assert "p show PIN" in screen.footer_hint + screen.handle("text", "p") + assert "p hide PIN" in screen.footer_hint + + +def test_a_page_with_no_pin_advertises_no_key_for_it() -> None: + # The standing rule: a footer never names a key that would do nothing, and a lane slot + # for an action this screen doesn't have stays empty rather than dim. + screen = _info_screen({k: v for k, v in _SNAPSHOT.items() if k != "ble_pin"}) + assert "PIN" not in screen.footer_hint + assert screen.fkey_lane[2] is None + screen.handle("text", "p") + assert screen.fkey_lane[2] is None + + +def test_uncovering_the_pin_keeps_the_reader_where_they_were() -> None: + # Nothing reflows — six cells become six cells — so the scroll must not jump. + screen = _info_screen(_SNAPSHOT) + screen.note_viewport(10) + screen.render_body(72) + screen.scroll_lines(8) + where = screen.scroll + screen.handle("text", "p") + assert screen.scroll == where + assert len(screen.render_body(72)) == len(_table_lines(72)) + + # -- contact share URL ---------------------------------------------------------- diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 037d615..9d85cb2 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -62,10 +62,12 @@ from meshterm.ui.about import ( support_project, ) from meshterm.ui.chat import ChatScreen +from meshterm.ui.config_editor import config_table, has_pin from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING from meshterm.ui.contacts_screen import ContactsScreen from meshterm.ui.courier_screen import CourierOutboxScreen from meshterm.ui.dashboard_screen import DashboardScreen +from meshterm.ui.device_info_screen import DeviceInfoScreen from meshterm.ui.livefeed_screen import LiveFeedScreen from meshterm.ui.map_render import MapMarker from meshterm.ui.map_screen import MapScreen @@ -486,6 +488,49 @@ def _tx_sweep(cols: int, rows: int) -> Screen: return screen +#: A device snapshot shaped like a companion's, for the Device info page. Every field the +#: config table draws, including the pairing PIN it conceals. +_DEVICE_SNAPSHOT = { + "name": "Homestead-Hub", + "adv_lat": 45.5017, + "adv_lon": -73.5673, + "ble_pin": 123456, + "radio_freq": 869525, + "radio_bw": 250, + "radio_sf": 11, + "radio_cr": 5, + "tx_power": 22, + "max_tx_power": 30, + "airtime_factor": 1.0, + "rx_delay": 0.0, + "manual_add_contacts": 0, + "autoadd_config": 0, + "flood_scope": "", + "adv_loc_policy": 1, + "multi_acks": 0, + "telemetry_mode_base": 1, + "telemetry_mode_loc": 0, + "telemetry_mode_env": 0, + "path_hash_mode": 1, +} + + +def _device_info(cols: int, rows: int) -> Screen: + return DeviceInfoScreen( + _GallerySession(cols, rows), + lambda reveal: config_table(_DEVICE_SNAPSHOT, {}, reveal_pin=reveal), + title="Device info", + conceals=has_pin(_DEVICE_SNAPSHOT), + ) + + +def _device_info_revealed(cols: int, rows: int) -> Screen: + # The same page after ``p``: the widest state of the row the other case masks. + screen = _device_info(cols, rows) + screen.handle("text", "p") + return screen + + def _about_meshterm(cols: int, rows: int) -> Screen: return AboutPage("About MeshTerm", about_meshterm()) @@ -522,6 +567,8 @@ _ENTRIES: list[_Entry] = [ _Entry("packet_viewer", _packet_viewer), _Entry("trace", _trace), _Entry("tx_sweep", _tx_sweep), + _Entry("device_info", _device_info), + _Entry("device_info_revealed", _device_info_revealed), _Entry("about_meshterm", _about_meshterm), _Entry("about_author", _about_author), _Entry("join_discord", _join_discord),