mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:07:43 +01:00
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.
Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.
Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.
Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.
`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""Guard: no lock-surface UI module may build a popup window.
|
|
|
|
A Tk popup is a separate override-redirect window that also takes the Tk grab
|
|
for itself, so gatelock's recovery tick covers it and disowns it within a
|
|
second of opening. ``_manual_workout_dialog._mw_sport_row`` has the mechanism
|
|
and the incident; ``scripts/verify_lock_popup_safety.py`` is the behavioural
|
|
half of this guard and proves the fix still holds under a live recovery tick
|
|
(run by the ``lock-popup-safety`` job in ``.github/workflows/python-tests.yml``).
|
|
|
|
The set of modules under the ban is DERIVED, not listed: anything in the
|
|
package that both imports ``tkinter`` and paints onto the shared container is
|
|
in scope automatically, so a new lock screen is covered the day it is written
|
|
rather than the day someone remembers to add it here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PACKAGE_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
EXEMPT: dict[str, str] = {
|
|
"status_view.py": "plain tk.Tk with no grab and no recovery loop",
|
|
"_heat_skip.py": "own throwaway root, destroyed before the lock exists",
|
|
}
|
|
"""Modules that touch Tk but never paint onto a gatelock lock surface.
|
|
|
|
Kept deliberately short and reasoned: every entry is a module allowed to open
|
|
a popup, so an unjustified addition here is how the guard gets hollowed out.
|
|
"""
|
|
|
|
BANNED = frozenset(
|
|
{
|
|
"OptionMenu",
|
|
"Combobox",
|
|
"tk_popup",
|
|
"post",
|
|
"messagebox",
|
|
"simpledialog",
|
|
"filedialog",
|
|
"colorchooser",
|
|
"Toplevel",
|
|
}
|
|
)
|
|
|
|
|
|
def lock_surface_modules() -> list[str]:
|
|
"""Every package module that touches Tk on behalf of the lock window.
|
|
|
|
Importing ``tkinter`` is the whole test: the mixins build into a parent
|
|
handed to them, so there is no reliable in-module marker saying "this ends
|
|
up on a surface". Anything that genuinely does not is named in ``EXEMPT``
|
|
with its reason.
|
|
"""
|
|
return [
|
|
path.name
|
|
for path in sorted(PACKAGE_DIR.glob("*.py"))
|
|
if "import tkinter" in path.read_text(encoding="utf-8")
|
|
and path.name not in EXEMPT
|
|
]
|
|
|
|
|
|
def symbols(module_name: str) -> set[str]:
|
|
"""Every attribute and bare name referenced in a module's code.
|
|
|
|
An AST walk rather than a source regex: every banned widget is either an
|
|
``ast.Attribute`` (``tk.OptionMenu``) or an ``ast.Name`` (a bare import),
|
|
so docstrings, comments and string literals are excluded for free. The
|
|
modules under test explain this very bug in prose that names the banned
|
|
widgets, and a regex would have to strip that back out.
|
|
"""
|
|
tree = ast.parse((PACKAGE_DIR / module_name).read_text(encoding="utf-8"))
|
|
return {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)} | {
|
|
n.id for n in ast.walk(tree) if isinstance(n, ast.Name)
|
|
}
|
|
|
|
|
|
def test_the_module_list_is_not_empty() -> None:
|
|
"""A derived list that silently went empty would pass every other test."""
|
|
assert lock_surface_modules(), (
|
|
"no lock-surface modules detected -- the markers in _SURFACE_MARKERS "
|
|
"have probably drifted, which would make this whole guard a no-op"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("module_name", lock_surface_modules())
|
|
def test_lock_surface_module_creates_no_popup(module_name: str) -> None:
|
|
"""Fail if a lock-surface module references any popup-creating API."""
|
|
offenders = sorted(symbols(module_name) & BANNED)
|
|
assert not offenders, (
|
|
f"{module_name} uses popup-creating Tk API {offenders}; a popup on a "
|
|
"lock surface is covered and loses its grab within one recovery tick"
|
|
)
|