mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:27:45 +01:00
Ingest manual/off-app workouts synced from the phone (or another device) into workout_log.json so they count toward the weekly-minimum unlock. No phone UI yet — fully driven by a crafted sync log. - _workout_sync.py: pull_all_manual_records() merges every device log under the sync prefix (phone, pc, …), keeping the highest-HLC copy per stable record id; and the session-verify path (_latest_payload) now skips kind="manual_workout" records so a manual can never be treated as a StrongLifts session. - _manual_sync.py (new): reconstruct_draft() rebuilds a ManualWorkoutDraft from a synced payload (raw inputs only — never trusting the phone's derived fields); ingest_manual_records() re-validates with the same validate_manual_workout the local form uses, enforces the rate budget, then HMAC-signs and writes each under its own date. Idempotent (dedup by sync_record_id) and non-destructive (never clobbers an existing counted day). - _log_mixin.py: extract write_signed_entry(log_file, date, workout_data) so the live save path and ingestion share one date-keyed signer; _load_existing_logs now delegates to the shared _read_logs. - screen_lock.py: run _ingest_synced_manual_workouts() at startup, beside the RunnerUp auto-fill, before the lock-decision early-exits. Trust: a phone can at most add a budget-limited honour-system manual_workout, never a phone_verified one; the PC re-validates and signs on ingest. Full suite green at 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
88 lines
2.9 KiB
Python
88 lines
2.9 KiB
Python
"""Mixin: workout log persistence (read/write workout_log.json)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock.log_integrity import compute_entry_hmac
|
|
|
|
from screen_locker import _compliance_state
|
|
from screen_locker._constants import SCHEDULED_SKIPS_FILE
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _read_logs(log_file: Path) -> dict:
|
|
"""Load ``workout_log.json`` as a dict, or ``{}`` if missing/corrupt."""
|
|
if not log_file.exists():
|
|
return {}
|
|
try:
|
|
with log_file.open() as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
|
|
def write_signed_entry(
|
|
log_file: Path, date: str, workout_data: Mapping[str, object]
|
|
) -> None:
|
|
"""Write one HMAC-signed workout entry into ``log_file`` keyed by ``date``.
|
|
|
|
Shared by the live save path (today's workout) and manual-sync ingestion,
|
|
which files a synced workout under its OWN date so the weekly count places
|
|
it in the right ISO week. The log is day-keyed: one entry per date.
|
|
"""
|
|
logs = _read_logs(log_file)
|
|
entry: dict[str, object] = {
|
|
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
"workout_data": 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[date] = entry
|
|
try:
|
|
with 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)
|
|
|
|
|
|
class LogMixin:
|
|
"""Handles reading and writing workout_log.json for the ScreenLocker.
|
|
|
|
``log_file``/``workout_data`` are declared here (not assigned) so mypy
|
|
knows their types on any composing class without needing
|
|
``type: ignore[attr-defined]`` on every access — the real values are set
|
|
by ``ScreenLocker.__init__``.
|
|
"""
|
|
|
|
log_file: Path
|
|
workout_data: Mapping[str, object]
|
|
|
|
def has_logged_today(self) -> bool:
|
|
"""Check if workout has been logged today with valid HMAC."""
|
|
return _compliance_state.has_logged_today(self.log_file)
|
|
|
|
def _load_existing_logs(self) -> dict:
|
|
"""Load existing workout logs from file."""
|
|
return _read_logs(self.log_file)
|
|
|
|
def _is_scheduled_skip_today(self) -> bool:
|
|
"""Return True if today's date is listed in the scheduled skips file."""
|
|
return _compliance_state.is_scheduled_skip_today(SCHEDULED_SKIPS_FILE)
|
|
|
|
def save_workout_log(self) -> None:
|
|
"""Save today's workout data to the log file with an HMAC signature."""
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
write_signed_entry(self.log_file, today, self.workout_data)
|