mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 17:47:23 +01:00
Show which sync backend the PC uses in the status window
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
This commit is contained in:
parent
4095e0ae5d
commit
b008992cb7
162
screen_locker/_sync_status.py
Normal file
162
screen_locker/_sync_status.py
Normal file
@ -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}"
|
||||
@ -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")
|
||||
|
||||
223
screen_locker/tests/test_sync_status.py
Normal file
223
screen_locker/tests/test_sync_status.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user