Drop the gatelock dependency, and actually sync the pool

Two things the pool needed to be real rather than merely correct.

**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:

    ImportError: libtk8.6.so: cannot open shared object file

A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.

The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.

This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.

**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.

install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.

Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.

seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.

freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-07 15:56:42 +02:00
parent ef69619597
commit aa032c95e4
15 changed files with 457 additions and 58 deletions

View File

@ -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

View File

@ -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 .

View File

@ -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/<app>/. 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",
)

View File

@ -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/<app>/. 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.

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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(

View File

@ -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

View File

@ -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

116
freedays/install.sh Executable file
View File

@ -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 "$@"

View File

@ -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",
]

View File

@ -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

View File

@ -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