mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
The grab axes are grab=GrabPolicy(kind=..., disable_vt=..., preempt_weaker_holder=...) and every colour read goes through config.palette; nothing about the lock's behaviour changed. Also carries the pylint hook on 4.0.8 with the source-only scope config; the gate stays at 8.0 until the remaining findings are cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
250 lines
9.5 KiB
Python
Executable File
250 lines
9.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
|
|
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._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._decision_log import record_run_aborted
|
|
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)
|
|
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
|
|
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=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 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)
|