screen-locker/screen_locker/screen_lock.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

237 lines
9.0 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,
)
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._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()
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)