screen-locker/screen_locker/screen_lock.py
Krzysztof kuhy Rudnicki 856f0c0774 Lift armed lock when carrot lands late
Poll morning_session every 15s (05:00-11:00) while the lock is
up; on a signed skip bank the early-bird marker, record
wake_alarm_skip_late and leave through the normal close().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:11:39 +01:00

241 lines
9.2 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
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)