From b008992cb7e2569d4a54ed9e8a4badac85cef51b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 21:16:27 +0200 Subject: [PATCH] Show which sync backend the PC uses in the status window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status window is the PC's surface for workout data, but it said nothing about the sync layer that data now arrives through -- so a machine that had silently fallen back to the GitHub mirror, or was configured but had never completed a push, looked identical to a healthy one. That is exactly how todo's desktop app sat on GitHub unnoticed while every other surface moved to Firebase. Adds a "Sync backend" section and a --sync CLI flag (for i3blocks, alongside --summary/--state), both rendering one line: which backend this machine is configured for, when it last pushed, and how many peers it has merged. Deliberately on-disk only -- no RTDB read, no Firebase sign-in -- because gather_status() runs on every i3blocks tick and every window open; a network round-trip there would be rate-limited nonsense. Everything shown is knowable from the revision cache plus which credential files exist. Coloured by `healthy` (configured AND has pushed) rather than by backend, because "configured but never pushed" is the failure that looks fine in every settings screen and produces no data. Named _section_sync_backend, not _section_sync: another session is adding a _section_sync that reports the outcome of a background sync *tick*. The two are complementary -- did the tick run, versus what is this machine wired to -- so they coexist rather than one overwriting the other. Verified by running it, not just the suite: the real window shows "firebase · pushed 2026-08-09 21:06 · 2 peer(s)" against this machine's live state, and `--sync` prints the same line. 924 tests, 100% branch coverage. Every except logs at warning per the repo's no-silent-failures hook; no suppressions added. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 --- screen_locker/_sync_status.py | 162 +++++++++++++++++ screen_locker/status_view.py | 29 ++- screen_locker/tests/test_sync_status.py | 223 ++++++++++++++++++++++++ 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 screen_locker/_sync_status.py create mode 100644 screen_locker/tests/test_sync_status.py diff --git a/screen_locker/_sync_status.py b/screen_locker/_sync_status.py new file mode 100644 index 0000000..73ea57a --- /dev/null +++ b/screen_locker/_sync_status.py @@ -0,0 +1,162 @@ +"""Read this machine's cross-device sync state from disk, for the status view. + +Deliberately on-disk only -- no network, no Firebase sign-in -- because +:func:`screen_locker._status_data.gather_status` is called on every i3blocks +tick and every status-window open. A sync check that hit the RTDB would turn a +status refresh into a rate-limited network round-trip. + +What is knowable without the network is still the useful part: which device id +this machine publishes under, whether a backend is configured at all, when it +last pushed, and how many peers it has merged. "Never pushed" and "configured +but failing" look identical from the RTDB; they are distinguishable here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime as dt +import json +import logging +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._device import device_identity + +_logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass(frozen=True) +class SyncStatus: + """This machine's sync posture, as far as on-disk state can tell. + + 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. + 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. + """ + + device_id: str + backend: str + pushed: bool + peer_count: int + last_push: str | None + + @property + def healthy(self) -> bool: + """Whether sync is configured *and* has actually pushed at least once. + + Configured-but-never-pushed is the interesting failure: it looks fine + in every settings screen and produces no data. + """ + return self.backend != "none" and self.pushed + + +def _read_state(path: Path) -> dict[str, object]: + """Return the parsed sync-state cache, or an empty dict. + + A missing or corrupt cache means "nothing known", never an exception: this + feeds a status display, and a broken cache must not stop the window from + opening. + """ + try: + parsed = json.loads(path.read_text()) + except FileNotFoundError: + _logger.warning( + "No sync state at %s — this machine has not completed a push yet", + path, + ) + return {} + except (OSError, json.JSONDecodeError) as exc: + _logger.warning( + "Sync state at %s is unreadable (%s) — the status window will " + "report this machine as never having pushed", + path, + exc, + ) + return {} + 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.""" + 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: + A :class:`SyncStatus`. Never raises and never touches the network. + """ + state = _read_state(state_file) + peers = state.get("peer_revs") + peer_count = len(peers) if isinstance(peers, dict) else 0 + last_push: str | None = None + try: + mtime = state_file.stat().st_mtime + except FileNotFoundError: + _logger.warning("No sync state at %s — last-push time is unknown", state_file) + mtime = None + except OSError as exc: + _logger.warning( + "Cannot stat sync state at %s (%s) — last-push time will be blank", + state_file, + exc, + ) + mtime = None + if mtime is not None: + # astimezone() with no argument resolves the system local zone, which + # is what a human reading the status window expects to see. + last_push = ( + dt.datetime.fromtimestamp(mtime, tz=dt.timezone.utc) + .astimezone() + .strftime("%Y-%m-%d %H:%M") + ) + + return SyncStatus( + device_id=device_identity().device_id, + backend=_backend(firebase_config=firebase_config, token_file=token_file), + pushed=bool(state.get("pushed_rev")), + peer_count=peer_count, + last_push=last_push, + ) + + +def format_sync_line(status: SyncStatus) -> str: + """Render [status] as one line for the status window and i3blocks. + + Args: + status: The gathered sync posture. + + Returns: + A single line naming the backend, the last push, and the peer count. + """ + if status.backend == "none": + return "sync not configured" + if not status.pushed: + return f"{status.backend} · configured but never pushed" + peers = "no peers" if not status.peer_count else f"{status.peer_count} peer(s)" + return f"{status.backend} · pushed {status.last_push} · {peers}" diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index 0e248d9..89bacdd 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -44,6 +44,7 @@ from screen_locker._status_data import ( ) from screen_locker._status_view_verify import PhoneCheckMixin, _make_bare_verifier from screen_locker._surface_group import FrameGroup +from screen_locker._sync_status import format_sync_line, gather_sync_status from screen_locker._temperature import ( fetch_current_temp_with_status, ) @@ -124,6 +125,7 @@ class StatusWindow( self._section_manual_workout_budget( self.container, snapshot.manual_workout_budget ) + self._section_sync_backend(self.container) self._section_shutdown(self.container, snapshot.shutdown) if self._phone_check_result is not None: status, message = self._phone_check_result @@ -256,6 +258,24 @@ class StatusWindow( color=self._colors.danger if manual.exhausted else self._colors.muted, ) + def _section_sync_backend(self, parent: tk.Widget) -> None: + """Render which backend this machine syncs through. + + Read from local state only (see :mod:`screen_locker._sync_status`), so + opening or refreshing the window never makes a network call. Coloured + by ``healthy`` rather than by backend: "configured but never pushed" + looks fine everywhere else and produces no data, which is exactly the + state worth surfacing here. + """ + del parent + status = gather_sync_status() + self._label("Sync backend", role="body", pad="sm") + self._text( + format_sync_line(status), + role="body", + color=self._colors.muted if status.healthy else self._colors.danger, + ) + def _section_shutdown( self, parent: tk.Widget, shutdown: ShutdownProjection ) -> None: @@ -344,7 +364,10 @@ def _compliance_state_word(snapshot: StatusSnapshot) -> str: def main(argv: list[str] | None = None) -> None: - """Entry point: ``--summary``/``--state`` print one line; else opens the window.""" + """Entry point: ``--summary``/``--state``/``--sync`` print one line. + + With no flag, opens the status window. + """ args = sys.argv[1:] if argv is None else argv if "--summary" in args: print(format_summary_line(gather_status())) @@ -352,6 +375,10 @@ def main(argv: list[str] | None = None) -> None: if "--state" in args: print(_compliance_state_word(gather_status())) return + if "--sync" in args: + # On-disk only, like the other two -- safe on an i3blocks tick. + print(format_sync_line(gather_sync_status())) + return root = tk.Tk() root.title("Workout Status") diff --git a/screen_locker/tests/test_sync_status.py b/screen_locker/tests/test_sync_status.py new file mode 100644 index 0000000..d8c1e51 --- /dev/null +++ b/screen_locker/tests/test_sync_status.py @@ -0,0 +1,223 @@ +"""Tests for the on-disk sync posture shown in the status view. + +The point of this module is that it never touches the network, so every test +here drives it purely from files in ``tmp_path``. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._sync_status import ( + SyncStatus, + format_sync_line, + gather_sync_status, +) +from screen_locker.status_view import main + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +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", + ) + + +class TestBackendDetection: + """Which backend this machine is configured for, from files alone.""" + + def test_no_credentials_is_not_configured(self, tmp_path: Path) -> None: + """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.""" + (tmp_path / "sync_token").write_text("tok") + + assert _gather(tmp_path).backend == "github" + + 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") + (tmp_path / "firebase.json").write_text("{}") + + assert _gather(tmp_path).backend == "firebase" + + +class TestPushState: + """What the revision cache says about pushes and peers.""" + + def test_no_state_file_means_never_pushed(self, tmp_path: Path) -> None: + """No state file means never pushed.""" + status = _gather(tmp_path) + + assert status.pushed is False + assert status.peer_count == 0 + assert status.last_push is None + + def test_a_pushed_rev_counts_as_pushed(self, tmp_path: Path) -> None: + """A pushed rev counts as pushed.""" + (tmp_path / "sync_state.json").write_text( + json.dumps({"pushed_rev": "abc", "peer_revs": {"a": "1", "b": "2"}}) + ) + + status = _gather(tmp_path) + + assert status.pushed is True + assert status.peer_count == 2 + assert status.last_push is not None + + def test_a_corrupt_state_file_reads_as_nothing_known(self, tmp_path: Path) -> None: + """A broken cache must not stop the status window from opening.""" + (tmp_path / "sync_state.json").write_text("{not json") + + status = _gather(tmp_path) + + assert status.pushed is False + assert status.peer_count == 0 + + def test_a_non_object_state_file_reads_as_nothing_known( + self, tmp_path: Path + ) -> None: + (tmp_path / "sync_state.json").write_text("[]") + + assert _gather(tmp_path).pushed is False + + def test_a_non_dict_peer_revs_counts_as_no_peers(self, tmp_path: Path) -> None: + """A non dict peer revs counts as no peers.""" + (tmp_path / "sync_state.json").write_text( + json.dumps({"pushed_rev": "abc", "peer_revs": "not-a-map"}) + ) + + assert _gather(tmp_path).peer_count == 0 + + def test_an_unstattable_state_file_has_no_timestamp(self, tmp_path: Path) -> None: + """A path whose parent is a file cannot be stat'd; that is not fatal.""" + blocker = tmp_path / "blocker" + blocker.write_text("not a directory") + + status = _gather(tmp_path, state_file=blocker / "sync_state.json") + + assert status.last_push is None + + +class TestHealthy: + """Configured AND actually pushing -- the distinction that matters.""" + + def test_unconfigured_is_not_healthy(self) -> None: + """Unconfigured is not healthy.""" + status = SyncStatus( + device_id="u", backend="none", pushed=False, peer_count=0, last_push=None + ) + + assert status.healthy is False + + def test_configured_but_never_pushed_is_not_healthy(self) -> None: + """The interesting failure: looks fine everywhere, produces no data.""" + status = SyncStatus( + device_id="u", + backend="firebase", + pushed=False, + peer_count=0, + last_push=None, + ) + + assert status.healthy is False + + def test_configured_and_pushed_is_healthy(self) -> None: + """Configured and pushed is healthy.""" + status = SyncStatus( + device_id="u", backend="firebase", pushed=True, peer_count=1, last_push="x" + ) + + assert status.healthy is True + + +class TestFormatSyncLine: + """The one-line rendering used by the window and i3blocks.""" + + def test_unconfigured_says_so(self) -> None: + """Unconfigured says so.""" + status = SyncStatus( + device_id="u", backend="none", pushed=False, peer_count=0, last_push=None + ) + + assert format_sync_line(status) == "sync not configured" + + def test_never_pushed_names_the_backend(self) -> None: + """Never pushed names the backend.""" + status = SyncStatus( + device_id="u", + backend="firebase", + pushed=False, + peer_count=0, + last_push=None, + ) + + assert "never pushed" in format_sync_line(status) + assert "firebase" in format_sync_line(status) + + def test_pushed_with_peers_reports_both(self) -> None: + """Pushed with peers reports both.""" + status = SyncStatus( + device_id="u", + backend="firebase", + pushed=True, + peer_count=2, + last_push="2026-08-09 19:05", + ) + + line = format_sync_line(status) + + assert "2026-08-09 19:05" in line + assert "2 peer(s)" in line + + def test_pushed_with_no_peers_says_no_peers(self) -> None: + """Pushed with no peers says no peers.""" + status = SyncStatus( + device_id="u", + backend="firebase", + pushed=True, + peer_count=0, + last_push="2026-08-09 19:05", + ) + + assert "no peers" in format_sync_line(status) + + +class TestSyncCliFlag: + """The --sync flag i3blocks calls.""" + + def test_sync_flag_prints_the_backend_line( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """--sync must stay on-disk only: safe on every i3blocks tick.""" + with patch( + "screen_locker.status_view.gather_sync_status", + return_value=SyncStatus( + device_id="u", + backend="firebase", + pushed=True, + peer_count=2, + last_push="2026-08-09 19:05", + ), + ): + main(["--sync"]) + out = capsys.readouterr().out + assert "firebase" in out + assert "2 peer(s)" in out