mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the one that already had this idea: scheduled_skips.json was a bare list of dates, checked first in the lock chain, that only this app could see. That is exactly what the shared pool generalises, so the chain now asks freedays and a day taken here also stands down diet-guard, wake-alarm, leetcode-guard and home-guard. Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d, 10/90d), the 120-character justification and the escalating countdown all exist to make a sick day *hard* to take. Free days are the opposite -- declared ahead, no reason, consecutive runs allowed. Merging the two policies would have had to ruin one of them, so only the storage of the frictionless half moved. This fixes a real bug in passing. is_scheduled_skip_today 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 both sides now, in local time, and the disagreement cannot recur. scripts/migrate_scheduled_skips.py moves the existing dates across; it is idempotent and has a --dry-run. Already run here: the 8 real skips (2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and count against 2026, leaving 27 of 35. The original file is gitignored and was left on disk untouched. add_scheduled_skip.py becomes a thin wrapper over freedays rather than being deleted, because arm.sh calls it and the name is in muscle memory. Left as-is it would have kept writing to a file nothing reads. conftest.py went over the 250-line cap, so the two fixtures that keep the suite off real user state moved to _real_state_fixtures.py. They belong together and neither subsumes the other: _isolate_home covers paths resolved from Path.home() at call time, while the freedays redirect covers paths bound into module constants at import time, which redirecting home afterwards cannot reach. requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one distribution, so those stale pins would have made the hook environments unresolvable rather than merely old. 1256 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|
|
|
|
from datetime import UTC, datetime
|
|
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._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 date as ``YYYY-MM-DD`` in UTC."""
|
|
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
|
|
|
|
|
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 (``~/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
|