#!/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 import os from pathlib import Path import sys import tkinter as tk from typing import TYPE_CHECKING from gatelock import ( GateRoot, GrabPolicy, LockConfig, LockWindow, ) from screen_locker import _instance from screen_locker._auto_upgrade import AutoUpgradeMixin from screen_locker._carrot_watch import watch_for_carrot 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, ) from screen_locker._decision_log import record_run_aborted from screen_locker._early_bird import EarlyBirdMixin from screen_locker._heat_skip import HeatSkipMixin from screen_locker._hold import watch_for_hold 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._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", "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, StartupChecksMixin, UIFlowsMixin, UIFlowsRelaxedMixin, UIWidgetsMixin, UnlockViewMixin, ): """Screen locker that requires workout logging to unlock.""" def __init__( self, *, demo_mode: bool = True, ) -> 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.workout_data: dict[str, str] = {} self._relaxed_day_mode: bool = False self._check_early_exits() try: self.root = GateRoot() except tk.TclError as exc: _logger.warning( "Could not open a window on DISPLAY=%s (%s) — this run cannot " "enforce anything, so it stops cleanly instead of failing and " "being restarted in a loop.", os.environ.get("DISPLAY"), exc, ) # No reachable X server. This used to escape as an unhandled # TclError and exit 1, which `Restart=on-failure` turned into a # storm: on 2026-08-30 the display was down from 02:00 and the # unit restarted ~1695 times in three hours, ~6.3s apart -- just # slow enough to stay under the default StartLimitBurst=5/10s, and # fast enough that the identical records evicted the entire # decision trail behind them. # # There is no screen to lock, so there is nothing this run can # enforce; exiting 0 (which RestartPreventExitStatus=0 honours) # is the correct answer, and recording it keeps "could not run" # from looking like "chose not to lock". record_run_aborted( "no_display", f"No usable X display ({exc}) — nothing to lock, so this run " f"stops instead of restarting. DISPLAY={os.environ.get('DISPLAY')!r}", ) self._instance_lock.release() sys.exit(0) self.root.on_callback_error = self.on_callback_error self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else "")) 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=GrabPolicy( kind="local" if demo_mode else "global", disable_vt=not demo_mode, # Opt in: a weaker holder (diet_guard, leetcode_guard) that # already owns the grab must stand down rather than block # enforcement forever -- the 2026-08-21 lockout was diet_guard # holding it for hours. Safe in this direction only; gatelock # never signals a holder that outranks us, so wake_alarm can # still take the screen. preempt_weaker_holder=not demo_mode, ), ) # Built before setup(), which calls build_surface per live output. self.container = FrameGroup([]) if 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 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() watch_for_hold(self.root, self.close) watch_for_carrot(self.root, self._stand_down_for_carrot) 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)