From 3073faa623cc495341768173299a81a5a84c0100 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 01:07:57 +0100 Subject: [PATCH] Add text-free check for locker screens Paints every locker screen under Xvfb and fails on any painted word. Red now: 18 screens. --shots DIR saves one PNG per screen. Co-Authored-By: Claude Opus 5.5 --- scripts/_text_free_rules.py | 20 ++++++ scripts/_text_free_screens.py | 86 ++++++++++++++++++++++ scripts/verify_text_free.py | 131 ++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 scripts/_text_free_rules.py create mode 100644 scripts/_text_free_screens.py create mode 100644 scripts/verify_text_free.py diff --git a/scripts/_text_free_rules.py b/scripts/_text_free_rules.py new file mode 100644 index 0000000..5593778 --- /dev/null +++ b/scripts/_text_free_rules.py @@ -0,0 +1,20 @@ +"""What counts as a painted word for ``verify_text_free``. + +Content is allowed (textgate-rework R4): numbers, dates, symbols, glyphs, and +a unit symbol written right after its number (``31°C``, ``5 km``, +``40 min``). Everything else with two or more letters in a row is chrome +someone wrote around the content, and the check fails on it. +""" + +from __future__ import annotations + +import re + +# A unit symbol glued to (or one space after) the number it measures. +_UNIT_AFTER_NUMBER = re.compile(r"(?<=\d)\s?(?:kg|km|min|h|ms)\b") +_WORD = re.compile(r"[A-Za-z]{2,}") + + +def words_in(text: str) -> list[str]: + """Return every chrome word in *text* (empty list when text-free).""" + return _WORD.findall(_UNIT_AFTER_NUMBER.sub("", text)) diff --git a/scripts/_text_free_screens.py b/scripts/_text_free_screens.py new file mode 100644 index 0000000..32fdba5 --- /dev/null +++ b/scripts/_text_free_screens.py @@ -0,0 +1,86 @@ +"""Screens ``verify_text_free`` paints beyond the fit check's list. + +The fit check measures only the screens that can overflow. The text check has +to see every screen kuhy can look at: the verified/unlock screens, the +checking screens and the status window (resizable, so absent from the fit +check). Each build paints into ``surface.content`` like the fit check's. +""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace +from typing import TYPE_CHECKING + +from screen_locker._status_data import gather_status +from screen_locker._temperature import TemperatureCheck +from screen_locker._ui_flows import UIFlowsMixin +from screen_locker._unlock_view import UnlockViewMixin +from screen_locker._workout_credit import WorkoutCreditResult +from screen_locker.status_view import StatusWindow +from scripts.verify_screen_fits import ScreenHost + +if TYPE_CHECKING: + from collections.abc import Callable + + +class _Host(UnlockViewMixin, ScreenHost): + """ScreenHost plus the unlock view, with every reward line switched on.""" + + def _apply_workout_credit(self) -> WorkoutCreditResult: + return WorkoutCreditResult( + shutdown_adjusted=True, + extra_bonus_delta=60, + weekly_count=5, + already_counted_today=False, + ) + + def _verify_runnerup_workout(self) -> tuple[str, str]: + """Stand-in for the RunnerUp check: never reads a phone. + + Blocks briefly so the waiting screen is what gets checked, not the + failure screen a finished future would route to straight away. + """ + threading.Event().wait(2) + return ("not_verified", "x") + + def paint_manual_saved(self) -> None: + """The real confirmation screen (ScreenHost stubs the hook out).""" + UIFlowsMixin._on_manual_workout_saved(self, {"source": "x"}) + + +def _host_paint(method: str, *args: object) -> Callable[[object], None]: + def build(surface: object) -> None: + host = _Host(surface) + host.root.after = lambda *_a, **_k: None # no unlock timer, no close + getattr(host, method)(*args) + + return build + + +def _status_window(surface: SimpleNamespace) -> None: + """The real status window over today's on-disk state (read-only).""" + StatusWindow( + surface.content.winfo_toplevel(), + gather_status(), + on_refresh=lambda: None, + temperature_fetcher=lambda _city: TemperatureCheck( + temp_celsius=21.0, timed_out=False + ), + ).root.update_idletasks() + + +def extra_paints() -> list[tuple[str, Callable[[object], None]]]: + """Every screen the fit check does not paint, as (name, build).""" + return [ + ("unlock", _host_paint("unlock_screen")), + ("verified", _host_paint("_handle_startup_phone_result", "verified", "x")), + ("too-short", _host_paint("_handle_startup_phone_result", "too_short", "x")), + ( + "clock-tampered", + _host_paint("_handle_startup_phone_result", "clock_tampered", "x"), + ), + ("runnerup-check", _host_paint("_handle_startup_phone_result", "stale", "x")), + ("manual-saved", _host_paint("paint_manual_saved")), + ("status-window", _status_window), + ] diff --git a/scripts/verify_text_free.py b/scripts/verify_text_free.py new file mode 100644 index 0000000..488c67b --- /dev/null +++ b/scripts/verify_text_free.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Assert no locker screen paints a word (textgate: zero visible text). + +kuhy's rule: anything he sees carries no text unless he asked for it. The +``textgate scan`` only sees string literals passed to known keywords, so it +misses every ``self._label("...")`` / ``self._text(f"...")`` call -- the +whole locker UI. This check paints every screen for real (Xvfb, the same +``ScreenHost`` the fit check uses), walks the widget tree, and fails on any +2+ letter word that is not content (see ``_text_free_rules``). + + cd ~/src/screen-locker && python3 -m scripts.verify_text_free [--shots DIR] + +``--shots DIR`` also saves one PNG per screen (ImageMagick ``import``), so +the same run is the before/after evidence. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import subprocess +import sys +import tkinter as tk +from types import SimpleNamespace + +from scripts._fit_extra_screens import EXTRA_SCREENS +from scripts._text_free_rules import words_in +from scripts._text_free_screens import extra_paints +from scripts._xvfb_reexec import reexec_under_xvfb +from scripts.verify_screen_fits import SCREENS, ScreenHost + +_logger = logging.getLogger(__name__) + +INNER_ENV = "_TEXT_FREE_CHECK_INNER" +_SIZE = (1366, 768) +_TYPED = frozenset({"Entry", "Spinbox", "Text", "TEntry", "TSpinbox", "TCombobox"}) + + +def _widget_texts(widget: tk.Misc) -> list[str]: + """Every painted string under *widget*: label/button text and menus.""" + out: list[str] = [] + for child in widget.winfo_children(): + out += _widget_texts(child) + # What the user typed is content. (Tk abbreviates options, so an Entry's + # cget("text") would answer its textvariable's *name*.) + if widget.winfo_class() in _TYPED: + return out + try: + text = str(widget.cget("text")) + except tk.TclError: + text = "" + try: + var = str(widget.cget("textvariable")) + except tk.TclError: + var = "" + if var: + text = f"{text} {widget.getvar(var)}" + if widget.winfo_class() == "Menu": + last = widget.index("end") + for i in range(0 if last is None else int(last) + 1): + try: + text += " " + str(widget.entrycget(i, "label")) + except tk.TclError: + continue + return [text, *out] if text.strip() else out + + +def _shoot(win: tk.Toplevel, path: Path) -> None: + """Save *win* as a PNG; a failed shot is logged, never fatal.""" + win.update() + result = subprocess.run( + ["import", "-window", str(win.winfo_id()), str(path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode: + _logger.warning("screenshot %s failed: %s", path, result.stderr.strip()) + + +def _paint_all(root: tk.Tk, shots: Path | None) -> dict[str, list[str]]: + """Paint each screen in its own window; map screen -> offending words.""" + builds = [ + (s.name, lambda surface, p=s.paint: p(ScreenHost(surface))) for s in SCREENS + ] + builds += [(name, build) for name, build in EXTRA_SCREENS] + builds += extra_paints() + bad: dict[str, list[str]] = {} + for name, build in builds: + win = tk.Toplevel(root) + win.geometry(f"{_SIZE[0]}x{_SIZE[1]}") + frame = tk.Frame(win, bg="#211D1B") + frame.pack(fill="both", expand=True) + build(SimpleNamespace(content=frame)) + win.update_idletasks() + words = [w for t in _widget_texts(win) for w in words_in(t)] + if words: + bad[name] = words + if shots is not None: + _shoot(win, shots / f"{name}.png") + win.destroy() + return bad + + +def main(argv: list[str] | None = None) -> int: + """Paint every screen; exit 1 listing each one that still shows words.""" + logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout) + args = sys.argv[1:] if argv is None else argv + if os.environ.get(INNER_ENV) != "1": + os.environ["_TEXT_FREE_ARGS"] = "\x1f".join(args) + return reexec_under_xvfb(inner_env=INNER_ENV, module="scripts.verify_text_free") + args = [a for a in os.environ.get("_TEXT_FREE_ARGS", "").split("\x1f") if a] + shots = Path(args[args.index("--shots") + 1]) if "--shots" in args else None + if shots is not None: + shots.mkdir(parents=True, exist_ok=True) + root = tk.Tk() + root.withdraw() + bad = _paint_all(root, shots) + root.destroy() + if not bad: + _logger.info("OK: no locker screen paints a word.") + return 0 + for name, words in bad.items(): + _logger.error("%s: %s", name, " ".join(words)) + _logger.error("%d screen(s) still paint words.", len(bad)) + return 1 + + +if __name__ == "__main__": + sys.exit(main())