mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
A manual workout logged at 00:02 CEST on 2026-09-13 was filed under 2026-09-12: every day-key derivation used datetime.now(tz=UTC), and at that instant UTC was still yesterday. The 09:18 lock check looked for 2026-09-13, found nothing, and locked the PC — for a workout that was on disk. The budget counter (a trailing window) counted it fine, which is why the status showed 1/2 used while the locker still locked. Any log between local midnight and 02:00 CEST hit this; it had happened before. - New screen_locker/_day.py: today_str() is the one definition of "what day is it" — the local calendar day the user sees on the clock, which is also what the phone app, RunnerUp verification, the status window and the freedays pool already used. All 14 UTC sites (log writer, lock predicates, explain chain, early-bird, wake state, shutdown/sick state, sick budget, manual CLI, phone verification) now go through it, writers and readers together so no new midnight window opens between them. - write_signed_entry dedups by workout_id across ALL days: a workout id is globally unique, and an entry moved to its correct day must not let its synced twin (still stamped with the old day) re-ingest as a duplicate. - scripts/rekey_log_entry.py: move a wrongly-filed entry to its real day (the HMAC never covered the day key, so it still verifies). - test_local_day_key.py freezes the clock at the exact failing instant. Replayed the real timeline (log at 00:02:35, evaluate at 09:18:53, weekly 3/5) against HEAD in a throwaway worktree vs this tree: HEAD -> logged under 2026-09-12, LOCK fixed -> logged under 2026-09-13, no lock (already_logged) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
157 lines
6.0 KiB
Python
157 lines
6.0 KiB
Python
"""Pure, read-only leaf predicates for the lock-decision chain.
|
|
|
|
Split out of :mod:`screen_locker._compliance_state` to keep every file under
|
|
the 250-line cap. That module re-exports everything here, so existing
|
|
importers are unaffected.
|
|
|
|
Nothing in this module touches ADB, sudo, subprocess, or the network, and
|
|
nothing writes to disk.
|
|
|
|
Note for tests: the ``gatelock.log_integrity`` helpers are imported *here*
|
|
now, so patch them at ``screen_locker._compliance_predicates.verify_entry_hmac``
|
|
rather than at the old ``_compliance_state`` path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import freedays
|
|
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
|
|
|
from screen_locker._constants import (
|
|
EARLY_BIRD_END_HOUR,
|
|
EARLY_BIRD_END_MINUTE,
|
|
EARLY_BIRD_START_HOUR,
|
|
)
|
|
from screen_locker._day import today_str
|
|
from screen_locker._log_io import load_workout_log
|
|
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
|
|
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from screen_locker._sick_tracker import SickHistory
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _today_str() -> str:
|
|
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
|
|
return today_str()
|
|
|
|
|
|
def is_scheduled_skip_today(today: date | None = None) -> bool:
|
|
"""Return True if *today* is in the shared free-day pool.
|
|
|
|
The source moved from this app's own ``scheduled_skips.json`` to the
|
|
fleet-wide pool (``~/src/utils/freedays``), so one marked day now stands down
|
|
every gate app at once instead of only this one.
|
|
|
|
The date is **local**, not UTC. The file-backed check this replaces
|
|
compared against a UTC day while ``add_scheduled_skip.py`` wrote a local
|
|
one, so a skip added near midnight could name a date the chain never
|
|
matched. freedays owns "what is today" for all of them now.
|
|
|
|
Still fails closed: an unreadable pool reads as "not free", so the lock
|
|
chain continues rather than the lock silently switching itself off.
|
|
"""
|
|
return freedays.is_free_day(today)
|
|
|
|
|
|
def has_logged_today(log_file: Path, *, today: str | None = None) -> bool:
|
|
"""Return True if a validly-signed workout is logged for *today*.
|
|
|
|
The day may hold multiple workouts; return True if ANY of today's entries
|
|
verifies (or is acceptably unsigned when no HMAC key is configured).
|
|
A ``relaxed_day_skip`` entry does NOT count — it is a UI-dismissal marker,
|
|
not a workout, and must not be mistaken for one by ``workout_logged_today``
|
|
(which would otherwise mask the more accurate
|
|
``relaxed_day_already_skipped`` reason in the decision trail).
|
|
"""
|
|
entries = [
|
|
entry
|
|
for entry in load_workout_log(log_file).get(today or _today_str(), [])
|
|
if entry.get("workout_data", {}).get("type") != RELAXED_DAY_SKIP_TYPE
|
|
]
|
|
if not entries:
|
|
return False
|
|
key_unavailable = compute_entry_hmac({"_probe": True}) is None
|
|
for entry in entries:
|
|
if verify_entry_hmac(entry):
|
|
return True
|
|
if key_unavailable and "hmac" not in entry:
|
|
_logger.info("HMAC key unavailable — accepting unsigned entry")
|
|
return True
|
|
_logger.warning("HMAC verification failed for today's log entries")
|
|
return False
|
|
|
|
|
|
def is_relaxed_day_skipped_today(log_file: Path, *, today: str | None = None) -> bool:
|
|
"""Return True if today's relaxed-day prompt was already dismissed.
|
|
|
|
Written by ``UIFlowsRelaxedMixin._skip_relaxed_day`` when the user clicks
|
|
"Skip — No Penalty". Without this, the recurring 30-minute
|
|
``workout-locker.timer`` re-derives ``relaxed_day`` from scratch every
|
|
tick and re-shows the popup, since ``is_relaxed_day`` is a pure date
|
|
check with no memory of a same-day dismissal on its own.
|
|
"""
|
|
entries = load_workout_log(log_file).get(today or _today_str(), [])
|
|
return any(
|
|
entry.get("workout_data", {}).get("type") == RELAXED_DAY_SKIP_TYPE
|
|
for entry in entries
|
|
)
|
|
|
|
|
|
def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bool:
|
|
"""Return True if today has an unresolved early-bird pending marker."""
|
|
if not pending_file.exists():
|
|
return False
|
|
try:
|
|
with pending_file.open() as f:
|
|
state = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
_logger.warning(
|
|
"Could not read the early-bird pending marker at %s: %s — treating "
|
|
"it as not pending, so the lock chain proceeds as if no early bird "
|
|
"was claimed today",
|
|
pending_file,
|
|
exc,
|
|
)
|
|
return False
|
|
if not isinstance(state, dict) or state.get("date") != (today or _today_str()):
|
|
return False
|
|
if verify_entry_hmac(state):
|
|
return True
|
|
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state:
|
|
_logger.info("HMAC key unavailable — accepting unsigned pending marker")
|
|
return True
|
|
_logger.warning("HMAC verification failed for early-bird pending marker")
|
|
return False
|
|
|
|
|
|
def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool:
|
|
"""Return True if today is recorded as a sick day.
|
|
|
|
Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of
|
|
the status layer have one predicate module to import from.
|
|
"""
|
|
return _is_sick_day(history, today=today)
|
|
|
|
|
|
def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool:
|
|
"""Deliberate, independent reimplementation of ``_is_early_bird_time``.
|
|
|
|
Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many
|
|
existing tests are pinned to its ``_get_local_time_minutes`` patch point.
|
|
Kept here as a small, separate, pure re-implementation for the status
|
|
layer only — see the module docstring in ``_early_bird.py``.
|
|
"""
|
|
start = EARLY_BIRD_START_HOUR * 60
|
|
end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
|
return start <= local_minutes < end
|