mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites exactly these three knobs. What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather than PEP 695 syntax, and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That last one makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])` to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the module-level `_W`, a *different* type variable -- mypy caught it as "a function returning TypeVar should receive at least one argument containing the same TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__` the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead module-level TypeVars. ruff, ruff-format, mypy --strict and both pylint passes clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
246 lines
9.3 KiB
Python
246 lines
9.3 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 datetime import UTC, datetime, timedelta
|
|
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
|
|
from screen_locker._degraded_sources import (
|
|
DegradedSource,
|
|
_record_degraded,
|
|
clear_degraded_sources,
|
|
degraded_sources,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Re-exported: these moved to _degraded_sources for the 250-line cap, but
|
|
# callers and tests still reach them as ``_sync_client.<name>``. __all__ keeps
|
|
# ruff from pruning the imports as unused -- same pattern the tests' conftest
|
|
# uses for its re-exported fixtures.
|
|
__all__ = [
|
|
"DegradedSource",
|
|
"clear_degraded_sources",
|
|
"degraded_sources",
|
|
"read_sync_token",
|
|
"remote_client",
|
|
"sync_client",
|
|
"try_recover_firebase_session",
|
|
]
|
|
|
|
|
|
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=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 _live_mirror_client(github: RemoteStore) -> tuple[RemoteStore | None, str]:
|
|
"""Build the mirrored client, but only return one that actually answers.
|
|
|
|
Constructing is not proving. ``mirror_client_for`` only asks
|
|
``has_session()``, which reads the cached JSON off disk and never touches
|
|
the network, so a credential that is *present but rejected* builds
|
|
perfectly and then 401s on every single read and write. That is exactly
|
|
what happened on 2026-08-27: the recovery below was wired to construction
|
|
failures, the construction succeeded, and so the self-heal never fired
|
|
while every operation was being refused.
|
|
|
|
``can_access_remote`` closes that gap with one authenticated round trip.
|
|
It is documented never to raise -- a rejected token, a missing session, a
|
|
bad URL and a dead network all report ``False`` -- so a probe failure is
|
|
reported as a reason string rather than an exception.
|
|
|
|
Returns:
|
|
``(client, "")`` when Firebase answered, else ``(None, reason)``.
|
|
"""
|
|
try:
|
|
client = mirror_client_for("screen_locker", github)
|
|
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
|
|
# The reason travels back to the caller, which decides whether to heal
|
|
# or degrade -- but it is logged here too, so the originating failure
|
|
# is on the record even when a later recovery masks it.
|
|
_logger.warning("Could not build the Firebase client: %s", exc)
|
|
return None, str(exc)
|
|
if not client.can_access_remote():
|
|
return None, (
|
|
"the cached Firebase credential was rejected by the server — it "
|
|
"exists on disk, so nothing failed while connecting, but every "
|
|
"read and write is being refused"
|
|
)
|
|
return client, ""
|
|
|
|
|
|
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. :class:`MirrorSyncClient` reads the union of both, so a workout
|
|
logged against either backend still counts.
|
|
|
|
Not read-only: ``_manual_push.push_pc_workouts`` writes this machine's log
|
|
through the client this returns. That matters for the degraded path --
|
|
when Firebase is dropped here the push still lands on GitHub, which the
|
|
phone also reads, so records are not lost; ``_manual_push`` reports the
|
|
push as INCOMPLETE rather than letting a half-landed push look clean.
|
|
|
|
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
|
|
usable, reason = _live_mirror_client(github)
|
|
if usable is not None:
|
|
return usable
|
|
# 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:
|
|
retried, retry_reason = _live_mirror_client(github)
|
|
if retried is not None:
|
|
_logger.info("Firebase recovered automatically: %s", recovery.reason)
|
|
return retried
|
|
_logger.warning(
|
|
"Firebase still unusable after %s: %s", recovery.reason, retry_reason
|
|
)
|
|
_record_degraded("firebase", retry_reason)
|
|
return github
|
|
_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",
|
|
reason,
|
|
recovery.reason,
|
|
)
|
|
_record_degraded("firebase", f"{reason}; 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
|