mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +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
192 lines
6.5 KiB
Python
192 lines
6.5 KiB
Python
"""Pulls the phone's last-synced workout via crdt-sync's GitHub transport.
|
|
|
|
GitHub is used purely as dumb file storage (see ``crdt_sync``'s own docs) --
|
|
the phone app pushes its completed-workout log to a private repo; this
|
|
module only ever reads it, it never pushes here itself (the PC has no
|
|
workout data of its own to contribute). Sync is optional: an unconfigured
|
|
token is a normal, expected state, not an error -- unlike diet_guard, where
|
|
sync is core to the app.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from crdt_sync import GitHubSyncClient, GitHubSyncError, Hlc, Record
|
|
|
|
from screen_locker._constants import (
|
|
SYNC_PHONE_DEVICE_ID,
|
|
SYNC_REPO_NAME,
|
|
SYNC_REPO_OWNER,
|
|
SYNC_TIMEOUT_SECONDS,
|
|
SYNC_TOKEN_FILE,
|
|
)
|
|
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_DEVICES_PREFIX = "screen-locker-sync/devices"
|
|
_LOG_PATH = f"{_DEVICES_PREFIX}/{SYNC_PHONE_DEVICE_ID}/log.json"
|
|
_PAYLOAD_FIELD = "payload"
|
|
|
|
|
|
def _is_manual_payload(payload: object) -> bool:
|
|
"""True if a decoded sync payload is a manual-workout record."""
|
|
return isinstance(payload, dict) and payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
|
|
|
|
|
|
def read_sync_token() -> str | None:
|
|
"""Return the saved sync PAT, or None if sync isn't configured.
|
|
|
|
Unlike diet_guard's equivalent, an absent or empty token file is a
|
|
normal state here -- sync is an optional primary channel, not something
|
|
the app requires to function.
|
|
"""
|
|
if not SYNC_TOKEN_FILE.exists():
|
|
return None
|
|
token = SYNC_TOKEN_FILE.read_text().strip()
|
|
return token or None
|
|
|
|
|
|
def _latest_payload(log_json: str) -> dict | None:
|
|
"""Return the most recently pushed workout payload from a log.json blob.
|
|
|
|
Raises:
|
|
TypeError: If the top-level JSON isn't an object, a record's shape
|
|
doesn't match what :meth:`Record.from_dict` expects, or the
|
|
payload field itself isn't a JSON object.
|
|
KeyError: If a record is missing an expected key.
|
|
ValueError: Via ``json.loads``, if the text isn't valid JSON, or via
|
|
:meth:`crdt_sync.Hlc.from_str` on a malformed clock string.
|
|
"""
|
|
raw = json.loads(log_json)
|
|
if not isinstance(raw, dict):
|
|
msg = f"top-level sync payload is not a JSON object: {raw!r}"
|
|
raise TypeError(msg)
|
|
|
|
records = [Record.from_dict(data) for data in raw.values()]
|
|
candidates = [
|
|
record
|
|
for record in records
|
|
if _PAYLOAD_FIELD in record.fields
|
|
and not _is_manual_payload(record.fields[_PAYLOAD_FIELD][0])
|
|
]
|
|
if not candidates:
|
|
return None
|
|
latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1])
|
|
|
|
payload = latest.fields[_PAYLOAD_FIELD][0]
|
|
if not isinstance(payload, dict):
|
|
msg = f"payload field is not a JSON object: {payload!r}"
|
|
raise TypeError(msg)
|
|
return payload
|
|
|
|
|
|
def pull_synced_workout() -> tuple[dict | None, str | None]:
|
|
"""Return ``(data, error)``: the phone's last-synced workout, if any.
|
|
|
|
- No token configured: ``(None, None)`` -- benign, sync is optional.
|
|
- Nothing pushed yet (repo reachable, path unused): ``(None, None)``.
|
|
- A real sync error (network, bad token, repo unreachable):
|
|
``(None, <message>)``.
|
|
- Corrupt/malformed sync data: ``(None, "corrupt sync data: ...")``.
|
|
- Success: ``(payload_dict, None)``.
|
|
"""
|
|
token = read_sync_token()
|
|
if token is None:
|
|
return None, None
|
|
|
|
client = GitHubSyncClient(
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
token,
|
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
|
)
|
|
try:
|
|
text = client.get_file_text(_LOG_PATH)
|
|
except GitHubSyncError as exc:
|
|
return None, str(exc)
|
|
|
|
if text is None:
|
|
return None, None
|
|
|
|
try:
|
|
payload = _latest_payload(text)
|
|
except (ValueError, KeyError, TypeError) as exc:
|
|
_logger.warning("Corrupt sync data at %s: %s", _LOG_PATH, exc)
|
|
return None, f"corrupt sync data: {exc}"
|
|
|
|
return payload, None
|
|
|
|
|
|
def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
|
|
"""Return ``{record_id: (payload, hlc)}`` for manual records in a log blob.
|
|
|
|
Raises the same decode errors as :func:`_latest_payload`; callers treat a
|
|
corrupt device log as "no manual records from that device".
|
|
"""
|
|
raw = json.loads(log_json)
|
|
if not isinstance(raw, dict):
|
|
msg = f"top-level sync payload is not a JSON object: {raw!r}"
|
|
raise TypeError(msg)
|
|
result: dict[str, tuple[dict, Hlc]] = {}
|
|
for data in raw.values():
|
|
record = Record.from_dict(data)
|
|
field = record.fields.get(_PAYLOAD_FIELD)
|
|
if field is None:
|
|
continue
|
|
payload, hlc = field
|
|
if _is_manual_payload(payload):
|
|
result[record.id] = (payload, hlc)
|
|
return result
|
|
|
|
|
|
def pull_all_manual_records() -> list[tuple[str, dict]]:
|
|
"""Return all synced manual-workout records across every device log.
|
|
|
|
Merges every ``devices/<device>/log.json`` under the sync prefix (phone,
|
|
pc, …), keeping the highest-HLC copy of each record id — records are
|
|
id-stable, so the same workout mirrored into two device logs dedups to one.
|
|
Best-effort: an unconfigured token, an unreachable repo, or a corrupt device
|
|
log yields fewer/no records rather than raising — manual sync, like session
|
|
sync, is optional.
|
|
"""
|
|
token = read_sync_token()
|
|
if token is None:
|
|
return []
|
|
|
|
client = GitHubSyncClient(
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
token,
|
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
|
)
|
|
try:
|
|
devices = client.list_directory(_DEVICES_PREFIX)
|
|
except GitHubSyncError as exc:
|
|
_logger.info("Manual sync unavailable (%s)", exc)
|
|
return []
|
|
|
|
merged: dict[str, tuple[dict, Hlc]] = {}
|
|
for device in devices:
|
|
path = f"{_DEVICES_PREFIX}/{device}/log.json"
|
|
try:
|
|
text = client.get_file_text(path)
|
|
except GitHubSyncError as exc:
|
|
_logger.info("Skipping device log %s (%s)", path, exc)
|
|
continue
|
|
if text is None:
|
|
continue
|
|
try:
|
|
records = _manual_records(text)
|
|
except (ValueError, KeyError, TypeError) as exc:
|
|
_logger.warning("Corrupt sync data at %s: %s", path, exc)
|
|
continue
|
|
for rid, (payload, hlc) in records.items():
|
|
existing = merged.get(rid)
|
|
if existing is None or existing[1] < hlc:
|
|
merged[rid] = (payload, hlc)
|
|
|
|
return [(rid, payload) for rid, (payload, _hlc) in merged.items()]
|