diff --git a/.github/workflows/freedays-precommit.yml b/.github/workflows/freedays-precommit.yml index 2aa16be..7c8ff1e 100644 --- a/.github/workflows/freedays-precommit.yml +++ b/.github/workflows/freedays-precommit.yml @@ -19,8 +19,8 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Install sibling packages - run: pip install ./gatelock ./crdt-sync + - name: Install the sibling package + run: pip install ./crdt-sync - name: Install dependencies working-directory: freedays run: pip install -r requirements.txt diff --git a/.github/workflows/freedays-tests.yml b/.github/workflows/freedays-tests.yml index 81da099..a70f7c9 100644 --- a/.github/workflows/freedays-tests.yml +++ b/.github/workflows/freedays-tests.yml @@ -25,12 +25,13 @@ jobs: with: python-version: "3.x" - # freedays imports gatelock (audit signing) and crdt-sync (the merge - # scheme and the Firebase transport). Both are siblings in this repo, so - # they install from the checkout rather than from a tag -- CI must test - # what is on this branch, not what was last released. - - name: Install sibling packages - run: pip install ../gatelock ../crdt-sync + # crdt-sync is a sibling in this repo, so it installs from the checkout + # rather than from a tag -- CI must test what is on this branch, not + # what was last released. Deliberately NOT gatelock: importing it pulls + # in tkinter, and this job has no display precisely because freedays + # must work without one. + - name: Install the sibling package + run: pip install ../crdt-sync - name: Install dependencies run: pip install -r requirements.txt && pip install --no-deps -e . diff --git a/crdt-sync/tool/_seeded_apps.py b/crdt-sync/tool/_seeded_apps.py new file mode 100644 index 0000000..9de7036 --- /dev/null +++ b/crdt-sync/tool/_seeded_apps.py @@ -0,0 +1,45 @@ +"""Which apps keep a desktop Firebase session, and why the list is hand-kept. + +Split out of ``seed_session.py`` for the 250-line cap. The rationale is worth +more than the tuple: it is what stops the next person from "simplifying" this +into a directory scan. +""" + +from __future__ import annotations + +from typing import Final + +# Every app that calls firebase_client_for/mirror_client_for from the desktop, +# and so keeps a refresh token under ~/.config//. Named here rather than +# discovered from ~/.config: a missing directory is exactly the case that +# needs seeding, so discovery would skip the app that needs this most. +# The remaining Flutter-only apps (home_inventory, workout_app) are absent on +# purpose: they authenticate in-app on the phone and have no desktop session. +# `todo` used to be grouped with them, but it grew a real desktop wrapper +# (lib/desktop/wrapper_server.dart) with its own Firebase REST client that +# needs a session exactly like the Python daemons do -- see +# lib/sync/firebase_backend.dart and lib/desktop/wrapper_server.dart in +# ~/todo. Its credential cache lives at ~/.config/todo/firebase_auth.json, +# same shape as every entry below, even though the reader is Dart, not +# Python: FirebaseCredentials.fromJson in crdt_sync_dart parses the exact +# {id_token, refresh_token, expires_at} shape credential_store_for writes. +# +# Re-derive rather than guess when adding one; a repo-by-repo sweep missed two +# of these. Grep all of ~ for the string literal passed to firebase_client_for, +# mirror_client_for and credential_store_for across every *.py. That still +# misses call sites passing a constant (wake_alarm._constants.SYNC_APP_NAME, +# home_guard._constants.APP_NAME), so check those by name too. "interop" is +# tool/interop_seed.py's own scratch store, not an app. +DEFAULT_APPS: Final = ( + "diet_guard", + "wake_alarm", + "screen_locker", + "byox_ladder", + "leetcode_guard", + "todo", + "home_guard", + # The shared free-day pool (~/utils/freedays), not an app: its systemd + # timer is headless and it has no session of its own to inherit, so + # without this a day marked on the phone never reaches the PC. + "freedays", +) diff --git a/crdt-sync/tool/seed_session.py b/crdt-sync/tool/seed_session.py index 06b8499..56aa617 100644 --- a/crdt-sync/tool/seed_session.py +++ b/crdt-sync/tool/seed_session.py @@ -42,38 +42,9 @@ from crdt_sync._remote import RemoteSyncError # Importable as a package module (tool/__init__.py exists) so the loopback # OAuth flow is shared, not reimplemented. +from tool._seeded_apps import DEFAULT_APPS from tool.google_id_token import TokenError, fetch_id_token -# Every app that calls firebase_client_for/mirror_client_for from the desktop, -# and so keeps a refresh token under ~/.config//. Named here rather than -# discovered from ~/.config: a missing directory is exactly the case that -# needs seeding, so discovery would skip the app that needs this most. -# The remaining Flutter-only apps (home_inventory, workout_app) are absent on -# purpose: they authenticate in-app on the phone and have no desktop session. -# `todo` used to be grouped with them, but it grew a real desktop wrapper -# (lib/desktop/wrapper_server.dart) with its own Firebase REST client that -# needs a session exactly like the Python daemons do -- see -# lib/sync/firebase_backend.dart and lib/desktop/wrapper_server.dart in -# ~/todo. Its credential cache lives at ~/.config/todo/firebase_auth.json, -# same shape as every entry below, even though the reader is Dart, not -# Python: FirebaseCredentials.fromJson in crdt_sync_dart parses the exact -# {id_token, refresh_token, expires_at} shape credential_store_for writes. -# -# Re-derive rather than guess when adding one; a repo-by-repo sweep missed two -# of these. Grep all of ~ for the string literal passed to firebase_client_for, -# mirror_client_for and credential_store_for across every *.py. That still -# misses call sites passing a constant (wake_alarm._constants.SYNC_APP_NAME, -# home_guard._constants.APP_NAME), so check those by name too. "interop" is -# tool/interop_seed.py's own scratch store, not an app. -DEFAULT_APPS = ( - "diet_guard", - "wake_alarm", - "screen_locker", - "byox_ladder", - "leetcode_guard", - "todo", - "home_guard", -) # A fixed default so the redirect URI is stable enough to register once on the # Web client. Arbitrary but out of the ephemeral range, and free on this box. diff --git a/freedays/README.md b/freedays/README.md index 8563f54..eeac1d1 100644 --- a/freedays/README.md +++ b/freedays/README.md @@ -85,7 +85,7 @@ where you are standing, it is Tuesday. ## Development ```console -$ pip install ../gatelock ../crdt-sync +$ pip install ../crdt-sync $ pip install -r requirements.txt && pip install --no-deps -e . $ python -m pytest # 100% branch coverage is enforced $ pre-commit run --config .pre-commit-config.yaml --all-files diff --git a/freedays/freedays/_audit.py b/freedays/freedays/_audit.py index c78dc1a..78ab297 100644 --- a/freedays/freedays/_audit.py +++ b/freedays/freedays/_audit.py @@ -17,9 +17,8 @@ import json import logging from typing import TYPE_CHECKING, Any -from gatelock.log_integrity import compute_entry_hmac, generate_hmac_key - from freedays._day import to_iso +from freedays._hmac import generate_key, sign from freedays._paths import Paths, resolve_paths if TYPE_CHECKING: @@ -56,13 +55,10 @@ def record_event( key_target = resolved.hmac_key if not key_target.exists(): # First run on this machine. Generating it here rather than in an - # installer means the trail is signed from the very first entry, and - # that gatelock never gets to warn about a key that was simply never - # created yet. - key_target.parent.mkdir(parents=True, exist_ok=True) - generate_hmac_key(key_target) - key_target.chmod(0o600) - entry["hmac"] = compute_entry_hmac(entry, key_file=key_target) + # installer means the trail is signed from its very first entry, + # with no setup step that can be skipped. + generate_key(key_target) + entry["hmac"] = sign(entry, key_file=key_target) target = resolved.audit try: target.parent.mkdir(parents=True, exist_ok=True) diff --git a/freedays/freedays/_cli.py b/freedays/freedays/_cli.py index 83d48c4..51d5e76 100644 --- a/freedays/freedays/_cli.py +++ b/freedays/freedays/_cli.py @@ -1,9 +1,14 @@ """The ``freedays`` command: the only thing that ever talks about free days. -Every subcommand is pull, never push. Nothing here runs on a timer, nothing -notifies, and no gate ever invokes it to nag -- the apps consult +Every subcommand that says anything is pull, never push: nothing notifies, +and no gate ever invokes this to nag -- the apps consult :func:`freedays.is_free_day` silently and say nothing either way. If you want to know where the pool stands, you ask; otherwise it is invisible. + +``sync-quiet`` is the one subcommand a timer runs, and it exists precisely +so that the timer stays silent: it moves data between devices and reports +success even when there is no network, because a unit that goes red every +time the laptop is offline is a unit you learn to ignore. """ from __future__ import annotations @@ -18,7 +23,7 @@ from freedays._cli_output import emit, emit_error from freedays._constants import DEFAULT_ANNUAL_BUDGET from freedays._day import resolve, to_iso from freedays._errors import FreeDayError -from freedays._sync import SyncUnavailableError, sync +from freedays._sync import SyncUnavailableError, sync, sync_quietly if TYPE_CHECKING: from collections.abc import Sequence @@ -54,6 +59,10 @@ def _build_parser() -> argparse.ArgumentParser: give.add_argument("day", help="YYYY-MM-DD, or 'today'/'tomorrow'") sub.add_parser("sync", help="pull and push the pool now") + sub.add_parser( + "sync-quiet", + help="sync, but exit 0 when the network or credential is unavailable", + ) return parser @@ -98,6 +107,17 @@ def _do_sync() -> int: return _EXIT_OK +def _do_sync_quiet() -> int: + """Sync on a timer's behalf, treating unavailability as success. + + A systemd unit that goes red every time the laptop is offline trains you + to ignore it, which is worse than not having it. A genuine bug still + reaches the journal through the library's own logging. + """ + emit("pool synced" if sync_quietly() else "pool not synced (no remote available)") + return _EXIT_OK + + def _dispatch(args: argparse.Namespace) -> int: if args.command == "status": return _show_status(args.year) @@ -107,7 +127,9 @@ def _dispatch(args: argparse.Namespace) -> int: return _do_mark(args.day, args.reason) if args.command == "release": return _do_release(args.day) - return _do_sync() + if args.command == "sync": + return _do_sync() + return _do_sync_quiet() def main(argv: Sequence[str] | None = None) -> int: diff --git a/freedays/freedays/_hmac.py b/freedays/freedays/_hmac.py new file mode 100644 index 0000000..1864e9c --- /dev/null +++ b/freedays/freedays/_hmac.py @@ -0,0 +1,90 @@ +"""Signing the audit trail, using nothing but the standard library. + +This deliberately does not call ``gatelock.log_integrity``, even though the +scheme below is byte-for-byte the same one and was lifted from it. Importing +``gatelock`` executes ``gatelock/__init__``, which imports ``tkinter`` for the +lock-window machinery -- so a headless policy library would have acquired a +hard dependency on a GUI toolkit, and ``import freedays`` would fail on any +machine without Tk. A sandbox run caught exactly that: + + ImportError: libtk8.6.so: cannot open shared object file + +The signature format is unchanged (HMAC-SHA256 over compact, sorted-key +JSON), so trail entries written before this module existed still verify +against the same key file. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import secrets +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + +_KEY_BYTES = 32 + + +def load_key(key_file: Path) -> bytes | None: + """Return the key bytes at ``key_file``, or None if unreadable.""" + try: + return key_file.read_bytes().strip() + except OSError: + _logger.warning("cannot read the free-day signing key from %s", key_file) + return None + + +def generate_key(key_file: Path) -> bytes | None: + """Create a key at ``key_file`` with owner-only permissions. + + Returns: + The new key bytes, or None if it could not be written. + """ + key = secrets.token_bytes(_KEY_BYTES) + try: + key_file.parent.mkdir(parents=True, exist_ok=True) + key_file.write_bytes(key) + key_file.chmod(0o600) + except OSError: + _logger.warning("cannot write the free-day signing key to %s", key_file) + return None + return key + + +def _payload(entry: dict[str, Any]) -> bytes: + return json.dumps(entry, sort_keys=True, separators=(",", ":")).encode() + + +def sign(entry: dict[str, Any], *, key_file: Path) -> str | None: + """Return the hex HMAC of ``entry``, or None when the key is unavailable. + + ``entry`` must not already contain a ``hmac`` field. + """ + key = load_key(key_file) + if key is None: + return None + return hmac.new(key, _payload(entry), hashlib.sha256).hexdigest() + + +def verify(entry: dict[str, Any], *, key_file: Path) -> bool: + """Whether ``entry``'s stored ``hmac`` matches its contents. + + False when the field is missing or not a string, and when the key cannot + be read -- "cannot check" and "does not match" are the same answer to the + only question a caller asks, which is whether to trust the line. + """ + stored = entry.get("hmac") + if not isinstance(stored, str): + return False + key = load_key(key_file) + if key is None: + return False + without = {name: value for name, value in entry.items() if name != "hmac"} + expected = hmac.new(key, _payload(without), hashlib.sha256).hexdigest() + return hmac.compare_digest(stored, expected) diff --git a/freedays/freedays/tests/test_audit.py b/freedays/freedays/tests/test_audit.py index 22d20fb..3980078 100644 --- a/freedays/freedays/tests/test_audit.py +++ b/freedays/freedays/tests/test_audit.py @@ -7,9 +7,8 @@ from datetime import date import json from typing import TYPE_CHECKING -from gatelock.log_integrity import verify_entry_hmac - from freedays._audit import record_event +from freedays._hmac import verify from freedays._paths import Paths if TYPE_CHECKING: @@ -50,14 +49,14 @@ def test_the_signing_key_is_generated_on_first_use(state_dir: Path) -> None: def test_the_written_entry_verifies_against_the_key(state_dir: Path) -> None: record_event("mark", DAY, actor="device-1") (entry,) = _lines(state_dir / "audit.jsonl") - assert verify_entry_hmac(entry, key_file=state_dir / "hmac.key") + assert verify(entry, key_file=state_dir / "hmac.key") def test_a_tampered_entry_stops_verifying(state_dir: Path) -> None: record_event("mark", DAY, actor="device-1") (entry,) = _lines(state_dir / "audit.jsonl") entry["day"] = "2026-12-25" - assert not verify_entry_hmac(entry, key_file=state_dir / "hmac.key") + assert not verify(entry, key_file=state_dir / "hmac.key") def test_an_existing_key_is_reused_rather_than_regenerated(state_dir: Path) -> None: @@ -71,7 +70,7 @@ def test_explicit_paths_are_honoured(tmp_path: Path) -> None: elsewhere = tmp_path / "elsewhere" record_event("mark", DAY, actor="device-1", paths=Paths.under(elsewhere)) (entry,) = _lines(elsewhere / "audit.jsonl") - assert verify_entry_hmac(entry, key_file=elsewhere / "hmac.key") + assert verify(entry, key_file=elsewhere / "hmac.key") def test_an_unwritable_trail_warns_but_never_raises( diff --git a/freedays/freedays/tests/test_cli.py b/freedays/freedays/tests/test_cli.py index 1193876..d1e8d92 100644 --- a/freedays/freedays/tests/test_cli.py +++ b/freedays/freedays/tests/test_cli.py @@ -130,3 +130,25 @@ def test_sync_failure_is_reported_without_a_traceback( def test_a_missing_subcommand_is_rejected() -> None: with pytest.raises(SystemExit): main([]) + + +def test_sync_quiet_reports_success( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("freedays._sync.firebase_client_for", lambda _app: FakeRemote()) + assert main(["sync-quiet"]) == 0 + assert "pool synced" in capsys.readouterr().out + + +def test_sync_quiet_exits_zero_when_there_is_no_remote( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The timer must not go red just because the machine is offline.""" + + def _refuse() -> None: + msg = "no credentials on this machine" + raise SyncUnavailableError(msg) + + monkeypatch.setattr("freedays._sync.get_client", _refuse) + assert main(["sync-quiet"]) == 0 + assert "no remote available" in capsys.readouterr().out diff --git a/freedays/freedays/tests/test_hmac.py b/freedays/freedays/tests/test_hmac.py new file mode 100644 index 0000000..92475c7 --- /dev/null +++ b/freedays/freedays/tests/test_hmac.py @@ -0,0 +1,96 @@ +"""Signing, and the ways it is allowed to fail.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from freedays._hmac import generate_key, load_key, sign, verify + +if TYPE_CHECKING: + from pathlib import Path + +_ENTRY = {"action": "mark", "day": "2026-12-24", "actor": "device-1"} + + +def test_a_generated_key_is_owner_only(tmp_path: Path) -> None: + """A signing key the rest of the machine can read is not much of one.""" + key_file = tmp_path / "hmac.key" + generate_key(key_file) + assert key_file.stat().st_mode & 0o077 == 0 + + +def test_a_generated_key_round_trips(tmp_path: Path) -> None: + key_file = tmp_path / "hmac.key" + created = generate_key(key_file) + assert created is not None + assert load_key(key_file) == created + + +def test_two_generated_keys_differ(tmp_path: Path) -> None: + first = generate_key(tmp_path / "a.key") + second = generate_key(tmp_path / "b.key") + assert first != second + + +def test_a_key_that_cannot_be_written_returns_none(tmp_path: Path) -> None: + blocked = tmp_path / "a-file" / "hmac.key" + blocked.parent.write_text("I am a file, not a directory", encoding="utf-8") + assert generate_key(blocked) is None + + +def test_a_missing_key_reads_as_none(tmp_path: Path) -> None: + assert load_key(tmp_path / "absent.key") is None + + +def test_signing_and_verifying_agree(tmp_path: Path) -> None: + key_file = tmp_path / "hmac.key" + generate_key(key_file) + signature = sign(_ENTRY, key_file=key_file) + assert signature is not None + assert verify({**_ENTRY, "hmac": signature}, key_file=key_file) + + +def test_signing_is_stable_across_key_order(tmp_path: Path) -> None: + """Sorted-key JSON: the same fields sign the same however they arrive.""" + key_file = tmp_path / "hmac.key" + generate_key(key_file) + reordered = dict(reversed(list(_ENTRY.items()))) + assert sign(_ENTRY, key_file=key_file) == sign(reordered, key_file=key_file) + + +def test_a_tampered_field_fails_verification(tmp_path: Path) -> None: + key_file = tmp_path / "hmac.key" + generate_key(key_file) + signature = sign(_ENTRY, key_file=key_file) + tampered = {**_ENTRY, "day": "2026-12-25", "hmac": signature} + assert not verify(tampered, key_file=key_file) + + +def test_another_key_fails_verification(tmp_path: Path) -> None: + mine = tmp_path / "mine.key" + theirs = tmp_path / "theirs.key" + generate_key(mine) + generate_key(theirs) + signature = sign(_ENTRY, key_file=mine) + assert not verify({**_ENTRY, "hmac": signature}, key_file=theirs) + + +def test_an_entry_without_a_signature_does_not_verify(tmp_path: Path) -> None: + key_file = tmp_path / "hmac.key" + generate_key(key_file) + assert not verify(dict(_ENTRY), key_file=key_file) + + +def test_a_non_string_signature_does_not_verify(tmp_path: Path) -> None: + key_file = tmp_path / "hmac.key" + generate_key(key_file) + assert not verify({**_ENTRY, "hmac": 17}, key_file=key_file) + + +def test_verification_without_a_key_is_a_no(tmp_path: Path) -> None: + """ "Cannot check" and "does not match" are the same answer to the caller.""" + assert not verify({**_ENTRY, "hmac": "deadbeef"}, key_file=tmp_path / "absent.key") + + +def test_signing_without_a_key_returns_none(tmp_path: Path) -> None: + assert sign(_ENTRY, key_file=tmp_path / "absent.key") is None diff --git a/freedays/install.sh b/freedays/install.sh new file mode 100755 index 0000000..c64c16f --- /dev/null +++ b/freedays/install.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# ============================================================================ +# Install the freedays CLI and its sync timer for the current user. +# +# freedays gets its OWN venv rather than borrowing an app's. The pool is +# shared by five apps, so binding the command that manages it to whichever +# app venv happened to be active would make "is freedays installed?" depend +# on something unrelated -- and uninstalling that app would take the CLI with +# it. This is idempotent: rerun it after any change. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UTILS_DIR="$(dirname "$REPO_DIR")" +readonly SCRIPT_NAME REPO_DIR UTILS_DIR +readonly STATE_DIR="${HOME}/.local/share/freedays" +readonly VENV_DIR="${STATE_DIR}/venv" +readonly BIN_DIR="${HOME}/.local/bin" +readonly UNIT_DIR="${HOME}/.config/systemd/user" +readonly SYSTEM_PYTHON="/usr/bin/python3" + +log() { printf 'install: %s\n' "$1" >&2; } +fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; } + +usage() { + echo "Usage: $SCRIPT_NAME [--no-timer]" + echo " --no-timer install the CLI only; do not enable the sync timer" + exit 0 +} + +INSTALL_TIMER=1 + +build_venv() { + log "building the freedays venv at $VENV_DIR" + mkdir -p "$STATE_DIR" + if [[ ! -x "$VENV_DIR/bin/python" ]]; then + "$SYSTEM_PYTHON" -m venv "$VENV_DIR" || fail "could not create the venv" + fi + # Siblings install from the working tree, not from a tag: this script runs + # from a checkout, and installing the released tag here would silently + # test something other than what is on disk. + "$VENV_DIR/bin/pip" install --quiet --upgrade pip || fail "pip self-upgrade" + "$VENV_DIR/bin/pip" install --quiet "$UTILS_DIR/crdt-sync" \ + || fail "the crdt-sync sibling package" + "$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \ + || fail "freedays itself" +} + +link_cli() { + log "putting the freedays command on PATH at $BIN_DIR/freedays" + mkdir -p "$BIN_DIR" + ln -sf "$VENV_DIR/bin/freedays" "$BIN_DIR/freedays" +} + +install_timer() { + log "installing the sync timer" + mkdir -p "$UNIT_DIR" + install -m 644 "$REPO_DIR/systemd/freedays-sync.service" "$UNIT_DIR/" + install -m 644 "$REPO_DIR/systemd/freedays-sync.timer" "$UNIT_DIR/" + systemctl --user daemon-reload || fail "systemctl daemon-reload" + systemctl --user enable --now freedays-sync.timer || fail "enabling the timer" +} + +verify() { + # The check that matters: the command resolves, and the gate apps' + # interpreter can import the library. Those are two different + # environments and either can be broken on its own. + log "verifying" + "$BIN_DIR/freedays" status >/dev/null || fail "the freedays CLI does not run" + "$SYSTEM_PYTHON" -c "import freedays" 2>/dev/null || { + log "NOTE: the system python cannot import freedays yet." + log " The gate apps need it there; their own install.sh does that." + } + if [[ "$INSTALL_TIMER" -eq 1 ]]; then + systemctl --user is-enabled freedays-sync.timer >/dev/null \ + || fail "the sync timer is not enabled" + fi +} + +main() { + build_venv + link_cli + if [[ "$INSTALL_TIMER" -eq 1 ]]; then + install_timer + fi + verify + + echo "============================================================" + "$BIN_DIR/freedays" status + echo "============================================================" + log "done. 'freedays mark today' takes the day off, everywhere." + if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + log "NOTE: $BIN_DIR is not on your PATH; add it to use 'freedays' bare." + fi +} + +while [[ $# -gt 0 ]]; do + case $1 in + --no-timer) + INSTALL_TIMER=0 + shift + ;; + -h|--help) + usage + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +main "$@" diff --git a/freedays/pyproject.toml b/freedays/pyproject.toml index e869e9e..6024698 100644 --- a/freedays/pyproject.toml +++ b/freedays/pyproject.toml @@ -7,8 +7,11 @@ name = "freedays" version = "0.1.0" description = "Shared pre-declared free-day pool: one global list of days on which every gate app stands down" requires-python = ">=3.14" +# Deliberately NOT gatelock. Its __init__ imports tkinter for the lock-window +# machinery, which would give this headless policy library a hard dependency +# on a GUI toolkit -- `import freedays` then fails anywhere without Tk. The +# ~15 lines of HMAC that were wanted live in freedays/_hmac.py instead. dependencies = [ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", ] diff --git a/freedays/systemd/freedays-sync.service b/freedays/systemd/freedays-sync.service new file mode 100644 index 0000000..5d309ce --- /dev/null +++ b/freedays/systemd/freedays-sync.service @@ -0,0 +1,19 @@ +[Unit] +Description=Pull and push the shared free-day pool +Documentation=file:///home/kuhy/utils/freedays/README.md +# Without a network this exits 0 having done nothing, by design -- the gates +# read the last-merged local file and a stale pool is never a reason to fail. +After=network-online.target + +[Service] +Type=oneshot +# `sync-quiet` rather than `sync`: this unit must never report failure just +# because the machine is offline or the credential has not been set up yet. +# A real bug still shows up in the journal via the library's own logging. +ExecStart=%h/.local/share/freedays/venv/bin/freedays sync-quiet + +# A hung network call must not leave a unit running until the next tick. +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/freedays/systemd/freedays-sync.timer b/freedays/systemd/freedays-sync.timer new file mode 100644 index 0000000..2b3cf9a --- /dev/null +++ b/freedays/systemd/freedays-sync.timer @@ -0,0 +1,19 @@ +[Unit] +Description=Sync the shared free-day pool periodically +Documentation=file:///home/kuhy/utils/freedays/README.md + +[Timer] +# Every 15 minutes. A free day marked on the phone should reach the PC well +# before the next gate fires, and the gates that run most often (wake-alarm +# every minute, the lockers every 5) read the *local* file, so this interval +# bounds staleness rather than gating anything. +OnBootSec=2min +OnUnitActiveSec=15min + +# A laptop that was asleep at the scheduled tick should sync on waking, not +# silently wait another 15 minutes -- the overnight case is exactly when a +# day gets marked from bed and needs to be there by morning. +Persistent=true + +[Install] +WantedBy=timers.target