mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 08:51:01 +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
210 lines
7.5 KiB
Python
Executable File
210 lines
7.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Screen locker with workout verification for Arch Linux / i3wm.
|
|
|
|
Requires user to log their workout to unlock the screen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import (
|
|
GateRoot,
|
|
LockConfig,
|
|
LockWindow,
|
|
)
|
|
|
|
from screen_locker import _instance
|
|
from screen_locker._auto_upgrade import AutoUpgradeMixin
|
|
from screen_locker._constants import (
|
|
EARLY_BIRD_END_HOUR,
|
|
EARLY_BIRD_END_MINUTE,
|
|
EARLY_BIRD_START_HOUR,
|
|
HMAC_KEY_FILE,
|
|
INSTANCE_LOCK_FILE,
|
|
MAX_CLOCK_SKEW_SECONDS,
|
|
MIN_WORKOUT_DURATION_MINUTES,
|
|
PHONE_PENALTY_DELAY_DEMO,
|
|
PHONE_PENALTY_DELAY_PRODUCTION,
|
|
SCHEDULED_SKIPS_FILE,
|
|
SICK_LOCKOUT_SECONDS,
|
|
)
|
|
from screen_locker._early_bird import EarlyBirdMixin
|
|
from screen_locker._heat_skip import HeatSkipMixin
|
|
from screen_locker._log_mixin import LogMixin
|
|
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
|
from screen_locker._phone_verification import PhoneVerificationMixin
|
|
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
|
from screen_locker._shutdown import ShutdownMixin
|
|
from screen_locker._sick_dialog import SickDialogMixin
|
|
from screen_locker._startup_checks import StartupChecksMixin
|
|
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 screen_locker._unlock_view import UnlockViewMixin
|
|
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM
|
|
from screen_locker._window_setup import WindowSetupMixin
|
|
from screen_locker._workout_credit import WorkoutCreditMixin
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from concurrent.futures import Future
|
|
|
|
__all__ = [
|
|
"EARLY_BIRD_END_HOUR",
|
|
"EARLY_BIRD_END_MINUTE",
|
|
"EARLY_BIRD_START_HOUR",
|
|
"HMAC_KEY_FILE",
|
|
"MAX_CLOCK_SKEW_SECONDS",
|
|
"MIN_WORKOUT_DURATION_MINUTES",
|
|
"PHONE_PENALTY_DELAY_DEMO",
|
|
"PHONE_PENALTY_DELAY_PRODUCTION",
|
|
"SCHEDULED_SKIPS_FILE",
|
|
"SICK_LOCKOUT_SECONDS",
|
|
"WEEKLY_WORKOUT_MINIMUM",
|
|
"ScreenLocker",
|
|
]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _assert_not_under_pytest() -> None:
|
|
"""Raise if the screen locker is being created inside a pytest run.
|
|
|
|
Defence-in-depth: prevents a real fullscreen Tk window from locking
|
|
the user's screen when tests forget to mock ``tk.Tk``.
|
|
The check is cheap (one dict lookup) and only fires during testing.
|
|
"""
|
|
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
|
|
msg = (
|
|
"SAFETY: ScreenLocker.__init__ called under pytest with "
|
|
"real tkinter — tk.Tk is not mocked"
|
|
)
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
class ScreenLocker(
|
|
AutoUpgradeMixin,
|
|
EarlyBirdMixin,
|
|
HeatSkipMixin,
|
|
LogMixin,
|
|
ManualWorkoutDialogMixin,
|
|
WindowSetupMixin,
|
|
WorkoutCreditMixin,
|
|
ShutdownMixin,
|
|
PhoneVerificationMixin,
|
|
RunnerUpVerificationMixin,
|
|
SickDialogMixin,
|
|
StartupChecksMixin,
|
|
UIFlowsMixin,
|
|
UIFlowsRelaxedMixin,
|
|
UIWidgetsMixin,
|
|
UnlockViewMixin,
|
|
):
|
|
"""Screen locker that requires workout logging to unlock."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
demo_mode: bool = True,
|
|
verify_only: bool = False,
|
|
) -> None:
|
|
"""Initialize screen locker with optional demo mode."""
|
|
_assert_not_under_pytest()
|
|
# Guards against a stray re-arm racing a second process into building
|
|
# a competing lock window (the actual mechanism behind "unlocked, then
|
|
# locked again seconds later" -- see 2026-08-21 incident notes). Not
|
|
# released in a try/finally: standing down here calls sys.exit(0)
|
|
# immediately, and process exit already drops the flock for free.
|
|
instance_lock = _instance.acquire(INSTANCE_LOCK_FILE)
|
|
if instance_lock is None:
|
|
# _instance.acquire already logged who holds it; standing down
|
|
# here (rather than building a second competing window) is the
|
|
# fix for the actual mechanism behind "unlocked, then locked
|
|
# again seconds later".
|
|
_logger.warning(
|
|
"standing down: another workout-locker instance already holds %s",
|
|
INSTANCE_LOCK_FILE,
|
|
)
|
|
sys.exit(0)
|
|
self._instance_lock: _instance.InstanceLock = instance_lock
|
|
script_dir = Path(__file__).resolve().parent
|
|
self.log_file = script_dir / "log.json"
|
|
self.verify_only = verify_only
|
|
self.workout_data: dict[str, str] = {}
|
|
self._relaxed_day_mode: bool = False
|
|
self._check_early_exits(verify_only=verify_only)
|
|
self.root = GateRoot()
|
|
self.root.on_callback_error = self.on_callback_error
|
|
title_suffix = (
|
|
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
|
)
|
|
self.root.title("Workout Locker" + title_suffix)
|
|
self.demo_mode = demo_mode
|
|
self.lockout_time = 10 if demo_mode else 1800
|
|
self._lock: LockWindow | None = None
|
|
# One shared token source for every window/widget this app creates,
|
|
# lock or not -- see the `unified-design-system` skill. `mode`/`grab`/
|
|
# `disable_vt` only matter for the fullscreen lock window itself; the
|
|
# color/font fields apply everywhere, so this is built unconditionally.
|
|
self._colors = LockConfig(
|
|
mode="hard",
|
|
grab="local" if demo_mode else "global",
|
|
disable_vt=not demo_mode,
|
|
)
|
|
# Built before setup(), which calls build_surface per live output.
|
|
self.container = FrameGroup([])
|
|
if verify_only:
|
|
self._setup_verify_window()
|
|
elif self._relaxed_day_mode:
|
|
self._setup_relaxed_day_window()
|
|
else:
|
|
self._lock = self._build_lock_window()
|
|
if demo_mode:
|
|
self._setup_demo_close_button()
|
|
self._ensure_container()
|
|
self._phone_future: Future[tuple[str, str]] | None = None
|
|
self._runnerup_future: Future[tuple[str, str]] | None = None
|
|
self._runnerup_on_failure: Callable[[], None] | None = None
|
|
if verify_only:
|
|
self._start_verify_workout_check()
|
|
elif self._relaxed_day_mode:
|
|
self._start_relaxed_day_flow()
|
|
else:
|
|
self._start_phone_check()
|
|
# Always set on this branch; guard only for mypy (can't narrow
|
|
# across two separate if/elif/else statements).
|
|
if self._lock is not None: # pragma: no branch
|
|
self._lock.grab_input()
|
|
|
|
def close(self) -> None:
|
|
"""Close the application and exit."""
|
|
if self._lock is not None:
|
|
self._lock.close()
|
|
else:
|
|
self.root.destroy()
|
|
self._instance_lock.release()
|
|
# The lock screen coming down is the end of enforcement for this run,
|
|
# so it belongs in the journal next to the decision that raised it —
|
|
# otherwise "the lock closed" and "the lock never opened" look alike.
|
|
_logger.warning("Lock window closed — enforcement for this run is over.")
|
|
sys.exit(0)
|
|
|
|
def run(self) -> None:
|
|
"""Start the Tkinter main event loop."""
|
|
if self._lock is not None:
|
|
self._lock.run()
|
|
else:
|
|
self.root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from screen_locker._cli import main
|
|
|
|
main(ScreenLocker, sys.argv)
|