mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 16:23:02 +02:00
ScreenLocker now composes gatelock.GateRoot + gatelock.LockWindow for the actual lock window instead of the inline WindowSetupMixin mechanics; the verify/relaxed-day auxiliary windows (never the lock itself) stay as plain Tk windows. The hand-copied _log_integrity.py is deleted in favor of gatelock.log_integrity (the canonical, non-duplicated module). This is the second of three migrations (diet_guard done, wake_alarm next). Two deliberate behavior changes, both confirmed: - dependencies = [] (pure stdlib) now includes gatelock, a documented departure from the prior zero-deps stance. - production grab upgraded from single-attempt-then-local-fallback to diet_guard's retry-forever (robust to e.g. a fullscreen game holding the grab). Net hardening as a side effect: run()/close() now go through gatelock's signal-safe lifecycle, so SIGTERM/SIGINT restore VT switching on every exit path -- previously only a clean close() did, leaving VT switching disabled if the service was killed mid-lock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCdT46zV8hESDvbgYMGDLt
395 lines
14 KiB
Python
Executable File
395 lines
14 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
|
|
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import GateRoot, LockConfig, LockWindow
|
|
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
|
|
|
from screen_locker import _sick_tracker
|
|
from screen_locker._constants import (
|
|
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,
|
|
)
|
|
from screen_locker._early_bird import EarlyBirdMixin
|
|
from screen_locker._phone_verification import PhoneVerificationMixin
|
|
from screen_locker._shutdown import ShutdownMixin
|
|
from screen_locker._sick_dialog import SickDialogMixin
|
|
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._wake_state import has_workout_skip_today
|
|
from screen_locker._weekly_check import (
|
|
WEEKLY_WORKOUT_MINIMUM,
|
|
has_weekly_minimum,
|
|
is_relaxed_day,
|
|
)
|
|
from screen_locker._window_setup import WindowSetupMixin
|
|
|
|
if TYPE_CHECKING:
|
|
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(
|
|
EarlyBirdMixin,
|
|
WindowSetupMixin,
|
|
ShutdownMixin,
|
|
PhoneVerificationMixin,
|
|
SickDialogMixin,
|
|
UIFlowsMixin,
|
|
UIFlowsRelaxedMixin,
|
|
UIWidgetsMixin,
|
|
):
|
|
"""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()
|
|
script_dir = Path(__file__).resolve().parent
|
|
self.log_file = script_dir / "workout_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
|
|
if verify_only:
|
|
self._setup_verify_window()
|
|
elif self._relaxed_day_mode:
|
|
self._setup_relaxed_day_window()
|
|
else:
|
|
config = LockConfig(
|
|
mode="hard",
|
|
grab="local" if demo_mode else "global",
|
|
disable_vt=not demo_mode,
|
|
)
|
|
self._lock = LockWindow(self.root, config, hooks=self)
|
|
self._lock.setup()
|
|
if demo_mode:
|
|
self._setup_demo_close_button()
|
|
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
|
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
|
self._phone_future: Future[tuple[str, str]] | 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 _is_sick_day_log(self) -> bool:
|
|
"""Check if today's workout log is a sick day (not yet verified)."""
|
|
if not self.log_file.exists():
|
|
return False
|
|
try:
|
|
with self.log_file.open() as f:
|
|
logs = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
entry = logs.get(today)
|
|
if entry is None:
|
|
return False
|
|
return entry.get("workout_data", {}).get("type") == "sick_day"
|
|
|
|
def _check_early_exits(self, *, verify_only: bool) -> None:
|
|
"""Check startup conditions and exit early when appropriate."""
|
|
if verify_only:
|
|
if not self._is_sick_day_log():
|
|
_logger.info(
|
|
"No sick day logged today. Nothing to verify.",
|
|
)
|
|
sys.exit(0)
|
|
return
|
|
self._check_non_verify_exits()
|
|
|
|
def _check_today_state_exits(self) -> bool:
|
|
"""Handle early-bird and today's log states. Return True to stop startup."""
|
|
if self._is_early_bird_log() and not self._is_early_bird_time():
|
|
if self._try_auto_upgrade_early_bird():
|
|
_logger.info("Auto-upgraded early_bird entry to phone_verified.")
|
|
sys.exit(0)
|
|
return True
|
|
return False # Expired early bird, upgrade unavailable — full lock.
|
|
if self._is_early_bird_log():
|
|
_logger.info("Early bird window still active — skipping lock.")
|
|
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day():
|
|
_logger.info("Auto-upgraded today's sick_day entry to phone_verified.")
|
|
elif self.has_logged_today():
|
|
_logger.info("Workout already logged today. Skipping screen lock.")
|
|
elif has_workout_skip_today():
|
|
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
|
|
elif self._is_early_bird_time():
|
|
self._save_early_bird_log()
|
|
_logger.info("Early bird time — skipping lock, will re-check at 08:30.")
|
|
else:
|
|
return False
|
|
sys.exit(0)
|
|
return True
|
|
|
|
def _check_non_verify_exits(self) -> None:
|
|
"""Check all normal (non-verify) startup early-exit conditions."""
|
|
if self._is_scheduled_skip_today():
|
|
_logger.info("Today is a scheduled skip day. Skipping screen lock.")
|
|
sys.exit(0)
|
|
return
|
|
if self._check_today_state_exits():
|
|
return
|
|
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
|
|
if is_relaxed_day():
|
|
_logger.info("Relaxed day (Tue-Thu) - showing optional workout prompt.")
|
|
self._relaxed_day_mode = True
|
|
return
|
|
# Fri-Mon: skip lock when weekly minimum is already met.
|
|
if has_weekly_minimum(self.log_file):
|
|
_logger.info(
|
|
"Weekly minimum of %d workouts met. Skipping screen lock.",
|
|
WEEKLY_WORKOUT_MINIMUM,
|
|
)
|
|
sys.exit(0)
|
|
return
|
|
|
|
def _try_auto_upgrade_sick_day(self) -> bool:
|
|
"""Silently upgrade today's sick_day entry if phone shows a workout."""
|
|
try:
|
|
status, message = self._verify_phone_workout()
|
|
except (OSError, RuntimeError) as exc:
|
|
_logger.info("Auto-upgrade phone check failed: %s", exc)
|
|
return False
|
|
if status != "verified":
|
|
_logger.info(
|
|
"Auto-upgrade skipped (phone status=%s): %s",
|
|
status,
|
|
message,
|
|
)
|
|
return False
|
|
self.workout_data["type"] = "phone_verified"
|
|
self.workout_data["source"] = message
|
|
self.workout_data["after_sick_day"] = "true"
|
|
self._adjust_shutdown_time_later()
|
|
self.save_workout_log()
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Unlock, logging
|
|
# ------------------------------------------------------------------
|
|
|
|
def _try_adjust_shutdown_for_workout(self) -> bool:
|
|
"""Try to adjust shutdown time later for actual workouts."""
|
|
workout_type = self.workout_data.get("type", "")
|
|
if workout_type != "phone_verified":
|
|
return False
|
|
adjusted = self._adjust_shutdown_time_later()
|
|
if adjusted:
|
|
_logger.info("Shutdown time moved 1.5 hours later as workout reward")
|
|
return adjusted
|
|
|
|
def _clear_debt_on_verified_workout(self) -> int | None:
|
|
"""Decrement workout debt by one for a verified workout.
|
|
|
|
Returns the new debt count, or ``None`` when this wasn't a
|
|
phone-verified workout.
|
|
"""
|
|
if self.workout_data.get("type") != "phone_verified":
|
|
return None
|
|
history = _sick_tracker.load_history()
|
|
if history.debt <= 0:
|
|
return 0
|
|
new_debt = _sick_tracker.clear_one_debt(history)
|
|
_sick_tracker.save_history(history)
|
|
return new_debt
|
|
|
|
def unlock_screen(self) -> None:
|
|
"""Save workout log and display success message."""
|
|
self.save_workout_log()
|
|
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
|
|
new_debt = self._clear_debt_on_verified_workout()
|
|
self.clear_container()
|
|
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
|
|
if shutdown_adjusted:
|
|
self._text(
|
|
"Shutdown time +1.5h later! 🎁",
|
|
font_size=24,
|
|
color="#ffaa00",
|
|
)
|
|
if new_debt is not None:
|
|
self._text(
|
|
f"Workout debt: {new_debt}",
|
|
font_size=20,
|
|
color="#ffaa00" if new_debt > 0 else "#888888",
|
|
)
|
|
self._text("Screen Unlocked!", font_size=36, pady=20)
|
|
if self.workout_data.get("type") == "phone_verified":
|
|
self.root.after(
|
|
1500,
|
|
lambda: self._show_commitment_prompt(on_done=self.close),
|
|
)
|
|
else:
|
|
self.root.after(1500, self.close)
|
|
|
|
def has_logged_today(self) -> bool:
|
|
"""Check if workout has been logged today with valid HMAC."""
|
|
if not self.log_file.exists():
|
|
return False
|
|
|
|
try:
|
|
with self.log_file.open() as f:
|
|
logs = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
else:
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
entry = logs.get(today)
|
|
if entry is None:
|
|
return False
|
|
if verify_entry_hmac(entry):
|
|
return entry.get("workout_data", {}).get("type") != "early_bird"
|
|
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry:
|
|
_logger.info(
|
|
"HMAC key unavailable — accepting unsigned entry",
|
|
)
|
|
return entry.get("workout_data", {}).get("type") != "early_bird"
|
|
_logger.warning(
|
|
"HMAC verification failed for today's log entry",
|
|
)
|
|
return False
|
|
|
|
def _load_existing_logs(self) -> dict:
|
|
"""Load existing workout logs from file."""
|
|
if not self.log_file.exists():
|
|
return {}
|
|
try:
|
|
with self.log_file.open() as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
def _is_scheduled_skip_today(self) -> bool:
|
|
"""Return True if today's date is listed in the scheduled skips file."""
|
|
if not SCHEDULED_SKIPS_FILE.exists():
|
|
return False
|
|
try:
|
|
with SCHEDULED_SKIPS_FILE.open() as f:
|
|
skips = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
return today in skips
|
|
|
|
def save_workout_log(self) -> None:
|
|
"""Save workout data to log file with HMAC signature."""
|
|
logs = self._load_existing_logs()
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
entry: dict[str, object] = {
|
|
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
"workout_data": self.workout_data,
|
|
}
|
|
signature = compute_entry_hmac(entry)
|
|
if signature is not None:
|
|
entry["hmac"] = signature
|
|
else:
|
|
_logger.warning("HMAC key unavailable — saving unsigned entry")
|
|
logs[today] = entry
|
|
try:
|
|
with self.log_file.open("w") as f:
|
|
json.dump(logs, f, indent=2)
|
|
except OSError as e:
|
|
_logger.warning("Could not save workout log: %s", e)
|
|
|
|
def close(self) -> None:
|
|
"""Close the application and exit."""
|
|
if self._lock is not None:
|
|
self._lock.close()
|
|
else:
|
|
self.root.destroy()
|
|
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__":
|
|
# Check for --production flag
|
|
demo_mode = True # Default to demo mode for safety
|
|
verify_only = "--verify-workout" in sys.argv
|
|
|
|
if "--production" in sys.argv:
|
|
demo_mode = False
|
|
|
|
locker = ScreenLocker(
|
|
demo_mode=demo_mode,
|
|
verify_only=verify_only,
|
|
)
|
|
locker.run()
|