mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:27:40 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
32a735f168
commit
3073faa623
20
scripts/_text_free_rules.py
Normal file
20
scripts/_text_free_rules.py
Normal file
@ -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))
|
||||
86
scripts/_text_free_screens.py
Normal file
86
scripts/_text_free_screens.py
Normal file
@ -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),
|
||||
]
|
||||
131
scripts/verify_text_free.py
Normal file
131
scripts/verify_text_free.py
Normal file
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user