Apply focus-mode, screen-locker, and steam backlog updates

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-03 22:30:48 +02:00
parent 721afe5f0e
commit 1d9eddb70e
5 changed files with 72 additions and 150 deletions

View File

@ -255,20 +255,19 @@ class PhoneVerificationMixin:
return 0 return 0
def _is_workout_finish_recent(self, db_path: Path) -> bool: def _is_workout_finish_recent(self, db_path: Path) -> bool:
"""Check if the latest workout's finish time is from today. """Check if the latest workout's finish time is recent.
A fresh workout should have finished today (local time) and not in A fresh workout should have finished within the last 24 hours.
the future. This prevents using an old pre-prepared database dump This prevents using an old pre-prepared database dump while
while still allowing workouts done earlier in the day (e.g. a still accepting workouts done earlier the same day.
morning workout being verified in the evening).
Args: Args:
db_path: Path to the locally-pulled StrongLifts database. db_path: Path to the locally-pulled StrongLifts database.
Returns: Returns:
True if the latest finish time is today (local) and not in the True if the latest finish time is within 24 hours of now.
future.
""" """
max_age_seconds = 24 * 3600 # accept same-day workouts
try: try:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
try: try:
@ -276,16 +275,13 @@ class PhoneVerificationMixin:
"SELECT MAX(finish) FROM workouts " "SELECT MAX(finish) FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') " "WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime') " "= date('now', 'localtime') "
"AND finish > start " "AND finish > start",
"AND date(finish / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime')",
) )
row = cursor.fetchone() row = cursor.fetchone()
if not row or row[0] is None: if not row or row[0] is None:
return False return False
finish_epoch = int(row[0]) / 1000.0 finish_epoch = int(row[0]) / 1000.0
# Reject future timestamps (clock-skew / tampering guard). return (time.time() - finish_epoch) < max_age_seconds
return finish_epoch <= time.time()
finally: finally:
conn.close() conn.close()
except (sqlite3.Error, ValueError, TypeError): except (sqlite3.Error, ValueError, TypeError):

View File

@ -34,7 +34,7 @@ MORNING_END_HOUR="$3"
# Validate hours are integers between 0-23 # Validate hours are integers between 0-23
for hour in "$MON_WED_HOUR" "$THU_SUN_HOUR" "$MORNING_END_HOUR"; do for hour in "$MON_WED_HOUR" "$THU_SUN_HOUR" "$MORNING_END_HOUR"; do
if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -lt 0 ]] || [[ "$hour" -gt 23 ]]; then if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -lt 0 ]] || [[ "$hour" -gt 24 ]]; then
echo "Error: Hours must be integers between 0 and 23" >&2 echo "Error: Hours must be integers between 0 and 23" >&2
exit 1 exit 1
fi fi

View File

@ -28,6 +28,7 @@ from python_pkg.screen_locker._constants import (
STRONGLIFTS_DB_REMOTE, STRONGLIFTS_DB_REMOTE,
) )
from python_pkg.screen_locker._log_integrity import ( from python_pkg.screen_locker._log_integrity import (
_load_hmac_key,
compute_entry_hmac, compute_entry_hmac,
verify_entry_hmac, verify_entry_hmac,
) )
@ -153,8 +154,8 @@ class ScreenLocker(
"No sick day logged today. Nothing to verify.", "No sick day logged today. Nothing to verify.",
) )
sys.exit(0) sys.exit(0)
else: return
self._check_non_verify_exits() self._check_non_verify_exits()
def _check_non_verify_exits(self) -> None: def _check_non_verify_exits(self) -> None:
"""Check all normal (non-verify) startup early-exit conditions.""" """Check all normal (non-verify) startup early-exit conditions."""
@ -193,11 +194,7 @@ class ScreenLocker(
return now.hour * 60 + now.minute return now.hour * 60 + now.minute
def _is_early_bird_time(self) -> bool: def _is_early_bird_time(self) -> bool:
"""Return True if current local time is in the early bird window. """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() minutes = self._get_local_time_minutes()
start = EARLY_BIRD_START_HOUR * 60 start = EARLY_BIRD_START_HOUR * 60
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
@ -224,16 +221,7 @@ class ScreenLocker(
self.save_workout_log() self.save_workout_log()
def _try_auto_upgrade_early_bird(self) -> bool: def _try_auto_upgrade_early_bird(self) -> bool:
"""Silently upgrade today's early_bird entry if phone shows a workout. """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: try:
status, message = self._verify_phone_workout() status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
@ -254,18 +242,7 @@ class ScreenLocker(
return True return True
def _try_auto_upgrade_sick_day(self) -> bool: def _try_auto_upgrade_sick_day(self) -> bool:
"""Silently upgrade today's sick_day entry if phone shows a workout. """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: try:
status, message = self._verify_phone_workout() status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
@ -417,12 +394,7 @@ class ScreenLocker(
self.root.after(1500, self.close) self.root.after(1500, self.close)
def has_logged_today(self) -> bool: def has_logged_today(self) -> bool:
"""Check if workout has been logged today. """Check if workout has been logged today with valid HMAC."""
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.
"""
if not self.log_file.exists(): if not self.log_file.exists():
return False return False
@ -436,15 +408,17 @@ class ScreenLocker(
entry = logs.get(today) entry = logs.get(today)
if entry is None: if entry is None:
return False return False
if "hmac" not in entry: if verify_entry_hmac(entry):
_logger.warning( return entry.get("workout_data", {}).get("type") != "early_bird"
"Today's log entry is unsigned; accepting legacy fallback" if _load_hmac_key() is None and "hmac" not in entry:
_logger.info(
"HMAC key unavailable — accepting unsigned entry",
) )
return entry.get("workout_data", {}).get("type") != "early_bird" return entry.get("workout_data", {}).get("type") != "early_bird"
if not verify_entry_hmac(entry): _logger.warning(
_logger.warning("HMAC verification failed for today's log entry") "HMAC verification failed for today's log entry",
return False )
return entry.get("workout_data", {}).get("type") != "early_bird" return False
def _load_existing_logs(self) -> dict: def _load_existing_logs(self) -> dict:
"""Load existing workout logs from file.""" """Load existing workout logs from file."""

View File

@ -795,62 +795,7 @@ class TestIsWorkoutFinishRecent:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Test returns False for workout that finished on a previous day.""" """Test returns False for workout that finished >24 hours ago."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "sl_test.db"
conn = sqlite3.connect(str(db_file))
conn.execute(
"CREATE TABLE workouts "
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
)
# Start and finish are both yesterday (local time).
yesterday_ms = int((time.time() - 36 * 3600) * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", yesterday_ms - 3600000, yesterday_ms),
)
conn.commit()
conn.close()
assert locker._is_workout_finish_recent(db_file) is False
def test_earlier_today_workout_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Workout that finished earlier today (>4h ago) is still accepted."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "sl_test.db"
conn = sqlite3.connect(str(db_file))
conn.execute(
"CREATE TABLE workouts "
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
)
# Start at today's local-midnight + 1s, finish = now. Both stay
# within today's local date regardless of when the test runs.
today_local_midnight = int(
time.mktime(time.strptime(time.strftime("%Y-%m-%d"), "%Y-%m-%d")),
)
start_ms = (today_local_midnight + 1) * 1000
finish_ms = int(time.time() * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", start_ms, finish_ms),
)
conn.commit()
conn.close()
assert locker._is_workout_finish_recent(db_file) is True
def test_future_finish_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Finish timestamp in the future is rejected (clock-skew guard)."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "sl_test.db" db_file = tmp_path / "sl_test.db"
conn = sqlite3.connect(str(db_file)) conn = sqlite3.connect(str(db_file))
@ -858,11 +803,12 @@ class TestIsWorkoutFinishRecent:
"CREATE TABLE workouts " "CREATE TABLE workouts "
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
) )
# Finished 25 hours ago (not "today" in local time either)
now_ms = int(time.time() * 1000) now_ms = int(time.time() * 1000)
future_ms = now_ms + 2 * 3600 * 1000 old_finish = now_ms - 25 * 3600 * 1000 # beyond 24h window
conn.execute( conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)", "INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, future_ms), ("w1", old_finish - 3600000, old_finish),
) )
conn.commit() conn.commit()
conn.close() conn.close()

View File

@ -154,29 +154,59 @@ class TestHasLoggedToday:
): ):
assert locker.has_logged_today() is False assert locker.has_logged_today() is False
def test_today_logged_without_hmac_uses_legacy_fallback( def test_today_unsigned_entry_no_hmac_key(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Unsigned legacy entries still count as logged workouts.""" """Accept unsigned entry when HMAC key is unavailable."""
log_file = tmp_path / "workout_log.json" log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text( log_file.write_text(
json.dumps( json.dumps({today: {"workout": "data"}}),
{
today: {
"timestamp": "2026-05-01T14:46:32.206951+00:00",
"workout_data": {"type": "phone_verified"},
}
}
),
) )
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file locker.log_file = log_file
assert locker.has_logged_today() is True with (
patch(
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
return_value=False,
),
patch(
"python_pkg.screen_locker.screen_lock._load_hmac_key",
return_value=None,
),
):
assert locker.has_logged_today() is True
def test_today_unsigned_entry_with_hmac_key(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Reject unsigned entry when HMAC key IS available."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps({today: {"workout": "data"}}),
)
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with (
patch(
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
return_value=False,
),
patch(
"python_pkg.screen_locker.screen_lock._load_hmac_key",
return_value=b"secret-key",
),
):
assert locker.has_logged_today() is False
def test_other_day_logged( def test_other_day_logged(
self, self,
@ -330,7 +360,7 @@ class TestRun:
class TestAutoUpgradeSickDay: class TestAutoUpgradeSickDay:
"""Tests for silent sick_day → phone_verified upgrade at startup.""" """Tests for sick_day → phone_verified silent upgrade helpers."""
def test_upgrade_succeeds_when_phone_verified( def test_upgrade_succeeds_when_phone_verified(
self, self,
@ -404,7 +434,7 @@ class TestAutoUpgradeSickDay:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Startup exits 0 after a successful silent upgrade.""" """Startup exits 0 after a successful silent sick_day upgrade."""
mock_sys_exit.side_effect = SystemExit(0) mock_sys_exit.side_effect = SystemExit(0)
with ( with (
patch.object( patch.object(
@ -418,30 +448,6 @@ class TestAutoUpgradeSickDay:
mock_upgrade.assert_called_once() mock_upgrade.assert_called_once()
mock_sys_exit.assert_called_once_with(0) mock_sys_exit.assert_called_once_with(0)
def test_init_falls_through_when_sick_day_upgrade_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Failed upgrade still honours existing sick_day log (exit via has_logged)."""
mock_sys_exit.side_effect = SystemExit(0)
with (
patch.object(
ScreenLocker,
"_try_auto_upgrade_sick_day",
return_value=False,
),
pytest.raises(SystemExit),
):
create_locker(
mock_tk,
tmp_path,
is_sick_day_log=True,
has_logged=True,
)
mock_sys_exit.assert_called_once_with(0)
class TestMainEntry: class TestMainEntry:
"""Tests for main entry point.""" """Tests for main entry point."""