mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Root-caused the 2026-08-21 stuck-lock incident: a manual workout unlocked correctly, but the mechanism behind "locked again seconds later" was a stray workout-locker.service re-arm racing a second process into building a competing lock window -- not a leetcode-guard conflict as first suspected (its timer wasn't due until 13:00 that day). Separately, diet_guard held the global grab for ~3 minutes against a newly-arriving, higher-ranked screen_locker that morning, which just retried and logged instead of making progress. - _build_lock_window now calls gatelock.wait_for_turn before arming, so a stronger incumbent is queued behind instead of raced. - New screen_locker/_instance.py (flock-based, mirrors leetcode_guard/diet_guard's own single-instance guards) stops a second process from ever building a competing window in the first place. - Bumped to gatelock v0.7.1, which adds weaker-holder preemption (SIGTERM, reusing the holder's own clean close() path) so a lower- ranked incumbent can no longer block a stronger claim for minutes. - Fixed a live crash in scripts/verify_screen_fits.py: it read the real log.json, so today's exhausted manual-workout budget (itself caused by the incident's duplicate entry) made the fit-check crash on an unset _mw_error_label. Points at a path that can never exist instead, so the measurement no longer depends on live data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
248 lines
9.4 KiB
Python
Executable File
248 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Assert every lock screen fits a 1366x768 display.
|
|
|
|
A lock surface is fullscreen, grabbed and (in production) VT-locked. Content
|
|
that does not fit is only reachable by scrolling a screen that should never
|
|
have needed a scrollbar -- and the scroll that revealed it used to happen on
|
|
its own, moving the screen under a user who had touched nothing (2026-08-03).
|
|
The runtime half of this guarantee is the ``warning``
|
|
``ScrollableSurface.finalize`` logs; this script is the half that runs before
|
|
the code ships, so a layout change cannot quietly reintroduce overflow.
|
|
|
|
``StatusWindow`` is deliberately absent: it is a normal, resizable,
|
|
dismissible window rather than a lock surface, so a user who needs more of it
|
|
can resize it. Every screen that grabs the display is here.
|
|
|
|
Not a pytest test because ``screen_locker/tests/conftest.py`` replaces the
|
|
whole ``tk`` module for every test so the suite can never reach a display, and
|
|
widget heights can only be measured by really rendering them. Like
|
|
``verify_lock_popup_safety.py``, this re-executes itself under Xvfb:
|
|
|
|
cd ~/screen-locker && python3 -m scripts.verify_screen_fits
|
|
|
|
Run as a *module*, not a path: ``-m`` puts the repo root on ``sys.path`` so
|
|
``screen_locker`` resolves from the checkout. Running it as a path only works
|
|
where the package happens to be pip-installed, which is why it passed here and
|
|
died in CI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import LockConfig, measure_fit, report_fit
|
|
|
|
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
|
|
from screen_locker._heat_skip import build_heat_skip_content
|
|
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
|
from screen_locker._sick_dialog import SickDialogMixin
|
|
from screen_locker._surface_group import FrameGroup
|
|
from screen_locker._ui_flows import UIFlowsMixin
|
|
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
|
|
from screen_locker._ui_widgets import UIWidgetsMixin
|
|
from scripts._xvfb_reexec import reexec_under_xvfb
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
import tkinter as tk
|
|
|
|
from gatelock import FitResult, ScrollableSurface
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
INNER_ENV = "_SCREEN_FITS_CHECK_INNER"
|
|
|
|
# The panel this app has to support: the laptop it runs on. Shorter panels
|
|
# get gatelock's best-effort compaction (``gatelock._density``) but are not
|
|
# gated here -- no machine runs one, and a target nobody uses is a constraint
|
|
# that only ever costs.
|
|
SIZES: tuple[tuple[int, int], ...] = ((1366, 768),)
|
|
|
|
# Long enough to be realistic: a message that wraps to three lines is a
|
|
# different height than one that does not, and this is what the phone check
|
|
# actually hands the retry screen.
|
|
LONG_MESSAGE = (
|
|
"✘ Workout JSON is from 2026-07-27, not today\n"
|
|
"Neither StrongLifts nor RunnerUp found a workout today.\n"
|
|
"Go do your workout first!"
|
|
)
|
|
|
|
|
|
class ScreenHost(
|
|
UIWidgetsMixin,
|
|
UIFlowsMixin,
|
|
UIFlowsRelaxedMixin,
|
|
SickDialogMixin,
|
|
ManualWorkoutDialogMixin,
|
|
):
|
|
"""The real screen-painting mixins, over a throwaway surface.
|
|
|
|
Only the attributes the paint methods read are set. Nothing that reaches a
|
|
phone, the network or a background thread is called: each screen is
|
|
painted directly, with the message its flow would have been handed.
|
|
|
|
The ``paint_*`` wrappers exist so the measurement list below never reaches
|
|
into the mixins' private surface from outside the class.
|
|
"""
|
|
|
|
def __init__(self, surface: ScrollableSurface) -> None:
|
|
"""Point the mixins at one surface's content frame."""
|
|
self.root = surface.content.winfo_toplevel()
|
|
self.demo_mode = False
|
|
self.verify_only = False
|
|
# Deliberately NOT the real log.json: is_budget_exhausted() reads it,
|
|
# and the real file's content depends on today's actual usage --
|
|
# exhausting the manual-workout budget in production made this crash
|
|
# (paint_manual_form_with_error touches _mw_error_label, which the
|
|
# budget-exhausted branch of _show_manual_workout_form never sets).
|
|
# A path that can never exist keeps every measured screen's shape
|
|
# independent of live data, which is the same guarantee every other
|
|
# attribute on this class already gives (see class docstring).
|
|
self.log_file = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "screen_locker"
|
|
/ "fit_check_never_exists.json"
|
|
)
|
|
self._colors = LockConfig()
|
|
self.container = FrameGroup([surface.content])
|
|
self.lockout_time = 1800
|
|
self.workout_data: dict[str, str] = {}
|
|
|
|
# -- hooks the flows call on submit/cancel; no screen here is submitted --
|
|
|
|
def _on_manual_workout_saved(self, entry: dict[str, object]) -> None:
|
|
"""Unused: no form is submitted."""
|
|
|
|
def _on_manual_workout_cancelled(self) -> None:
|
|
"""Unused: BACK is never pressed."""
|
|
|
|
def unlock_screen(self) -> None:
|
|
"""Unused: nothing unlocks during a measurement."""
|
|
|
|
def close(self) -> None:
|
|
"""Unused: no screen is closed during a measurement."""
|
|
|
|
def _verify_phone_workout(self) -> tuple[str, str]:
|
|
"""Never reached -- present so a stray call fails loudly, not silently."""
|
|
msg = "the fit check must not run the phone check"
|
|
raise AssertionError(msg)
|
|
|
|
# -- public paint wrappers, one per screen under measurement --
|
|
|
|
def paint_retry(self) -> None:
|
|
"""Paint the "no workout found" retry screen."""
|
|
self._show_retry_and_sick(LONG_MESSAGE)
|
|
|
|
def paint_manual_form(self) -> None:
|
|
"""Paint the manual-workout evidence form."""
|
|
self._show_manual_workout_form()
|
|
|
|
def paint_manual_form_with_error(self) -> None:
|
|
"""Paint the form as it looks after a rejected submission.
|
|
|
|
Measured separately because the validation message is a line the empty
|
|
form does not have, and "fits until the user gets something wrong" is
|
|
not fitting.
|
|
"""
|
|
self._show_manual_workout_form()
|
|
self._mw_error_label.config(
|
|
text=(
|
|
"Start time must look like HH:MM, and every reflection needs "
|
|
f"at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} characters."
|
|
)
|
|
)
|
|
|
|
def paint_sick(self) -> None:
|
|
"""Paint the sick-day justification screen."""
|
|
self._show_sick_justification()
|
|
|
|
def paint_relaxed_retry(self) -> None:
|
|
"""Paint the relaxed-day (Tue-Thu) retry screen."""
|
|
self._show_relaxed_retry(LONG_MESSAGE, "no_workout")
|
|
|
|
def paint_verify_retry(self) -> None:
|
|
"""Paint the verify-mode retry screen."""
|
|
self._show_verify_retry(LONG_MESSAGE)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Screen:
|
|
"""One screen to measure, named by the paint call that produces it."""
|
|
|
|
name: str
|
|
paint: Callable[[ScreenHost], None]
|
|
|
|
|
|
def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc:
|
|
"""Paint the heat-skip screen and return the frame to measure.
|
|
|
|
Built through its extracted content builder because in production it owns
|
|
a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure.
|
|
"""
|
|
return build_heat_skip_content(
|
|
surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None
|
|
)
|
|
|
|
|
|
SCREENS: tuple[Screen, ...] = (
|
|
Screen("phone-check", ScreenHost.paint_phone_check),
|
|
Screen("no-workout-retry", ScreenHost.paint_retry),
|
|
Screen("manual-workout-form", ScreenHost.paint_manual_form),
|
|
Screen("manual-workout-form+error", ScreenHost.paint_manual_form_with_error),
|
|
Screen("sick-justification", ScreenHost.paint_sick),
|
|
Screen("lockout", ScreenHost.lockout),
|
|
Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry),
|
|
Screen("verify-retry", ScreenHost.paint_verify_retry),
|
|
)
|
|
|
|
# Screens built without a ScreenHost, measured through their own builder.
|
|
EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),)
|
|
|
|
|
|
def measure_all() -> list[FitResult]:
|
|
"""Measure every screen at every supported size."""
|
|
results: list[FitResult] = []
|
|
for width, height in SIZES:
|
|
for screen in SCREENS:
|
|
|
|
def build(surface: ScrollableSurface, paint: Screen = screen) -> None:
|
|
paint.paint(ScreenHost(surface))
|
|
|
|
results.append(measure_fit(screen.name, build, width=width, height=height))
|
|
results.extend(
|
|
measure_fit(name, build, width=width, height=height)
|
|
for name, build in EXTRA_SCREENS
|
|
)
|
|
return results
|
|
|
|
|
|
def main() -> int:
|
|
"""Measure every screen and fail if any of them overflows."""
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
|
|
if os.environ.get(INNER_ENV) != "1":
|
|
return reexec_under_xvfb(
|
|
inner_env=INNER_ENV, module="scripts.verify_screen_fits"
|
|
)
|
|
|
|
_logger.info("Measuring every lock screen against every supported panel:")
|
|
results = measure_all()
|
|
if report_fit(results):
|
|
_logger.info("\nOK: every screen fits without scrolling.")
|
|
return 0
|
|
_logger.error(
|
|
"\nAt least one screen needs a scrollbar to be usable. Shrink the "
|
|
"layout (design-system tokens, gatelock._density) rather than "
|
|
"accepting the scroll -- a lock screen the user has to scroll is the "
|
|
"defect this check exists to prevent."
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|