2025-11-18 18:07:15 +01:00
|
|
|
#!/usr/bin/env python3
|
2025-11-30 14:53:09 +01:00
|
|
|
"""Screen locker with workout verification for Arch Linux / i3wm.
|
|
|
|
|
|
2025-11-18 18:07:15 +01:00
|
|
|
Requires user to log their workout to unlock the screen.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import contextlib
|
2025-11-30 15:30:25 +01:00
|
|
|
from datetime import datetime, timezone
|
2025-11-18 18:07:15 +01:00
|
|
|
import json
|
2025-11-30 14:36:13 +01:00
|
|
|
import logging
|
2025-11-30 23:03:03 +01:00
|
|
|
from pathlib import Path
|
2025-11-30 13:42:16 +01:00
|
|
|
import sys
|
|
|
|
|
import tkinter as tk
|
2026-02-14 18:42:20 +01:00
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
2026-03-18 22:20:05 +01:00
|
|
|
from python_pkg.screen_locker._constants import (
|
2026-05-01 19:07:34 +02:00
|
|
|
EARLY_BIRD_END_HOUR,
|
|
|
|
|
EARLY_BIRD_END_MINUTE,
|
|
|
|
|
EARLY_BIRD_START_HOUR,
|
2026-04-09 21:44:13 +02:00
|
|
|
HMAC_KEY_FILE,
|
|
|
|
|
MAX_CLOCK_SKEW_SECONDS,
|
|
|
|
|
MIN_WORKOUT_DURATION_MINUTES,
|
2026-03-16 22:46:48 +01:00
|
|
|
PHONE_PENALTY_DELAY_DEMO,
|
|
|
|
|
PHONE_PENALTY_DELAY_PRODUCTION,
|
|
|
|
|
SICK_LOCKOUT_SECONDS,
|
|
|
|
|
STRONGLIFTS_DB_REMOTE,
|
2026-02-23 22:50:42 +01:00
|
|
|
)
|
2026-04-09 21:44:13 +02:00
|
|
|
from python_pkg.screen_locker._log_integrity import (
|
|
|
|
|
compute_entry_hmac,
|
|
|
|
|
verify_entry_hmac,
|
|
|
|
|
)
|
|
|
|
|
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
|
|
|
|
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
|
|
|
|
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
2026-04-12 20:45:24 +02:00
|
|
|
from python_pkg.wake_alarm._state import has_workout_skip_today
|
2026-04-09 21:44:13 +02:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import Callable
|
|
|
|
|
from concurrent.futures import Future
|
2026-03-18 22:20:05 +01:00
|
|
|
|
|
|
|
|
__all__ = [
|
2026-05-01 19:07:34 +02:00
|
|
|
"EARLY_BIRD_END_HOUR",
|
|
|
|
|
"EARLY_BIRD_END_MINUTE",
|
|
|
|
|
"EARLY_BIRD_START_HOUR",
|
2026-04-09 21:44:13 +02:00
|
|
|
"HMAC_KEY_FILE",
|
|
|
|
|
"MAX_CLOCK_SKEW_SECONDS",
|
|
|
|
|
"MIN_WORKOUT_DURATION_MINUTES",
|
2026-03-18 22:20:05 +01:00
|
|
|
"PHONE_PENALTY_DELAY_DEMO",
|
|
|
|
|
"PHONE_PENALTY_DELAY_PRODUCTION",
|
|
|
|
|
"SICK_LOCKOUT_SECONDS",
|
|
|
|
|
"STRONGLIFTS_DB_REMOTE",
|
|
|
|
|
"ScreenLocker",
|
|
|
|
|
]
|
2025-11-30 15:01:14 +01:00
|
|
|
|
2026-03-16 22:46:48 +01:00
|
|
|
_logger = logging.getLogger(__name__)
|
2026-02-14 18:42:20 +01:00
|
|
|
|
2025-11-18 18:07:15 +01:00
|
|
|
|
chore: optimize pre-commit, remove tracked binaries, fix lint issues
- Move slow hooks (mypy, pylint, bandit, pytest, prettier) to pre-push stage
- Remove redundant autoflake (ruff covers F401/F841)
- Fix shellcheck OOM by batching files with xargs -n 40
- Remove tracked .o, .wav, .pyc binaries from git
- Move pomodoro wav files to ../testsAndMisc_binaries/ with symlinks
- Add *.o, *.so, *.a to .gitignore
- Refactor hltb._pick_best_hltb_entry to fix C901/PLR0911/SIM102
- Fix SC2034 warnings in gif_to_square.sh and upgrade.sh
- Add disk_cleanup_check.sh script
- Various test and code improvements across screen_locker,
steam_backlog_enforcer, word_frequency, moviepy_showcase
2026-04-10 18:44:51 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 22:46:48 +01:00
|
|
|
class ScreenLocker(
|
|
|
|
|
ShutdownMixin,
|
|
|
|
|
PhoneVerificationMixin,
|
|
|
|
|
UIFlowsMixin,
|
|
|
|
|
):
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Screen locker that requires workout logging to unlock."""
|
|
|
|
|
|
2026-03-29 22:50:24 +02:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
demo_mode: bool = True,
|
|
|
|
|
verify_only: bool = False,
|
|
|
|
|
) -> None:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Initialize screen locker with optional demo mode."""
|
chore: optimize pre-commit, remove tracked binaries, fix lint issues
- Move slow hooks (mypy, pylint, bandit, pytest, prettier) to pre-push stage
- Remove redundant autoflake (ruff covers F401/F841)
- Fix shellcheck OOM by batching files with xargs -n 40
- Remove tracked .o, .wav, .pyc binaries from git
- Move pomodoro wav files to ../testsAndMisc_binaries/ with symlinks
- Add *.o, *.so, *.a to .gitignore
- Refactor hltb._pick_best_hltb_entry to fix C901/PLR0911/SIM102
- Fix SC2034 warnings in gif_to_square.sh and upgrade.sh
- Add disk_cleanup_check.sh script
- Various test and code improvements across screen_locker,
steam_backlog_enforcer, word_frequency, moviepy_showcase
2026-04-10 18:44:51 +02:00
|
|
|
_assert_not_under_pytest()
|
2025-11-30 23:03:03 +01:00
|
|
|
script_dir = Path(__file__).resolve().parent
|
|
|
|
|
self.log_file = script_dir / "workout_log.json"
|
2026-03-29 22:50:24 +02:00
|
|
|
self.verify_only = verify_only
|
2026-05-01 19:07:34 +02:00
|
|
|
self.workout_data: dict[str, str] = {}
|
|
|
|
|
self._check_early_exits(verify_only=verify_only)
|
2025-11-18 18:07:15 +01:00
|
|
|
self.root = tk.Tk()
|
2026-03-29 22:50:24 +02:00
|
|
|
title_suffix = (
|
|
|
|
|
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
|
|
|
|
)
|
|
|
|
|
self.root.title("Workout Locker" + title_suffix)
|
2025-11-18 18:07:15 +01:00
|
|
|
self.demo_mode = demo_mode
|
2026-02-14 18:42:20 +01:00
|
|
|
self.lockout_time = 10 if demo_mode else 1800
|
2026-03-29 22:50:24 +02:00
|
|
|
if verify_only:
|
|
|
|
|
self._setup_verify_window()
|
|
|
|
|
else:
|
|
|
|
|
self._setup_window()
|
|
|
|
|
if demo_mode:
|
|
|
|
|
self._setup_demo_close_button()
|
2026-02-14 18:42:20 +01:00
|
|
|
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
|
|
|
|
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
2026-02-24 21:11:05 +01:00
|
|
|
self._phone_future: Future[tuple[str, str]] | None = None
|
2026-03-29 22:50:24 +02:00
|
|
|
if verify_only:
|
|
|
|
|
self._start_verify_workout_check()
|
|
|
|
|
else:
|
|
|
|
|
self._start_phone_check()
|
|
|
|
|
self._grab_input()
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _setup_window(self) -> None:
|
|
|
|
|
"""Configure the window for fullscreen lock."""
|
|
|
|
|
screen_w = self.root.winfo_screenwidth()
|
|
|
|
|
screen_h = self.root.winfo_screenheight()
|
Reduce per-file-ignores by fixing lint violations across codebase
Fix ruff violations in ~15 source files and ~60+ test files to minimize
per-file-ignores in pyproject.toml. Remaining ignores are justified with
comments explaining why each suppression is necessary.
Source fixes: FBT003 (keyword args), S310 (URL validation), SLF001
(private access), T201 (print→logging), C901 (complexity), E501 (line
length), E402 (import order).
Test fixes: SIM117 (combined with), FBT (boolean args), PERF203 (try in
loop), S310/S607 (URLs/executables), E402/E501 (imports/lines), S108
(tmp paths), PLR0913 (too many args), ARG (unused args), ANN (type
annotations), RUF059 (unused unpacked vars), PT019 (fixture naming).
Remaining per-file-ignores (with justifications):
- Tests: ARG, D, PLC0415, PLR2004, S101, SLF001
- music_gen sources: PLC0415 (heavy ML lazy imports)
- moviepy_showcase: PLC0415 (circular dependency)
- generate_images: PLR0913 (matplotlib helpers need many params)
- praca_magisterska_video: E501, E402 (long paths, mpl.use)
2026-03-25 18:58:05 +01:00
|
|
|
self.root.overrideredirect(boolean=True)
|
2026-02-14 18:42:20 +01:00
|
|
|
self.root.geometry(f"{screen_w}x{screen_h}+0+0")
|
Reduce per-file-ignores by fixing lint violations across codebase
Fix ruff violations in ~15 source files and ~60+ test files to minimize
per-file-ignores in pyproject.toml. Remaining ignores are justified with
comments explaining why each suppression is necessary.
Source fixes: FBT003 (keyword args), S310 (URL validation), SLF001
(private access), T201 (print→logging), C901 (complexity), E501 (line
length), E402 (import order).
Test fixes: SIM117 (combined with), FBT (boolean args), PERF203 (try in
loop), S310/S607 (URLs/executables), E402/E501 (imports/lines), S108
(tmp paths), PLR0913 (too many args), ARG (unused args), ANN (type
annotations), RUF059 (unused unpacked vars), PT019 (fixture naming).
Remaining per-file-ignores (with justifications):
- Tests: ARG, D, PLC0415, PLR2004, S101, SLF001
- music_gen sources: PLC0415 (heavy ML lazy imports)
- moviepy_showcase: PLC0415 (circular dependency)
- generate_images: PLR0913 (matplotlib helpers need many params)
- praca_magisterska_video: E501, E402 (long paths, mpl.use)
2026-03-25 18:58:05 +01:00
|
|
|
self.root.attributes(fullscreen=True)
|
|
|
|
|
self.root.attributes(topmost=True)
|
2025-11-30 13:42:16 +01:00
|
|
|
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
|
|
|
|
|
2026-03-29 22:50:24 +02:00
|
|
|
def _setup_verify_window(self) -> None:
|
|
|
|
|
"""Configure window for post-sick-day workout verification."""
|
|
|
|
|
self.root.geometry("600x400")
|
|
|
|
|
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-05-01 19:07:34 +02:00
|
|
|
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)
|
|
|
|
|
else:
|
|
|
|
|
self._check_non_verify_exits()
|
|
|
|
|
|
|
|
|
|
def _check_non_verify_exits(self) -> None:
|
|
|
|
|
"""Check all normal (non-verify) startup early-exit conditions."""
|
|
|
|
|
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)
|
|
|
|
|
elif self._is_early_bird_log():
|
|
|
|
|
_logger.info("Early bird window still active — skipping lock.")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
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.",
|
|
|
|
|
)
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
elif self.has_logged_today():
|
|
|
|
|
_logger.info("Workout already logged today. Skipping screen lock.")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
elif has_workout_skip_today():
|
|
|
|
|
_logger.info(
|
|
|
|
|
"Wake alarm earned workout skip. Skipping screen lock.",
|
|
|
|
|
)
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
elif self._is_early_bird_time():
|
|
|
|
|
self._save_early_bird_log()
|
|
|
|
|
_logger.info(
|
|
|
|
|
"Early bird time — skipping lock, will re-check at 08:30.",
|
|
|
|
|
)
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
def _get_local_time_minutes(self) -> int:
|
|
|
|
|
"""Return current local time as minutes from midnight."""
|
|
|
|
|
now = datetime.now(tz=timezone.utc).astimezone()
|
|
|
|
|
return now.hour * 60 + now.minute
|
|
|
|
|
|
|
|
|
|
def _is_early_bird_time(self) -> bool:
|
|
|
|
|
"""Return True if current local time is in the early bird window.
|
|
|
|
|
|
|
|
|
|
The early bird window is EARLY_BIRD_START_HOUR (5 AM) up to but not
|
|
|
|
|
including EARLY_BIRD_END_HOUR:EARLY_BIRD_END_MINUTE (8:30 AM).
|
|
|
|
|
"""
|
|
|
|
|
minutes = self._get_local_time_minutes()
|
|
|
|
|
start = EARLY_BIRD_START_HOUR * 60
|
|
|
|
|
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
|
|
|
|
return start <= minutes < end
|
|
|
|
|
|
|
|
|
|
def _is_early_bird_log(self) -> bool:
|
|
|
|
|
"""Check if today's workout log entry is an early_bird provisional entry."""
|
|
|
|
|
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") == "early_bird"
|
|
|
|
|
|
|
|
|
|
def _save_early_bird_log(self) -> None:
|
|
|
|
|
"""Save an early_bird provisional entry to the workout log."""
|
|
|
|
|
self.workout_data = {"type": "early_bird"}
|
|
|
|
|
self.save_workout_log()
|
|
|
|
|
|
|
|
|
|
def _try_auto_upgrade_early_bird(self) -> bool:
|
|
|
|
|
"""Silently upgrade today's early_bird entry if phone shows a workout.
|
|
|
|
|
|
|
|
|
|
Called at 8:30 AM when the early bird grace period expires. If the
|
|
|
|
|
phone shows a completed workout, upgrades the entry to phone_verified
|
|
|
|
|
and rewards with a later shutdown time. Otherwise returns False so the
|
|
|
|
|
caller can show the lock screen.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if the entry was upgraded to phone_verified, False otherwise.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
status, message = self._verify_phone_workout()
|
|
|
|
|
except (OSError, RuntimeError) as exc:
|
|
|
|
|
_logger.info("Early bird upgrade phone check failed: %s", exc)
|
|
|
|
|
return False
|
|
|
|
|
if status != "verified":
|
|
|
|
|
_logger.info(
|
|
|
|
|
"Early bird 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_early_bird"] = "true"
|
|
|
|
|
self._adjust_shutdown_time_later()
|
|
|
|
|
self.save_workout_log()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _try_auto_upgrade_sick_day(self) -> bool:
|
|
|
|
|
"""Silently upgrade today's sick_day entry if phone shows a workout.
|
|
|
|
|
|
|
|
|
|
Runs at startup without any UI so that a real workout logged on the
|
|
|
|
|
phone retroactively replaces an earlier sick_day entry (for example
|
|
|
|
|
when a previous bug forced the user into the sick path).
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if the entry was upgraded to phone_verified, False otherwise.
|
|
|
|
|
On False the caller should fall through to the normal startup
|
|
|
|
|
path (which will skip the lock because the sick_day entry still
|
|
|
|
|
satisfies ``has_logged_today``).
|
|
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _setup_demo_close_button(self) -> None:
|
|
|
|
|
"""Add close button for demo mode."""
|
|
|
|
|
close_btn = tk.Button(
|
|
|
|
|
self.root,
|
|
|
|
|
text="✕ Close Demo",
|
|
|
|
|
font=("Arial", 12),
|
|
|
|
|
bg="#ff4444",
|
|
|
|
|
fg="white",
|
|
|
|
|
command=self.close,
|
|
|
|
|
cursor="hand2",
|
|
|
|
|
)
|
|
|
|
|
close_btn.place(x=10, y=10)
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _grab_input(self) -> None:
|
|
|
|
|
"""Force input focus to the locker window."""
|
2025-11-18 18:07:15 +01:00
|
|
|
self.root.update_idletasks()
|
|
|
|
|
self.root.focus_force()
|
2026-02-24 21:11:05 +01:00
|
|
|
if self.demo_mode:
|
|
|
|
|
with contextlib.suppress(tk.TclError):
|
|
|
|
|
self.root.grab_set()
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
self.root.grab_set_global()
|
|
|
|
|
except tk.TclError:
|
|
|
|
|
_logger.warning("Global grab failed, falling back to local grab")
|
|
|
|
|
with contextlib.suppress(tk.TclError):
|
|
|
|
|
self.root.grab_set()
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def clear_container(self) -> None:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Remove all widgets from the main container."""
|
2025-11-18 18:07:15 +01:00
|
|
|
for widget in self.container.winfo_children():
|
|
|
|
|
widget.destroy()
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# UI helper methods
|
|
|
|
|
# ------------------------------------------------------------------
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _label(
|
|
|
|
|
self,
|
|
|
|
|
text: str,
|
|
|
|
|
*,
|
|
|
|
|
font_size: int = 36,
|
|
|
|
|
color: str = "white",
|
|
|
|
|
pady: int = 20,
|
|
|
|
|
) -> tk.Label:
|
|
|
|
|
"""Create and pack a bold label in the container."""
|
|
|
|
|
label = tk.Label(
|
2025-11-18 18:07:15 +01:00
|
|
|
self.container,
|
2026-02-14 18:42:20 +01:00
|
|
|
text=text,
|
|
|
|
|
font=("Arial", font_size, "bold"),
|
|
|
|
|
fg=color,
|
2025-11-30 13:42:16 +01:00
|
|
|
bg="#1a1a1a",
|
2025-11-18 18:07:15 +01:00
|
|
|
)
|
2026-02-14 18:42:20 +01:00
|
|
|
label.pack(pady=pady)
|
|
|
|
|
return label
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _text(
|
|
|
|
|
self,
|
|
|
|
|
text: str,
|
|
|
|
|
*,
|
|
|
|
|
font_size: int = 18,
|
|
|
|
|
color: str = "white",
|
|
|
|
|
pady: int = 10,
|
|
|
|
|
) -> tk.Label:
|
|
|
|
|
"""Create and pack a non-bold text label in the container."""
|
|
|
|
|
label = tk.Label(
|
|
|
|
|
self.container,
|
|
|
|
|
text=text,
|
|
|
|
|
font=("Arial", font_size),
|
|
|
|
|
fg=color,
|
|
|
|
|
bg="#1a1a1a",
|
2025-11-18 18:07:15 +01:00
|
|
|
)
|
2026-02-14 18:42:20 +01:00
|
|
|
label.pack(pady=pady)
|
|
|
|
|
return label
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _button(
|
|
|
|
|
self,
|
|
|
|
|
parent: tk.Widget,
|
|
|
|
|
text: str,
|
|
|
|
|
*,
|
|
|
|
|
bg: str,
|
|
|
|
|
command: Callable[[], None],
|
|
|
|
|
width: int = 10,
|
|
|
|
|
) -> tk.Button:
|
|
|
|
|
"""Create a styled button (caller must pack)."""
|
|
|
|
|
return tk.Button(
|
|
|
|
|
parent,
|
|
|
|
|
text=text,
|
2025-11-30 13:42:16 +01:00
|
|
|
font=("Arial", 24, "bold"),
|
2026-02-14 18:42:20 +01:00
|
|
|
bg=bg,
|
2025-11-30 13:42:16 +01:00
|
|
|
fg="white",
|
2026-02-14 18:42:20 +01:00
|
|
|
width=width,
|
|
|
|
|
command=command,
|
2026-01-06 13:10:54 +01:00
|
|
|
cursor="hand2" if self.demo_mode else "",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _button_row(self) -> tk.Frame:
|
|
|
|
|
"""Create and pack a horizontal button container."""
|
|
|
|
|
frame = tk.Frame(self.container, bg="#1a1a1a")
|
|
|
|
|
frame.pack(pady=20)
|
|
|
|
|
return frame
|
2026-01-06 13:10:54 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
# ------------------------------------------------------------------
|
2026-03-27 15:54:01 +01:00
|
|
|
# Unlock, logging
|
2026-02-14 18:42:20 +01:00
|
|
|
# ------------------------------------------------------------------
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
def _try_adjust_shutdown_for_workout(self) -> bool:
|
|
|
|
|
"""Try to adjust shutdown time later for actual workouts."""
|
|
|
|
|
workout_type = self.workout_data.get("type", "")
|
2026-03-27 15:54:01 +01:00
|
|
|
if workout_type != "phone_verified":
|
2026-02-14 18:42:20 +01:00
|
|
|
return False
|
|
|
|
|
adjusted = self._adjust_shutdown_time_later()
|
|
|
|
|
if adjusted:
|
|
|
|
|
_logger.info("Shutdown time moved 1.5 hours later as workout reward")
|
|
|
|
|
return adjusted
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def unlock_screen(self) -> None:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Save workout log and display success message."""
|
2025-11-18 18:07:15 +01:00
|
|
|
self.save_workout_log()
|
2026-02-14 18:42:20 +01:00
|
|
|
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
|
2025-11-18 18:07:15 +01:00
|
|
|
self.clear_container()
|
2026-02-14 18:42:20 +01:00
|
|
|
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
|
2026-02-02 21:38:52 +01:00
|
|
|
if shutdown_adjusted:
|
2026-02-14 18:42:20 +01:00
|
|
|
self._text(
|
|
|
|
|
"Shutdown time +1.5h later! 🎁",
|
|
|
|
|
font_size=24,
|
|
|
|
|
color="#ffaa00",
|
2026-02-02 21:38:52 +01:00
|
|
|
)
|
2026-02-14 18:42:20 +01:00
|
|
|
self._text("Screen Unlocked!", font_size=36, pady=20)
|
2025-11-18 18:07:15 +01:00
|
|
|
self.root.after(1500, self.close)
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def has_logged_today(self) -> bool:
|
2026-05-01 19:07:34 +02:00
|
|
|
"""Check if workout has been logged today.
|
|
|
|
|
|
|
|
|
|
Signed entries are verified with HMAC. Older unsigned entries are
|
|
|
|
|
still accepted as a legacy fallback so the user-level service does not
|
|
|
|
|
forget workouts when the root-owned HMAC key is unavailable.
|
|
|
|
|
"""
|
2025-11-30 23:03:03 +01:00
|
|
|
if not self.log_file.exists():
|
2025-11-18 18:07:15 +01:00
|
|
|
return False
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2025-11-18 18:07:15 +01:00
|
|
|
try:
|
2025-11-30 23:57:49 +01:00
|
|
|
with self.log_file.open() as f:
|
2025-11-18 18:07:15 +01:00
|
|
|
logs = json.load(f)
|
2025-11-30 13:42:16 +01:00
|
|
|
except (OSError, json.JSONDecodeError):
|
2025-11-18 18:07:15 +01:00
|
|
|
return False
|
2025-11-30 20:47:38 +01:00
|
|
|
else:
|
|
|
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
2026-04-09 21:44:13 +02:00
|
|
|
entry = logs.get(today)
|
|
|
|
|
if entry is None:
|
|
|
|
|
return False
|
2026-05-01 19:07:34 +02:00
|
|
|
if "hmac" not in entry:
|
|
|
|
|
_logger.warning(
|
|
|
|
|
"Today's log entry is unsigned; accepting legacy fallback"
|
2026-04-12 21:27:24 +02:00
|
|
|
)
|
2026-05-01 19:07:34 +02:00
|
|
|
return entry.get("workout_data", {}).get("type") != "early_bird"
|
|
|
|
|
if not verify_entry_hmac(entry):
|
|
|
|
|
_logger.warning("HMAC verification failed for today's log entry")
|
|
|
|
|
return False
|
|
|
|
|
return entry.get("workout_data", {}).get("type") != "early_bird"
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
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 {}
|
|
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def save_workout_log(self) -> None:
|
2026-04-09 21:44:13 +02:00
|
|
|
"""Save workout data to log file with HMAC signature."""
|
2026-02-14 18:42:20 +01:00
|
|
|
logs = self._load_existing_logs()
|
2025-11-30 15:30:25 +01:00
|
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
2026-04-09 21:44:13 +02:00
|
|
|
entry: dict[str, object] = {
|
2025-11-30 15:30:25 +01:00
|
|
|
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
2025-11-30 13:42:16 +01:00
|
|
|
"workout_data": self.workout_data,
|
2025-11-18 18:07:15 +01:00
|
|
|
}
|
2026-04-09 21:44:13 +02:00
|
|
|
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
|
2025-11-18 18:07:15 +01:00
|
|
|
try:
|
2025-11-30 23:57:49 +01:00
|
|
|
with self.log_file.open("w") as f:
|
2025-11-18 18:07:15 +01:00
|
|
|
json.dump(logs, f, indent=2)
|
2025-11-30 13:42:16 +01:00
|
|
|
except OSError as e:
|
2025-11-30 23:57:49 +01:00
|
|
|
_logger.warning("Could not save workout log: %s", e)
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def close(self) -> None:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Close the application and exit."""
|
2025-11-18 18:07:15 +01:00
|
|
|
self.root.destroy()
|
|
|
|
|
sys.exit(0)
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2025-11-30 15:49:40 +01:00
|
|
|
def run(self) -> None:
|
2025-11-30 14:45:55 +01:00
|
|
|
"""Start the Tkinter main event loop."""
|
2025-11-18 18:07:15 +01:00
|
|
|
self.root.mainloop()
|
|
|
|
|
|
|
|
|
|
|
2025-11-30 13:42:16 +01:00
|
|
|
if __name__ == "__main__":
|
2025-11-18 18:07:15 +01:00
|
|
|
# Check for --production flag
|
|
|
|
|
demo_mode = True # Default to demo mode for safety
|
2026-03-29 22:50:24 +02:00
|
|
|
verify_only = "--verify-workout" in sys.argv
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-03-29 22:50:24 +02:00
|
|
|
if "--production" in sys.argv:
|
2025-11-18 18:07:15 +01:00
|
|
|
demo_mode = False
|
2025-11-30 13:42:16 +01:00
|
|
|
|
2026-03-29 22:50:24 +02:00
|
|
|
locker = ScreenLocker(
|
|
|
|
|
demo_mode=demo_mode,
|
|
|
|
|
verify_only=verify_only,
|
|
|
|
|
)
|
2025-11-18 18:07:15 +01:00
|
|
|
locker.run()
|