mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
A real 1h57m workout on 2026-08-24 synced to Firebase and the locker still locked the screen. The trigger was auth: screen_locker was the only app with no cached firebase_auth.json, so it fell back to a stale shared password and got HTTP 400 every run. The defect was what it did next — an unreadable primary source was reported as "no workout" rather than "could not check", which is the same conflation that took 2026-06-12. Recover the session by borrowing a live refresh token from a sibling app, track degraded sources as state instead of a log line, and say so on the lock screen: name the backend, the error, and how stale the data is, so a sync fault reads as a sync fault. Also fix two bugs found while finishing the recovery: restore_and_bonus.py banked `existing + bonus` with no idempotence guard while finish_2026_08_24.sh advertised "safe to re-run" — a second run silently granted the compensation hours again. Guard on a durable `shutdown_bonus_granted_for` list; the bonus map alone cannot answer the question, since a week's total mixes earned streak hours with corrections. _extra_benefits._save_state rebuilt state from four hardcoded keys, so the next weekly rollover would have dropped that guard and re-armed the double-grant. Merge onto loaded state instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
227 lines
8.2 KiB
Python
227 lines
8.2 KiB
Python
"""Building the sync client: read the token, pick Firebase or the GitHub mirror.
|
|
|
|
Split out of :mod:`screen_locker._workout_sync` to keep every file under the
|
|
250-line cap. Re-exported from there, so callers and their patch targets are
|
|
unchanged.
|
|
|
|
``sync_client`` returning None means sync is OFF -- and says why in the log,
|
|
because a silent None here is exactly how the PC stopped syncing for weeks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from crdt_sync import (
|
|
CONFIG_FILE,
|
|
ConfigError,
|
|
FirebaseAuthError,
|
|
FirebaseConfig,
|
|
FirebaseCredentials,
|
|
GitHubSyncClient,
|
|
RemoteStore,
|
|
RemoteSyncError,
|
|
credential_store_for,
|
|
firebase_client_for,
|
|
mirror_client_for,
|
|
)
|
|
import requests
|
|
|
|
from screen_locker._constants import (
|
|
SYNC_REPO_NAME,
|
|
SYNC_REPO_OWNER,
|
|
SYNC_TIMEOUT_SECONDS,
|
|
SYNC_TOKEN_FILE,
|
|
)
|
|
from screen_locker._credential_recovery import RecoveryResult, recover_session
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DegradedSource:
|
|
"""A workout backend that could not be read on this run.
|
|
|
|
``reason`` is the backend's own error text, kept verbatim so the status
|
|
view and the lock decision can quote something actionable rather than a
|
|
generic "sync problem".
|
|
"""
|
|
|
|
name: str
|
|
reason: str
|
|
|
|
|
|
# Process-scoped rather than persisted: it describes THIS run's ability to
|
|
# read, and a stale marker from an earlier run would be worse than none. The
|
|
# lock chain and the pull both happen in one process, so this survives exactly
|
|
# as long as it is true.
|
|
_degraded: list[DegradedSource] = []
|
|
|
|
|
|
def degraded_sources() -> list[DegradedSource]:
|
|
"""Return the backends that failed to answer during this run."""
|
|
return list(_degraded)
|
|
|
|
|
|
def clear_degraded_sources() -> None:
|
|
"""Forget recorded failures (called at the start of a fresh check)."""
|
|
_degraded.clear()
|
|
|
|
|
|
def _record_degraded(name: str, reason: str) -> None:
|
|
"""Note that *name* could not be read, so callers can stop guessing.
|
|
|
|
Logging alone was not enough: on 2026-08-24 the warning was emitted, the
|
|
Firebase read was skipped, and the lock decision still reported
|
|
"0 workouts this week" as though the source had answered "none".
|
|
"""
|
|
_degraded.append(DegradedSource(name, reason))
|
|
|
|
|
|
def try_recover_firebase_session() -> RecoveryResult:
|
|
"""Rebuild our Firebase session from a sibling app's cached credential.
|
|
|
|
The exchange itself lives here rather than in ``_credential_recovery`` so
|
|
that module stays network-free and unit-testable; this wrapper supplies
|
|
the one impure step.
|
|
"""
|
|
|
|
def _mint(refresh_token: str) -> None:
|
|
config = FirebaseConfig.load()
|
|
# Exchange the borrowed token for our own session. Done against the
|
|
# documented REST endpoint rather than through FirebaseTokenProvider,
|
|
# whose only refresh entry point is private -- and reaching into a
|
|
# library's privates is how a dependency bump breaks enforcement.
|
|
response = requests.post(
|
|
f"https://securetoken.googleapis.com/v1/token?key={config.api_key}",
|
|
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
timeout=SYNC_TIMEOUT_SECONDS,
|
|
)
|
|
response.raise_for_status()
|
|
body = response.json()
|
|
store = credential_store_for("screen_locker")
|
|
store.save(
|
|
FirebaseCredentials(
|
|
id_token=body["id_token"],
|
|
refresh_token=body["refresh_token"],
|
|
expires_at=datetime.now(tz=timezone.utc)
|
|
+ timedelta(seconds=int(body["expires_in"])),
|
|
)
|
|
)
|
|
|
|
return recover_session(
|
|
config_root=Path.home() / ".config", app_name="screen_locker", mint=_mint
|
|
)
|
|
|
|
|
|
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 remote_client(github: RemoteStore) -> RemoteStore:
|
|
"""Return the backend to read the phone's workout log from.
|
|
|
|
Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a
|
|
mirror so a phone that has not moved yet is still seen; GitHub alone
|
|
otherwise. Both callers here are read-only pulls -- this side never pushes
|
|
-- and :class:`MirrorSyncClient` reads the union of both, so a workout
|
|
logged against either backend still counts.
|
|
|
|
The config file is checked before constructing anything, so an
|
|
unconfigured machine never reaches the network.
|
|
|
|
Rolling back is deleting this function and passing ``github`` straight
|
|
through: no data moves either way.
|
|
"""
|
|
if not CONFIG_FILE.is_file():
|
|
return github
|
|
try:
|
|
return mirror_client_for("screen_locker", github)
|
|
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
|
|
# Before degrading, try to heal: a sibling app on this machine almost
|
|
# certainly holds a live refresh token for the same account. Waiting
|
|
# for a human to copy a JSON file is what cost 2026-06-12 and
|
|
# 2026-08-24 -- two workouts done, two lockouts anyway.
|
|
recovery = try_recover_firebase_session()
|
|
if recovery.recovered:
|
|
try:
|
|
client = mirror_client_for("screen_locker", github)
|
|
except (ConfigError, FirebaseAuthError, RemoteSyncError) as retry_exc:
|
|
_logger.warning(
|
|
"Firebase still unusable after %s: %s", recovery.reason, retry_exc
|
|
)
|
|
_record_degraded("firebase", str(retry_exc))
|
|
return github
|
|
_logger.info("Firebase recovered automatically: %s", recovery.reason)
|
|
return client
|
|
_logger.warning(
|
|
"Firebase unavailable, reading workouts via GitHub only: %s — the "
|
|
"phone syncs to Firebase, so a workout logged there is INVISIBLE "
|
|
"on this machine until this is fixed. Automatic recovery also "
|
|
"failed: %s",
|
|
exc,
|
|
recovery.reason,
|
|
)
|
|
_record_degraded("firebase", f"{exc}; recovery: {recovery.reason}")
|
|
return github
|
|
|
|
|
|
def sync_client() -> RemoteStore | None:
|
|
"""Return the configured read client, or None if sync is set up nowhere.
|
|
|
|
A GitHub token is no longer required: Firebase has been the primary backend
|
|
since eb4ff01, so a Firebase-only machine must still sync. Previously this
|
|
module returned early whenever the PAT was missing, reporting "sync is OFF"
|
|
on a machine whose sync was working perfectly -- a false negative that hid
|
|
a live backend behind a legacy credential check.
|
|
|
|
GitHub is used alone when only the PAT exists, Firebase alone when only
|
|
``~/.config/crdt-sync/`` exists, and the mirrored union when both do.
|
|
``None`` means neither is configured, which stays a benign, expected state.
|
|
"""
|
|
token = read_sync_token()
|
|
github = (
|
|
GitHubSyncClient(
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
token,
|
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
|
)
|
|
if token is not None
|
|
else None
|
|
)
|
|
if github is not None:
|
|
return remote_client(github)
|
|
if not CONFIG_FILE.is_file():
|
|
_logger.warning(
|
|
"Cannot pull synced workouts: no sync token at %s and no Firebase "
|
|
"config at %s — sync is OFF, so only ADB/HTTP can verify a phone "
|
|
"workout and phone-logged workouts will NOT count here",
|
|
SYNC_TOKEN_FILE,
|
|
CONFIG_FILE,
|
|
)
|
|
return None
|
|
try:
|
|
return firebase_client_for("screen_locker")
|
|
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
|
|
_logger.warning(
|
|
"Firebase is configured at %s but unusable, and there is no GitHub "
|
|
"token at %s to fall back to: %s — pulling NO synced workouts",
|
|
CONFIG_FILE,
|
|
SYNC_TOKEN_FILE,
|
|
exc,
|
|
)
|
|
return None
|