Sync: Firebase only — drop the GitHub transport (kuhyx/syncs stays a frozen archive)

The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.

- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
  only, proven with can_access_remote(), healed once from a sibling app's
  session, else a warning naming what will not sync (push AND pull) plus a
  recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
  sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
  (the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
  SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
  "firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
  install_systemd.sh (the installer still said v0.9.0).

kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 19:18:56 +02:00
parent 2dac472331
commit 965b9c4ebb
28 changed files with 276 additions and 650 deletions

View File

@ -2,6 +2,14 @@
## Status
**Superseded (2026-10-09): screen-locker sync is Firebase RTDB only.** The
phone stopped writing the GitHub repo on 2026-08-21, and from 2026-10-09 the
PC does not use it either: the push, `sync_client()` and
`scripts/tombstone_sync_record.py` all use `firebase_client_for("screen_locker")`,
and the sync_token / GitHub client / mirror code paths are deleted.
`kuhyx/syncs/screen-locker-sync` stays as a frozen archive that no code reads
and nothing deletes. The rest of this document is the historical design record.
**Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing
decision" below). No code has been written for this workstream. The design
questions below (sync pattern, repo name, scope) have been pre-decided so the

View File

@ -73,7 +73,7 @@ ensure_runtime_deps() {
_pip_runtime_dep gatelock \
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock"
_pip_runtime_dep crdt_sync \
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync"
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync"
}
ensure_runtime_deps

View File

@ -4,11 +4,11 @@ version = "1.0.0"
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
requires-python = ">=3.14"
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
# crdt-sync: shared CRDT merge scheme + sync transports, also used by
# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time",
]

View File

@ -3,7 +3,7 @@
bandit==1.9.4
codespell==2.4.3
coverage==7.16.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock

View File

@ -186,15 +186,12 @@ RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = (
)
# ---------------------------------------------------------------------------
# Sync (phone workout data) — Workstream C, built on the shared crdt-sync
# library. GitHub is used purely as dumb file storage via the REST Contents
# API (not a git clone), same pattern as diet_guard's cross-device sync. The
# phone app pushes to devices/<SYNC_PHONE_DEVICE_ID>/log.json; the PC reads
# every device's log and pushes its OWN manual workouts to devices/pc/log.json
# (see _manual_push) — which needs a token with contents:write, not read-only.
# Sync (phone workout data) — built on the shared crdt-sync library, Firebase
# RTDB only since 2026-10-09 (the shared ~/.config/crdt-sync/ credential). The
# old GitHub repo kuhyx/syncs/screen-locker-sync is a frozen archive: nothing
# reads or writes it. Every device pushes to devices/<device id>/log.json; the
# PC reads every device's log and pushes its OWN workouts (see _manual_push).
# ---------------------------------------------------------------------------
SYNC_REPO_OWNER: str = "kuhyx"
SYNC_REPO_NAME: str = "syncs"
# The phone's pre-uuid device id. Still read (the phone may not have migrated
# yet), but no longer the only path the reader knows about -- it discovers
# every device directory.
@ -209,12 +206,6 @@ SYNC_LEGACY_DEVICE_ID: str | None = "pc"
SYNC_DEVICE_ID_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / ".device_id"
)
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
# user creates this once via github.com and saves it here, mode 600. Never
# committed — this path is outside the repo entirely. Unlike diet_guard,
# this file being absent is a normal, expected state (sync is optional here).
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
# Revision cache for cross-device sync. Without it every push re-uploads and
# every pull re-downloads regardless of change, which is the traffic the
# Firebase free tier's monthly budget depends on not happening.

View File

@ -1,7 +1,7 @@
"""Push the PC's workouts to the shared sync repo.
"""Push the PC's workouts to the shared Firebase sync store.
``log.json`` is the single source of truth: this module derives the
crdt-sync log directly from it and pushes to ``devices/pc/log.json``, so the
crdt-sync log directly from it and pushes to this device's log, so the
phone converges on the SAME history the PC has — manual workouts *and*
machine-verified ones (StrongLifts sessions, RunnerUp runs).
@ -10,11 +10,11 @@ workout is published automatically no matter when or how it was recorded,
including entries logged before this module existed. Idempotence comes from two
properties: the record id is the workout's own stable ``workout_id``, and its
HLC is derived deterministically from the entry's own timestamp — so re-pushing
an unchanged log produces a byte-identical record set and no repo churn.
an unchanged log produces a byte-identical record set and no store churn.
This reverses the old "the PC only ever reads, never pushes" invariant (it had
no data of its own to contribute). Pushing needs a sync token with
**contents:write** on the repo — a read-only token 403s. Nothing here fails
no data of its own to contribute). Firebase is the only transport since
2026-10-09; the old GitHub mirror is a frozen archive. Nothing here fails
silently: every path returns a :class:`PushResult` whose ``reason`` says exactly
what happened, and logs it.
"""
@ -29,33 +29,23 @@ from typing import TYPE_CHECKING
from crdt_sync import (
FileSyncStateStore,
GitHubSyncClient,
GitHubSyncError,
Hlc,
LogCodec,
Record,
RemoteSyncError,
RepoNotFoundError,
RevisionTracking,
SyncTarget,
sync_log,
)
from screen_locker._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_STATE_FILE,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from screen_locker._constants import SYNC_STATE_FILE
from screen_locker._device import device_identity
from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _derive_workout_id
from screen_locker._push_outcome import describe_push
from screen_locker._sync_retry import with_sync_retry
from screen_locker._sync_tombstones import tombstone_records
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
if TYPE_CHECKING:
from pathlib import Path
@ -172,32 +162,20 @@ def push_pc_workouts(log_file: Path) -> PushResult:
the returned :class:`PushResult` and a WARNING say why a push did not happen.
"""
identity = device_identity()
token = read_sync_token()
if token is None:
reason = f"no sync token at {SYNC_TOKEN_FILE}"
_logger.warning(
"Workouts NOT synced: %s — create a fine-grained GitHub PAT with "
"contents:write on %s/%s and save it there (chmod 600)",
reason,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return PushResult(pushed=False, record_count=0, reason=reason)
log = records_from_workout_log(log_file)
if not log:
reason = f"no counted workouts in {log_file}"
_logger.warning("Workouts NOT synced: %s", reason)
return PushResult(pushed=False, record_count=0, reason=reason)
client = remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
client, reason = sync_client_or_reason()
if client is None:
# sync_client_or_reason has already said why, in full; this line
# ties that cause to the records it just stranded.
_logger.warning(
"Workouts NOT synced: %d workout(s) stay local — %s", len(log), reason
)
return PushResult(pushed=False, record_count=len(log), reason=reason)
try:
with_sync_retry(
lambda: sync_log(
@ -218,32 +196,14 @@ def push_pc_workouts(log_file: Path) -> PushResult:
),
description="push PC workouts",
)
except (GitHubSyncError, RemoteSyncError) as exc:
except RemoteSyncError as exc:
# Report what actually happened, nothing more: this used to assert
# "a 403 means the token lacks contents:write" for every failure,
# including a plain network error, which sent a 2026-07-20
# investigation chasing a permissions bug that did not exist.
reason = f"sync error: {exc}"
# Report what actually happened. This used to assert "a 403 here means
# the token lacks contents:write" for *every* failure, including a
# plain network error — which sent a 2026-07-20 investigation chasing a
# permissions bug that did not exist (the token could write fine; the
# network was simply not up yet). Only RepoNotFoundError actually
# implicates the repo or the token.
if isinstance(exc, RepoNotFoundError):
_logger.warning(
"Workout sync push FAILED for %d workout(s): %s — check the "
"token has contents:write on %s/%s",
len(log),
exc,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
else:
_logger.warning(
"Workout sync push FAILED for %d workout(s): %s",
len(log),
exc,
)
_logger.warning("Workout sync push FAILED for %d workout(s): %s", len(log), exc)
return PushResult(pushed=False, record_count=len(log), reason=reason)
# Delegated so a half-landed push can never be logged as a clean one;
# see _push_outcome for why that distinction is load-bearing.
_, reason = describe_push(len(log))
return PushResult(pushed=True, record_count=len(log), reason=reason)
_logger.info("Synced %d workout(s) to Firebase", len(log))
return PushResult(pushed=True, record_count=len(log), reason="pushed")

View File

@ -23,8 +23,8 @@ Safety invariants (do not break when adding tools):
calls must never write to stdout. All logging is routed to STDERR below,
and no tool calls ``screen_lock.py`` / ``status_view.main`` (which
``print`` / open Tk windows / ``sys.exit``).
* **No secret ever leaves.** No tool returns the sync token
(``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates
* **No secret ever leaves.** No tool returns the Firebase session
(``~/.config/screen_locker/firebase_auth.json``) or any HMAC key; the predicates
load only status/state and return booleans, and the status snapshot
carries no secret material.
"""

View File

@ -1,4 +1,4 @@
"""Phone workout verification: GitHub sync first, ADB/HTTP as fallback."""
"""Phone workout verification: Firebase sync first, ADB/HTTP as fallback."""
from __future__ import annotations
@ -144,7 +144,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
# ── HTTP fallback (no ADB / developer options required) ───────────────────
def _verify_phone_workout(self) -> tuple[str, str]:
"""Verify today's workout: GitHub sync first, ADB/HTTP as fallback.
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
Returns (status, message). Status values:
verified / too_short / not_verified / no_phone / sync_failed /
@ -154,7 +154,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
if not clock_ok:
return "clock_tampered", clock_msg
# GitHub sync is the primary channel — it works without the phone
# Firebase sync is the primary channel — it works without the phone
# being on the same network as the PC. Only a *verified* sync result
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
# phone's last push predates today, or a later push failed offline)
@ -174,7 +174,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
)
elif sync_error is not None:
_logger.info(
"GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
)
# Prefer ADB when a device is visible, but if the pull yields no usable

View File

@ -1,54 +0,0 @@
"""Turning a completed push into an honest result.
Split out of :mod:`screen_locker._manual_push` to keep every file under the
250-line cap.
A push that the primary backend refused still reaches the GitHub mirror, and
the records are not lost -- the phone reads the union of both. What must not
happen is that half-landed push logging the same line as a whole one, which is
how weeks of broken Firebase pushes looked healthy in the journal.
"""
from __future__ import annotations
import logging
from screen_locker._constants import SYNC_REPO_NAME, SYNC_REPO_OWNER
from screen_locker._degraded_sources import degraded_sources
_logger = logging.getLogger(__name__)
def describe_push(record_count: int) -> tuple[bool, str]:
"""Report whether every backend received the records, and say so.
Args:
record_count: How many records the push carried.
Returns:
``(complete, reason)`` -- ``reason`` reads like a sentence either way,
because it is what ``PushResult`` carries back to the caller.
"""
missing = [source.name for source in degraded_sources()]
if not missing:
_logger.info(
"Synced %d workout(s) to %s/%s",
record_count,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return True, "pushed"
names = ", ".join(missing)
reason = (
f"pushed to {SYNC_REPO_OWNER}/{SYNC_REPO_NAME} only; "
f"{names} did NOT receive these records"
)
_logger.warning(
"Workout sync push INCOMPLETE for %d workout(s): %s — the phone reads "
"both backends, so this still syncs, but %s is broken and must be "
"fixed before it is the only path left",
record_count,
reason,
names,
)
return False, reason

View File

@ -44,7 +44,7 @@ def describe_staleness(
"""Return a finding when *name*'s newest record is too old, else None.
Args:
name: The backend, as the user would name it ("GitHub mirror").
name: The backend, as the user would name it ("Firebase").
newest: Timestamp of the most recent record it returned, or None when
it returned nothing at all.
now: Injected for tests.

View File

@ -1,4 +1,4 @@
"""Building the sync client: read the token, pick Firebase or the GitHub mirror.
"""Building the sync client: Firebase only, proven live before it is returned.
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
@ -6,6 +6,12 @@ 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.
Firebase is the only transport since 2026-10-09. The GitHub mirror
(``kuhyx/syncs/screen-locker-sync``) is a frozen archive: nothing here reads
or writes it, and nothing deletes it. The PC used to refuse to push at all
without a GitHub PAT, even with Firebase configured -- so when the PAT was
lost, this machine silently published nothing.
"""
from __future__ import annotations
@ -17,24 +23,16 @@ 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._constants import SYNC_TIMEOUT_SECONDS
from screen_locker._credential_recovery import RecoveryResult, recover_session
from screen_locker._degraded_sources import (
DegradedSource,
@ -53,9 +51,8 @@ __all__ = [
"DegradedSource",
"clear_degraded_sources",
"degraded_sources",
"read_sync_token",
"remote_client",
"sync_client",
"sync_client_or_reason",
"try_recover_firebase_session",
]
@ -96,27 +93,22 @@ def try_recover_firebase_session() -> RecoveryResult:
)
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
# What a missing or broken Firebase costs, in both directions -- named in every
# warning so the journal says what is lost, not just that something failed.
_CONSEQUENCE = (
"this PC's workouts will NOT be pushed and phone-logged workouts will "
"NOT be pulled, so only ADB/HTTP can verify a phone workout here"
)
def _live_mirror_client(github: RemoteStore) -> tuple[RemoteStore | None, str]:
"""Build the mirrored client, but only return one that actually answers.
def _live_firebase_client() -> tuple[RemoteStore | None, str]:
"""Build the Firebase client, but only return one that actually answers.
Constructing is not proving. ``mirror_client_for`` only asks
Constructing is not proving. ``firebase_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
what happened on 2026-08-27: the recovery was wired to construction
failures, the construction succeeded, and so the self-heal never fired
while every operation was being refused.
@ -129,117 +121,66 @@ def _live_mirror_client(github: RemoteStore) -> tuple[RemoteStore | None, str]:
``(client, "")`` when Firebase answered, else ``(None, reason)``.
"""
try:
client = mirror_client_for("screen_locker", github)
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
client = firebase_client_for(
"screen_locker", timeout_seconds=SYNC_TIMEOUT_SECONDS
)
except (ConfigError, 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
# or give up -- 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"
"the cached Firebase credential was rejected by the server, or "
"the database is unreachable — every read and write is 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.
def sync_client_or_reason() -> tuple[RemoteStore | None, str]:
"""Return a live Firebase client, or None and the reason there is none.
The config file is checked before constructing anything, so an
unconfigured machine never reaches the network.
unconfigured machine never reaches the network. A configured but failing
Firebase is healed from a sibling app's session when possible (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); if that fails too, the failure is
recorded as a degraded source so an empty pull is never reported as
"no workouts".
Rolling back is deleting this function and passing ``github`` straight
through: no data moves either way.
Returns:
``(client, "")`` on success, else ``(None, reason)`` -- ``reason`` is a
sentence a caller can put straight into its own result.
"""
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.
reason = f"no Firebase config at {CONFIG_FILE}"
_logger.warning("Sync is OFF: %s — %s", reason, _CONSEQUENCE)
return None, reason
client, reason = _live_firebase_client()
if client is not None:
return client, ""
recovery = try_recover_firebase_session()
if recovery.recovered:
retried, retry_reason = _live_mirror_client(github)
retried, retry_reason = _live_firebase_client()
if retried is not None:
_logger.info("Firebase recovered automatically: %s", recovery.reason)
return retried
return retried, ""
reason = f"still unusable after {recovery.reason}: {retry_reason}"
else:
reason = f"{reason}; automatic recovery failed: {recovery.reason}"
_logger.warning(
"Firebase still unusable after %s: %s", recovery.reason, retry_reason
"Firebase is configured but unusable: %s — %s", reason, _CONSEQUENCE
)
_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
_record_degraded("firebase", reason)
return None, f"Firebase unusable: {reason}"
def sync_client() -> RemoteStore | None:
"""Return the configured read client, or None if sync is set up nowhere.
"""Return the live Firebase client, or None when sync cannot run.
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.
``None`` is never silent: :func:`sync_client_or_reason` has already
logged a warning naming the cause and what will not sync because of it.
"""
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
client, _reason = sync_client_or_reason()
return client

View File

@ -21,7 +21,7 @@ from typing import TYPE_CHECKING
from crdt_sync import CONFIG_FILE as FIREBASE_CONFIG_FILE
from screen_locker._constants import SYNC_STATE_FILE, SYNC_TOKEN_FILE
from screen_locker._constants import SYNC_STATE_FILE
from screen_locker._device import device_identity
_logger = logging.getLogger(__name__)
@ -36,9 +36,9 @@ class SyncStatus:
Attributes:
device_id: The uuid this machine publishes under.
backend: ``"firebase"``, ``"github"``, or ``"none"`` -- the highest
backend this machine is configured for. Firebase is primary and
GitHub is the cutover mirror, so Firebase wins when both exist.
backend: ``"firebase"`` or ``"none"`` -- whether this machine is
configured for sync at all. Firebase is the only transport since
2026-10-09; the old GitHub mirror is a frozen archive.
pushed: Whether this machine has ever completed a push.
peer_count: How many other devices' logs it has merged.
last_push: ISO-ish local timestamp of the last push, or None.
@ -86,26 +86,22 @@ def _read_state(path: Path) -> dict[str, object]:
return parsed if isinstance(parsed, dict) else {}
def _backend(*, firebase_config: Path, token_file: Path) -> str:
"""Return the highest backend this machine is configured for."""
def _backend(*, firebase_config: Path) -> str:
"""Return the backend this machine is configured for, or ``"none"``."""
if firebase_config.is_file():
return "firebase"
if token_file.is_file():
return "github"
return "none"
def gather_sync_status(
*,
state_file: Path = SYNC_STATE_FILE,
token_file: Path = SYNC_TOKEN_FILE,
firebase_config: Path = FIREBASE_CONFIG_FILE,
) -> SyncStatus:
"""Read this machine's sync posture from local state only.
Args:
state_file: The revision cache written after each push.
token_file: The GitHub PAT, present only on the mirror path.
firebase_config: The shared Firebase credential.
Returns:
@ -138,7 +134,7 @@ def gather_sync_status(
return SyncStatus(
device_id=device_identity().device_id,
backend=_backend(firebase_config=firebase_config, token_file=token_file),
backend=_backend(firebase_config=firebase_config),
pushed=bool(state.get("pushed_rev")),
peer_count=peer_count,
last_push=last_push,

View File

@ -1,11 +1,12 @@
"""Pulls phone/other-device workouts via crdt-sync's GitHub transport.
"""Pulls phone/other-device workouts via crdt-sync's Firebase 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
Firebase RTDB is the only backend since 2026-10-09 (the old GitHub repo is a
frozen archive nobody reads) -- the phone app pushes its completed-workout log
there; this module
reads the session log and every device's manual-workout records. (The PC does
push its OWN manual workouts, but via :mod:`screen_locker._manual_push`, not
here.) Sync is optional: an unconfigured token is a normal, expected state,
not an error -- unlike diet_guard, where sync is core to the app.
here.) Sync is optional: a machine without a Firebase config still locks
and verifies over ADB/HTTP -- but it says so at ``warning`` every time.
"""
from __future__ import annotations
@ -20,16 +21,7 @@ from crdt_sync import (
RemoteSyncError,
)
from screen_locker._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TOKEN_FILE,
)
from screen_locker._sync_client import (
read_sync_token,
remote_client,
sync_client,
)
from screen_locker._sync_client import sync_client, sync_client_or_reason
from screen_locker._sync_records import (
_is_manual_payload,
_is_session_payload,
@ -40,12 +32,9 @@ from screen_locker._sync_records import (
)
from screen_locker._sync_retry import with_sync_retry
# Re-exported for callers (_manual_push) and for the autouse isolate_sync_token
# fixture, which redirects SYNC_TOKEN_FILE on this module as well as on
# _sync_client -- without both, a real token on the host leaks into the tests.
# Re-exported for callers (_manual_push, scripts/tombstone_sync_record.py).
__all__ = [
"CONFIG_FILE",
"SYNC_TOKEN_FILE",
"_is_manual_payload",
"_is_session_payload",
"_manual_records",
@ -54,9 +43,8 @@ __all__ = [
"pull_all_manual_records",
"pull_all_session_records",
"pull_synced_workout",
"read_sync_token",
"remote_client",
"sync_client",
"sync_client_or_reason",
]
if TYPE_CHECKING:
@ -104,11 +92,9 @@ def _merge_device_records(
)
except RemoteSyncError as exc:
_logger.warning(
"Could not list device logs under %s in %s/%s: %s — pulling NO "
"Could not list device logs under %s in Firebase: %s — pulling NO "
"synced %s, so phone-logged workouts will not count",
_DEVICES_PREFIX,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
exc,
what,
)
@ -146,7 +132,7 @@ def pull_synced_workout() -> tuple[dict | None, str | None]:
- Nothing configured anywhere: ``(None, None)`` -- benign, sync is optional.
- Nothing pushed yet: ``(None, None)``.
- A real sync error (network, bad token, backend unreachable):
- A real sync error (network, rejected credential, backend unreachable):
``(None, <message>)``.
- Success: ``(payload_dict, None)``.
"""
@ -182,13 +168,13 @@ def pull_all_session_records() -> list[tuple[str, dict]]:
being open: ``pull_synced_workout`` returns only the single newest session
for the live unlock check, which cannot backfill a week.
Best-effort in the same way -- an unconfigured token or a corrupt device
Best-effort in the same way -- no Firebase config or a corrupt device
log yields fewer records rather than raising.
"""
client = sync_client()
if client is None:
_logger.warning(
"No sync client (neither a GitHub token nor a Firebase config) — "
"No usable Firebase sync client (see the warning above) — "
"synced StrongLifts sessions cannot be pulled, so a workout done "
"on another device will NOT count toward the weekly minimum.",
)
@ -204,7 +190,7 @@ def pull_all_manual_records() -> list[tuple[str, dict]]:
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
Best-effort: no Firebase config, an unreachable store, or a corrupt device
log yields fewer/no records rather than raising — manual sync, like session
sync, is optional.
"""

View File

@ -100,7 +100,7 @@ def _firebase_config(
class ReachableClient:
"""A built client whose authenticated probe succeeds.
``remote_client`` no longer trusts a client just because it constructed:
``sync_client`` no longer trusts a client just because it constructed:
it probes with ``can_access_remote`` first, because a credential that is
present but server-rejected builds fine and then 401s on every operation.
Fakes therefore have to answer that probe. ``identity`` carries whatever

View File

@ -236,15 +236,14 @@ def _mock_weekly_logic() -> Iterator[None]:
def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Point the Firebase config at a path that does not exist.
``remote_client`` reads ``crdt_sync.CONFIG_FILE`` to decide whether to
build a Firebase-primary mirror. On a developer machine that file *does*
exist, so without this the sync tests would reach the real database and
assert against live data instead of their own fakes. Tests that want the
Firebase path point it back at a file they control.
``sync_client`` (the ONLY way push, pull and the tombstone script get a
client) reads ``_sync_client.CONFIG_FILE`` to decide whether to build the
Firebase client. On a developer machine that file *does* exist, so
without this the sync tests would reach the real database and assert
against live data. Tests wanting Firebase point it at a file they own.
"""
# CONFIG_FILE is read by remote_client, which lives in _sync_client since
# _workout_sync was split. Patching the old module would leave the real
# config in play and the sync tests would assert against live data.
# Patch _sync_client's binding: crdt_sync's own CONFIG_FILE is bound to
# the real home at import, which _isolate_home cannot redirect.
from screen_locker import _sync_client
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))

View File

@ -85,7 +85,7 @@ def test_no_module_binds_a_path_inside_the_real_app_config() -> None:
"""Import-time bindings of ``~/.config/screen_locker`` must all be redirected.
``_isolate_home`` only reaches paths built from ``Path.home()`` at *call*
time. A module constant such as ``SYNC_TOKEN_FILE`` was bound at import,
time. A module constant such as the old ``SYNC_TOKEN_FILE`` was bound at import,
before any fixture ran, and keeps the real path unless every module that
holds it is patched. This scans every runtime module for such a binding,
so a new one fails here instead of silently writing to the live dir.

View File

@ -10,9 +10,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from crdt_sync import GitHubSyncError, RepoNotFoundError
from crdt_sync import FirebaseSyncError
from screen_locker import _manual_push, _sync_client
from screen_locker import _manual_push
from screen_locker._manual_push import (
PushResult,
push_pc_workouts,
@ -40,23 +40,32 @@ _RUN = {
class TestPushPcWorkouts:
"""Every outcome is reported — never a silent no-op."""
def test_no_token_reports_why(self, tmp_path: Path) -> None:
"""No token reports why."""
def test_no_firebase_config_reports_why(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""No Firebase config: nothing pushed, the reason and count say so.
The autouse ``_no_real_firebase_config`` fixture is exactly this
machine state, so the real ``sync_client_or_reason`` runs here.
"""
log_file = tmp_path / "log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with patch.object(_manual_push, "read_sync_token", return_value=None):
with caplog.at_level("WARNING"):
result = push_pc_workouts(log_file)
assert result == PushResult(pushed=False, record_count=0, reason=result.reason)
assert "no sync token" in result.reason
assert result.pushed is False
assert result.record_count == 1
assert result.reason.startswith("no Firebase config at ")
assert "1 workout(s) stay local" in caplog.text
def test_empty_log_reports_why(self, tmp_path: Path) -> None:
"""Empty log reports why."""
log_file = tmp_path / "log.json"
_write_log(log_file, {})
with patch.object(_manual_push, "read_sync_token", return_value="t"):
with patch.object(_manual_push, "sync_client_or_reason") as build:
result = push_pc_workouts(log_file)
build.assert_not_called()
assert result.pushed is False
assert result.record_count == 0
assert "no counted workouts" in result.reason
@ -68,10 +77,11 @@ class TestPushPcWorkouts:
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push, "sync_log", side_effect=GitHubSyncError("403 forbidden")
_manual_push, "sync_client_or_reason", return_value=(MagicMock(), "")
),
patch.object(
_manual_push, "sync_log", side_effect=FirebaseSyncError("403 forbidden")
),
):
result = push_pc_workouts(log_file)
@ -94,12 +104,13 @@ class TestPushPcWorkouts:
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push, "sync_client_or_reason", return_value=(MagicMock(), "")
),
patch.object(
_manual_push,
"sync_log",
side_effect=GitHubSyncError("network error reading x"),
side_effect=FirebaseSyncError("network error reading x"),
),
caplog.at_level("WARNING"),
):
@ -108,28 +119,6 @@ class TestPushPcWorkouts:
assert "network error reading x" in caplog.text
assert "contents:write" not in caplog.text
def test_repo_not_found_still_points_at_the_token(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The permission hint survives for the error that actually implies it."""
log_file = tmp_path / "log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push,
"sync_log",
side_effect=RepoNotFoundError("no access"),
),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert "contents:write" in caplog.text
def test_successful_push_reports_count(self, tmp_path: Path) -> None:
"""Successful push reports count."""
log_file = tmp_path / "log.json"
@ -144,8 +133,9 @@ class TestPushPcWorkouts:
)
fake_sync = MagicMock()
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push, "sync_client_or_reason", return_value=(MagicMock(), "")
),
patch.object(_manual_push, "sync_log", fake_sync),
):
result = push_pc_workouts(log_file)
@ -159,8 +149,9 @@ class TestPushPcWorkouts:
log_file, {"2026-07-12": [_entry(_RUN, "2026-07-12T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push, "sync_client_or_reason", return_value=(MagicMock(), "")
),
patch.object(_manual_push, "sync_log", MagicMock()) as fake_sync,
):
push_pc_workouts(log_file)
@ -169,57 +160,26 @@ class TestPushPcWorkouts:
assert "runnerup_verified:2026-07-12" in pushed
class TestIncompletePushIsNotReportedAsClean:
"""A push that reached only some backends must not look like a full one."""
class TestPushLogsItsOutcome:
"""The journal line is what says a push landed; it must name Firebase."""
def test_a_degraded_backend_makes_the_push_incomplete(
def test_a_healthy_push_logs_the_count_and_no_warning(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Firebase missing the records must be said out loud, at warning.
Before this, a dead Firebase produced the identical
``INFO Synced N workout(s)`` line as a fully mirrored push, so a
half-landed push was indistinguishable from a healthy one for weeks.
"""
"""A clean push says how many records went to Firebase, at INFO."""
log_file = tmp_path / "log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
_sync_client.clear_degraded_sources()
_sync_client._record_degraded("firebase", "HTTP 401")
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "remote_client", MagicMock()),
patch.object(
_manual_push, "sync_client_or_reason", return_value=(MagicMock(), "")
),
patch.object(_manual_push, "with_sync_retry", MagicMock()),
caplog.at_level("WARNING"),
caplog.at_level("INFO"),
):
result = push_pc_workouts(log_file)
_sync_client.clear_degraded_sources()
assert result.pushed is True, "the records DID land on GitHub"
assert "firebase" in result.reason
assert "did NOT receive" in result.reason
assert "INCOMPLETE" in caplog.text
def test_a_healthy_push_stays_quiet(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The happy path must stay clean, or the warning means nothing."""
log_file = tmp_path / "log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
_sync_client.clear_degraded_sources()
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "remote_client", MagicMock()),
patch.object(_manual_push, "with_sync_retry", MagicMock()),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is True
assert result.reason == "pushed"
assert "INCOMPLETE" not in caplog.text
assert result == PushResult(pushed=True, record_count=1, reason="pushed")
assert "Synced 1 workout(s) to Firebase" in caplog.text
assert not [r for r in caplog.records if r.levelname == "WARNING"]

View File

@ -2,8 +2,8 @@
Regression tests for 2026-06-12 and 2026-08-24, when a completed ~2h workout
sat in Firebase while the PC locked the screen anyway. The PC's Firebase
credential no longer authenticated, so ``remote_client`` degraded to the
GitHub mirror -- which the phone had stopped writing to on 2026-08-15. The
credential no longer authenticated, so the PC degraded to the (since
retired) GitHub mirror -- which the phone had stopped writing to on 2026-08-15. The
pull then returned zero records and the lock chain read that as "you did not
train today" rather than "I could not read the place your workouts live".
@ -101,11 +101,10 @@ class TestFirebaseDegradationIsVisible:
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
raise FirebaseAuthError(message)
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
monkeypatch.setattr(_sync_client, "firebase_client_for", _boom)
_sync_client.clear_degraded_sources()
github = object()
assert _workout_sync.remote_client(github) is github
assert _workout_sync.sync_client() is None
degraded = _sync_client.degraded_sources()
assert degraded, "a failed Firebase sign-in must be recorded"
assert "INVALID_LOGIN_CREDENTIALS" in degraded[0].reason
@ -117,12 +116,12 @@ class TestFirebaseDegradationIsVisible:
_firebase_config(_sync_client, tmp_path, monkeypatch)
monkeypatch.setattr(
_sync_client,
"mirror_client_for",
lambda _app, client: ReachableClient(("mirror", client)),
"firebase_client_for",
lambda *_a, **_k: ReachableClient(),
)
_sync_client.clear_degraded_sources()
_workout_sync.remote_client(object())
_workout_sync.sync_client()
assert _sync_client.degraded_sources() == []
@ -139,24 +138,23 @@ class TestFirebaseDegradationIsVisible:
_firebase_config(_sync_client, tmp_path, monkeypatch)
attempts: list[int] = []
def _dead_then_alive(_app: object, client: object) -> object:
def _dead_then_alive(*_args: object, **_kwargs: object) -> object:
attempts.append(1)
if len(attempts) == 1:
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
raise FirebaseAuthError(message)
return ReachableClient(("mirror", client))
return ReachableClient()
monkeypatch.setattr(_sync_client, "mirror_client_for", _dead_then_alive)
monkeypatch.setattr(_sync_client, "firebase_client_for", _dead_then_alive)
monkeypatch.setattr(
_sync_client,
"try_recover_firebase_session",
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
)
_sync_client.clear_degraded_sources()
github = object()
recovered = _workout_sync.remote_client(github)
assert recovered.identity == ("mirror", github)
recovered = _workout_sync.sync_client()
assert isinstance(recovered, ReachableClient)
assert _sync_client.degraded_sources() == [], (
"a source that recovered is not degraded"
)
@ -168,8 +166,8 @@ class TestFirebaseDegradationIsVisible:
Recovery reporting success is not proof the backend is readable, so
the retry is what decides. If it raises too, this is the same lockout
as before and must be recorded -- silently returning the GitHub
mirror is exactly how 2026-08-24 stayed invisible.
as before and must be recorded -- silently handing back a dead
client is exactly how 2026-08-24 stayed invisible.
"""
_firebase_config(_sync_client, tmp_path, monkeypatch)
@ -177,16 +175,15 @@ class TestFirebaseDegradationIsVisible:
msg = "still 400 after refresh"
raise FirebaseAuthError(msg)
monkeypatch.setattr(_sync_client, "mirror_client_for", _always_dead)
monkeypatch.setattr(_sync_client, "firebase_client_for", _always_dead)
monkeypatch.setattr(
_sync_client,
"try_recover_firebase_session",
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
)
_sync_client.clear_degraded_sources()
github = object()
assert _workout_sync.remote_client(github) is github
assert _workout_sync.sync_client() is None
degraded = _sync_client.degraded_sources()
assert degraded, "a retry that still fails must be recorded"
assert "still 400 after refresh" in degraded[0].reason
@ -206,7 +203,7 @@ class TestFirebaseDegradationIsVisible:
msg = "HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
raise FirebaseAuthError(msg)
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
monkeypatch.setattr(_sync_client, "firebase_client_for", _boom)
monkeypatch.setattr(
_sync_client,
"try_recover_firebase_session",
@ -215,9 +212,8 @@ class TestFirebaseDegradationIsVisible:
),
)
_sync_client.clear_degraded_sources()
github = object()
assert _workout_sync.remote_client(github) is github
assert _workout_sync.sync_client() is None
reason = _sync_client.degraded_sources()[0].reason
assert "INVALID_LOGIN_CREDENTIALS" in reason
assert "no sibling app" in reason
@ -227,10 +223,10 @@ class TestFirebaseDegradationIsVisible:
) -> None:
"""No Firebase config is a choice, not a failure -- do not cry wolf."""
monkeypatch.setattr(
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json")
)
_sync_client.clear_degraded_sources()
_workout_sync.remote_client(object())
_workout_sync.sync_client()
assert _sync_client.degraded_sources() == []

View File

@ -2,7 +2,7 @@
Split from ``test_sync_degradation.py`` (250-line cap).
``mirror_client_for`` only checks ``has_session()``, which reads the cached
``firebase_client_for`` only checks ``has_session()``, which reads the cached
JSON off disk and never reaches the network. So on 2026-08-27 a credential the
server had stopped accepting constructed perfectly, the recovery -- wired only
to construction failures -- never ran, and every read and write was refused
@ -38,7 +38,7 @@ class TestRejectedCredentialTriggersRecovery:
) -> None:
"""This is 2026-08-27: present on disk, rejected by the server.
``mirror_client_for`` only checks ``has_session()``, which reads the
``firebase_client_for`` only checks ``has_session()``, which reads the
cached JSON and never reaches the network, so construction SUCCEEDED
and the recovery -- wired only to construction failures -- never ran
while every read and write was being refused with HTTP 401.
@ -46,11 +46,11 @@ class TestRejectedCredentialTriggersRecovery:
_firebase_config(_sync_client, tmp_path, monkeypatch)
built: list[int] = []
def _rejected_then_live(_app: object, _client: object) -> object:
def _rejected_then_live(*_args: object, **_kwargs: object) -> object:
built.append(1)
return RejectedClient() if len(built) == 1 else ReachableClient()
monkeypatch.setattr(_sync_client, "mirror_client_for", _rejected_then_live)
monkeypatch.setattr(_sync_client, "firebase_client_for", _rejected_then_live)
monkeypatch.setattr(
_sync_client,
"try_recover_firebase_session",
@ -58,7 +58,7 @@ class TestRejectedCredentialTriggersRecovery:
)
_sync_client.clear_degraded_sources()
client = _workout_sync.remote_client(object())
client = _workout_sync.sync_client()
assert isinstance(client, ReachableClient), (
"a rejected credential must trigger the same self-heal as a "
@ -71,14 +71,14 @@ class TestRejectedCredentialTriggersRecovery:
) -> None:
"""If it is still refused after healing, say so on the lock screen.
Falling back to GitHub silently is what let weeks of 401s scroll past
Returning a dead client silently is what let weeks of 401s scroll past
in a journal nobody reads.
"""
_firebase_config(_sync_client, tmp_path, monkeypatch)
monkeypatch.setattr(
_sync_client,
"mirror_client_for",
lambda _app, _client: RejectedClient(),
"firebase_client_for",
lambda *_a, **_k: RejectedClient(),
)
monkeypatch.setattr(
_sync_client,
@ -86,9 +86,8 @@ class TestRejectedCredentialTriggersRecovery:
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
)
_sync_client.clear_degraded_sources()
github = object()
assert _workout_sync.remote_client(github) is github
assert _workout_sync.sync_client() is None
degraded = _sync_client.degraded_sources()
assert degraded, "a credential the server refuses must be recorded"
assert "rejected" in degraded[0].reason

View File

@ -1,4 +1,4 @@
"""Isolation fixture for the phone-workout GitHub sync token.
"""Autouse isolation fixtures for the phone-workout sync layer.
Split out of ``conftest.py`` (already at the repo's 400-line-per-file cap)
and re-exported there so pytest still picks this up as an autouse fixture
@ -7,7 +7,6 @@ for every test in this directory.
from __future__ import annotations
from contextlib import ExitStack
from typing import TYPE_CHECKING
from unittest.mock import patch
@ -18,40 +17,6 @@ if TYPE_CHECKING:
from pathlib import Path
# Every module that holds SYNC_TOKEN_FILE (~/.config/screen_locker/sync_token)
# by value, bound at import before any fixture could redirect Path.home().
_SYNC_TOKEN_BINDINGS = (
"_constants",
"_manual_push",
"_sync_client",
"_sync_status",
"_workout_sync",
)
@pytest.fixture(autouse=True)
def isolate_sync_token(tmp_path: Path) -> Iterator[None]:
"""Redirect SYNC_TOKEN_FILE to tmp_path so tests never see a real token.
Without this, any test calling ``_verify_phone_workout`` would fall
through to ``pull_synced_workout()``, which -- if a real
``~/.config/screen_locker/sync_token`` happens to exist on the host --
would make a real GitHub API call. Defaulting to a nonexistent tmp_path
file makes ``read_sync_token()`` return None, the same benign "sync not
configured" state every existing test already assumes.
"""
# read_sync_token moved to _sync_client when _workout_sync was split;
# _workout_sync still re-exports the constant, so both names must be
# redirected or a real token on the host leaks into the tests. The rest
# bind it by value too (only for messages today); patching every binding
# is what test_home_isolation's module scan checks.
token = tmp_path / "sync_token"
with ExitStack() as stack:
for module in _SYNC_TOKEN_BINDINGS:
stack.enter_context(patch(f"screen_locker.{module}.SYNC_TOKEN_FILE", token))
yield
@pytest.fixture(autouse=True)
def isolate_sync_device_id(tmp_path: Path) -> Iterator[None]:
"""Redirect the persisted device id so tests never mint one for real.

View File

@ -27,13 +27,11 @@ def _gather(
tmp_path: Path,
*,
state_file: Path | None = None,
token_file: Path | None = None,
firebase_config: Path | None = None,
) -> SyncStatus:
"""Gather status against files under ``tmp_path`` unless overridden."""
return gather_sync_status(
state_file=state_file or tmp_path / "sync_state.json",
token_file=token_file or tmp_path / "sync_token",
firebase_config=firebase_config or tmp_path / "firebase.json",
)
@ -45,15 +43,14 @@ class TestBackendDetection:
"""No credentials is not configured."""
assert _gather(tmp_path).backend == "none"
def test_a_github_token_alone_is_the_mirror_path(self, tmp_path: Path) -> None:
"""A github token alone is the mirror path."""
def test_a_leftover_github_token_does_not_count(self, tmp_path: Path) -> None:
"""A stale GitHub PAT on disk is not a backend: Firebase is the only one."""
(tmp_path / "sync_token").write_text("tok")
assert _gather(tmp_path).backend == "github"
assert _gather(tmp_path).backend == "none"
def test_firebase_wins_when_both_exist(self, tmp_path: Path) -> None:
"""Firebase is primary; GitHub is only the cutover mirror."""
(tmp_path / "sync_token").write_text("tok")
def test_firebase_config_is_the_backend(self, tmp_path: Path) -> None:
"""The shared Firebase config is what makes this machine configured."""
(tmp_path / "firebase.json").write_text("{}")
assert _gather(tmp_path).backend == "firebase"

View File

@ -1,8 +1,8 @@
"""Tests for the phone-workout sync pull (crdt-sync transport).
Covers the token read, the payload predicates, the per-log record extractors
and the cross-device merge. The backend selection (``sync_client`` /
``remote_client``) and the two public pulls live in ``_part2``.
Covers the payload predicates, the per-log record extractors
and the cross-device merge. The backend selection (``sync_client``)
and the two public pulls live in ``_part2``.
"""
from __future__ import annotations
@ -21,31 +21,9 @@ from screen_locker.tests._workout_sync_fixtures import (
_session_record_dict,
)
# The autouse `isolate_sync_token` fixture (registered repo-wide via the
# `pytest_plugins` entry for `screen_locker.tests.test_sync_fixtures` in
# pyproject.toml) already redirects `_workout_sync.SYNC_TOKEN_FILE` into a
# per-test tmp_path, so tests here can write to it directly without needing
# their own isolation fixture. The autouse `_no_real_firebase_config` fixture
# in conftest.py likewise points `CONFIG_FILE` at a nonexistent path, so tests
# that want the Firebase branch must monkeypatch it back at a file they own.
class TestReadSyncToken:
"""ReadSyncToken."""
def test_returns_none_when_file_is_missing(self) -> None:
"""Returns none when file is missing."""
assert _workout_sync.read_sync_token() is None
def test_returns_none_when_file_is_empty(self) -> None:
"""Returns none when file is empty."""
_workout_sync.SYNC_TOKEN_FILE.write_text(" \n")
assert _workout_sync.read_sync_token() is None
def test_returns_the_stripped_token(self) -> None:
"""Returns the stripped token."""
_workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n")
assert _workout_sync.read_sync_token() == "abc123"
# The autouse `_no_real_firebase_config` fixture in conftest.py points
# `_sync_client.CONFIG_FILE` at a nonexistent path, so tests that want the
# Firebase branch must monkeypatch it back at a file they own.
class TestIsSessionPayload:

View File

@ -18,7 +18,6 @@ import pytest
from screen_locker import _sync_client, _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
_firebase_config,
)
@ -27,7 +26,7 @@ if TYPE_CHECKING:
class TestSyncClient:
"""Every configuration of (GitHub token, Firebase config), plus failure."""
"""Firebase is the only backend: configured, missing, or unusable."""
def test_returns_none_when_nothing_is_configured(self) -> None:
"""Returns none when nothing is configured."""
@ -36,41 +35,36 @@ class TestSyncClient:
def test_warns_when_nothing_is_configured(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Warns when nothing is configured."""
"""The warning names BOTH directions that stop syncing."""
with caplog.at_level("WARNING"):
_workout_sync.sync_client()
assert "sync is OFF" in caplog.text
assert "Sync is OFF" in caplog.text
assert "will NOT be pushed" in caplog.text
assert "NOT be pulled" in caplog.text
def test_uses_github_alone_when_only_the_token_exists(self) -> None:
"""Uses github alone when only the token exists."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
github = MagicMock()
with patch.object(_sync_client, "GitHubSyncClient", return_value=github):
assert _workout_sync.sync_client() is github
def test_reason_names_the_missing_config(self) -> None:
"""The reason is a sentence a caller can put into its own result."""
client, reason = _workout_sync.sync_client_or_reason()
assert client is None
assert reason.startswith("no Firebase config at ")
def test_mirrors_when_both_the_token_and_firebase_exist(
def test_never_builds_a_client_without_config(self) -> None:
"""An unconfigured machine must not reach the network at all."""
with patch.object(_sync_client, "firebase_client_for") as build:
_workout_sync.sync_client()
build.assert_not_called()
def test_uses_firebase_when_the_config_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Mirrors when both the token and firebase exist."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
_firebase_config(_sync_client, tmp_path, monkeypatch)
github = MagicMock()
monkeypatch.setattr(
_sync_client,
"mirror_client_for",
lambda _app, client: ReachableClient(("mirror", client)),
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=github):
assert _workout_sync.sync_client().identity == ("mirror", github)
def test_uses_firebase_alone_when_only_the_config_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Firebase-only machine must still sync -- no PAT required."""
"""A configured machine syncs over Firebase -- no PAT anywhere."""
_firebase_config(_sync_client, tmp_path, monkeypatch)
firebase = MagicMock()
monkeypatch.setattr(_sync_client, "firebase_client_for", lambda _app: firebase)
monkeypatch.setattr(
_sync_client, "firebase_client_for", lambda *_a, **_k: firebase
)
assert _workout_sync.sync_client() is firebase
assert _workout_sync.sync_client_or_reason() == (firebase, "")
@pytest.mark.parametrize(
"error",
@ -83,14 +77,16 @@ class TestSyncClient:
def test_returns_none_when_firebase_only_is_unusable(
self, error: Exception, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No PAT to fall back to: report it loudly, do not raise."""
"""No fallback exists: report it loudly, do not raise."""
_firebase_config(_sync_client, tmp_path, monkeypatch)
def _boom(*_args: object, **_kwargs: object) -> None:
raise error
monkeypatch.setattr(_sync_client, "firebase_client_for", _boom)
assert _workout_sync.sync_client() is None
client, reason = _workout_sync.sync_client_or_reason()
assert client is None
assert reason.startswith("Firebase unusable: ")
def test_warns_when_firebase_only_is_unusable(
self,

View File

@ -1,65 +0,0 @@
"""``remote_client`` mirror selection, split from ``test_workout_sync_part2``.
Kept separate from the pulls so no workout-sync test file exceeds the repo's
400-line limit.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from crdt_sync import ConfigError
from screen_locker import _sync_client, _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
_firebase_config,
)
if TYPE_CHECKING:
import pytest
class TestRemoteClient:
"""Which backend the workout pull reads from during the cutover."""
def test_stays_on_github_without_firebase_config(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unconfigured machine must not reach the network at all."""
monkeypatch.setattr(
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
)
github = object()
assert _workout_sync.remote_client(github) is github
def test_mirrors_to_github_when_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Configured: reads union both, so either backend's workout counts."""
_firebase_config(_sync_client, tmp_path, monkeypatch)
monkeypatch.setattr(
_sync_client,
"mirror_client_for",
lambda _app, client: ReachableClient(("mirror", client)),
)
github = object()
assert _workout_sync.remote_client(github).identity == ("mirror", github)
def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken Firebase must degrade to GitHub, never fail the pull."""
_firebase_config(_sync_client, tmp_path, monkeypatch)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
github = object()
assert _workout_sync.remote_client(github) is github

View File

@ -1,8 +1,8 @@
"""Tests for the phone-workout sync pull (crdt-sync transport).
Covers the token read, the payload predicates, the per-log record extractors
and the cross-device merge. The backend selection (``sync_client`` /
``remote_client``) and the two public pulls live in ``_part2``.
Covers the payload predicates, the per-log record extractors and the
cross-device merge. The backend selection (``sync_client``) and the two
public pulls live in ``_part2``.
"""
from __future__ import annotations

View File

@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch
from crdt_sync import (
FirebaseSyncError,
GitHubSyncError,
)
from screen_locker import _sync_client, _workout_sync
@ -36,16 +35,15 @@ class TestPullSyncedWorkout:
def test_returns_none_none_when_no_token_is_configured(self) -> None:
"""Returns none none when no token is configured."""
with patch.object(_sync_client, "GitHubSyncClient") as client_cls:
with patch.object(_sync_client, "firebase_client_for") as client_cls:
assert _workout_sync.pull_synced_workout() == (None, None)
client_cls.assert_not_called()
def test_returns_the_error_message_on_a_sync_error(self) -> None:
"""A failed device LISTING is a real error, not "nothing to sync"."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
client.list_directory.side_effect = FirebaseSyncError("offline")
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, "offline")
def test_does_not_propagate_a_firebase_sync_error(self) -> None:
@ -55,10 +53,9 @@ class TestPullSyncedWorkout:
crash the locker with status=1/FAILURE. It must come back as an error
tuple like any other backend failure.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = FirebaseSyncError("firebase exploded")
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert data is None
assert error == "firebase exploded"
@ -74,33 +71,32 @@ class TestPullSyncedWorkout:
_firebase_config(_sync_client, tmp_path, monkeypatch)
client = MagicMock()
client.list_directory.side_effect = FirebaseSyncError("firebase exploded")
monkeypatch.setattr(_sync_client, "firebase_client_for", lambda _app: client)
monkeypatch.setattr(
_sync_client, "firebase_client_for", lambda *_a, **_k: client
)
data, error = _workout_sync.pull_synced_workout()
assert data is None
assert error == "firebase exploded"
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
"""Returns none none when nothing has been pushed yet."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client({"phone": None})
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
def test_returns_none_none_when_there_are_no_devices_at_all(self) -> None:
"""Returns none none when there are no devices at all."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client({})
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
def test_returns_the_payload_on_success(self) -> None:
"""Returns the payload on success."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
payload = _session_payload()
client = _multi_device_client(
{"phone": json.dumps({"a": _session_record_dict("session:a", payload)})}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (payload, None)
def test_finds_a_session_in_a_uuid_named_device_directory(self) -> None:
@ -109,7 +105,6 @@ class TestPullSyncedWorkout:
Reading a single hardcoded ``devices/phone/log.json`` returned a stale
session forever while newer ones sat unread one directory over.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
fresh = _session_payload(note="FROM-UUID-DIR")
client = _multi_device_client(
{
@ -131,12 +126,11 @@ class TestPullSyncedWorkout:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (fresh, None)
def test_returns_the_highest_clock_session_across_devices(self) -> None:
"""Returns the highest clock session across devices."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
newest = _session_payload(note="NEWEST")
client = _multi_device_client(
{
@ -163,7 +157,7 @@ class TestPullSyncedWorkout:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert error is None
assert data == newest
@ -175,7 +169,6 @@ class TestPullSyncedWorkout:
would hand back a record with none of the fields a caller expects --
even though here they carry the HIGHEST clocks in the log.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
session = _session_payload(note="REAL-SESSION")
client = _multi_device_client(
{
@ -201,14 +194,13 @@ class TestPullSyncedWorkout:
)
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert error is None
assert data == session
def test_skips_a_corrupt_device_log_instead_of_failing(self) -> None:
"""A corrupt log costs that device's sessions, not the whole pull."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
payload = _session_payload()
client = _multi_device_client(
{
@ -216,12 +208,11 @@ class TestPullSyncedWorkout:
"phone": json.dumps({"a": _session_record_dict("session:a", payload)}),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (payload, None)
def test_returns_none_none_when_every_device_log_is_corrupt(self) -> None:
"""Returns none none when every device log is corrupt."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client({"corrupt": "{not valid json"})
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)

View File

@ -10,12 +10,12 @@ import json
from unittest.mock import MagicMock, patch
from crdt_sync import (
GitHubSyncError,
FirebaseSyncError,
Hlc,
Record,
)
from screen_locker import _sync_client, _workout_sync
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
_manual_payload,
_manual_record_dict,
@ -32,15 +32,13 @@ class TestPullAllManualRecords:
def test_returns_empty_when_listing_fails(self) -> None:
"""Returns empty when listing fails."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
client.list_directory.side_effect = FirebaseSyncError("offline")
with patch.object(_workout_sync, "sync_client", return_value=client):
assert _workout_sync.pull_all_manual_records() == []
def test_merges_manual_records_across_devices(self) -> None:
"""Merges manual records across devices."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
@ -51,7 +49,7 @@ class TestPullAllManualRecords:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
@ -63,7 +61,6 @@ class TestPullAllManualRecords:
the phone app look like it worked and then silently come back on the
next 15-minute sync.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
hlc = Hlc(wall_time_ms=1000, counter=0, node_id="phone")
tombstoned = Record(
id="manual:gone",
@ -81,7 +78,7 @@ class TestPullAllManualRecords:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert [rid for rid, _ in result] == ["manual:kept"]
@ -92,7 +89,6 @@ class TestPullAllManualRecords:
absent from one device's dict while the live copy won the merge from
the other, so the record was ingested exactly as before.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
hlc = Hlc(wall_time_ms=1000, counter=0, node_id="phone")
tombstoned = Record(
id="manual:dup",
@ -111,30 +107,28 @@ class TestPullAllManualRecords:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert [rid for rid, _ in result] == ["manual:keep"]
def test_skips_missing_and_corrupt_device_logs(self) -> None:
"""Skips missing and corrupt device logs."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _manual_record_dict("manual:a", _manual_payload())}
),
"gone": GitHubSyncError("404"),
"gone": FirebaseSyncError("404"),
"empty": None,
"corrupt": "{not json",
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert [rid for rid, _ in result] == ["manual:a"]
def test_dedups_same_id_keeping_highest_clock(self) -> None:
"""Dedups same ID keeping highest clock."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
@ -157,14 +151,13 @@ class TestPullAllManualRecords:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert len(result) == 1
assert result[0][1]["cost"] == "NEW"
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
"""Ignores a lower clock duplicate seen later."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
@ -187,7 +180,7 @@ class TestPullAllManualRecords:
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
with patch.object(_workout_sync, "sync_client", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert len(result) == 1
assert result[0][1]["cost"] == "NEW"

View File

@ -34,7 +34,6 @@ import sys
from crdt_sync import (
FileSyncStateStore,
GitHubSyncClient,
LogCodec,
Record,
RevisionTracking,
@ -42,12 +41,7 @@ from crdt_sync import (
sync_log,
)
from screen_locker._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_STATE_FILE,
SYNC_TIMEOUT_SECONDS,
)
from screen_locker._constants import SYNC_STATE_FILE
from screen_locker._device import device_identity
from screen_locker._manual_push import (
_decode_log,
@ -55,7 +49,7 @@ from screen_locker._manual_push import (
records_from_workout_log,
)
from screen_locker._sync_tombstones import add_tombstone
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
_logger = logging.getLogger(__name__)
@ -72,15 +66,10 @@ def _report(message: str) -> None:
def _sync(local_log: dict[str, Record]) -> dict[str, Record]:
"""Run one sync tick with *local_log* as this device's view; return the merge."""
identity = device_identity()
token = read_sync_token()
if token is None:
msg = "no sync token — cannot reach the store"
client, reason = sync_client_or_reason()
if client is None:
msg = f"cannot reach the Firebase sync store: {reason}"
raise SystemExit(msg)
client = remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER, SYNC_REPO_NAME, token, timeout_seconds=SYNC_TIMEOUT_SECONDS
)
)
return sync_log(
SyncTarget(
client=client,