Compare commits

..

No commits in common. "main" and "focus-project-v0.10.0" have entirely different histories.

106 changed files with 696 additions and 7890 deletions

View File

@ -16,4 +16,3 @@ overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract` - lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100` - coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
- coverage-gaps: `coverage-gaps coverage.xml` - coverage-gaps: `coverage-gaps coverage.xml`
- deploy: `git -C ~/src/utils pull --ff-only && ~/src/utils/scripts/deploy.sh` (installs the sickdays CLI + launcher from the live checkout, then exits 1 unless every released library's version tag is on origin and matches HEAD and the live `sickdays` runs the text-free checkout; `--verify-only` for the health check alone)

View File

@ -4,13 +4,13 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope. dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none' publish_to: 'none'
version: 0.2.2 version: 0.2.1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
dependencies: dependencies:
file_picker: 13.3.3 file_picker: 13.3.2
file_selector: 1.1.0 file_selector: 1.1.0
flutter: flutter:
sdk: flutter sdk: flutter

View File

@ -1,88 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
itself does not change; this pins that the gate reaches pay.
"""
from __future__ import annotations
from datetime import date, datetime
import importlib
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import pytest
from earned_time import credit_units, earners_for
if TYPE_CHECKING:
from types import ModuleType
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
def at(hour: int, minute: int) -> float:
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
@pytest.fixture
def real_fp(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> ModuleType:
"""The real focus_project on redirected state (HOME, data dir, key)."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
monkeypatch.syspath_prepend(str(_SIBLING))
fp = importlib.import_module("focus_project")
fp.create_project(
"hr",
name="HR",
start=DAY,
end=DAY,
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
return fp
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
log = tmp_path / "data" / "activity.jsonl"
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
harvest = importlib.import_module("focus_project.harvest")
assert harvest.harvest(now=at(23, 0)) == 0
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
def row(source: str, kind: str, ts: float) -> dict[str, object]:
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
TIMER = (
row("timer", "start", at(9, 0)),
row("timer", "checkin", at(9, 5)),
row("timer", "stop", at(9, 10)),
)
def test_timer_only_day_pays_zero(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
assert date(2026, 11, 8) == real_fp.BACKING_FROM
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
def test_backed_timer_day_still_pays(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
beat = row("tutor", "heartbeat", at(9, 5))
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10

View File

@ -1,143 +0,0 @@
# Pre-commit Configuration for feedback_inbox
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# Update: pre-commit autoupdate
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config feedback_inbox/.pre-commit-config.yaml --all-files
files: ^feedback_inbox/
default_language_version:
python: python3
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: [--maxkb=2000]
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements
- id: name-tests-test
args: [--pytest-test-first]
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- id: requirements-txt-fixer
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh feedback_inbox
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: Block ruff noqa file-level comments
entry: '(?i)#\s*ruff:\s*noqa'
language: pygrep
types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.4
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
args:
- --ignore-missing-imports
- --no-error-summary
- --disable-error-code=no-untyped-def
- --disable-error-code=no-untyped-call
- --disable-error-code=var-annotated
- --disable-error-code=no-any-unimported
- --disable-error-code=type-arg
- --disable-error-code=no-any-return
- --disable-error-code=misc
- --disable-error-code=unused-ignore
- --disable-error-code=unreachable
- --disable-error-code=assignment
- --disable-error-code=no-redef
- --disable-error-code=attr-defined
- --disable-error-code=arg-type
- --disable-error-code=union-attr
- --disable-error-code=call-overload
- --disable-error-code=return-value
- --disable-error-code=redundant-cast
- --disable-error-code=empty-body
- --disable-error-code=list-item
- repo: https://github.com/pylint-dev/pylint
rev: v4.0.8
hooks:
- id: pylint
args:
- --rcfile=feedback_inbox/pyproject.toml
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args:
- -c
- feedback_inbox/pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(feedback_inbox/feedback_inbox/tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: scripts/run_subproject_tests.sh feedback_inbox
language: script
types: [python]
pass_filenames: false
require_serial: true
stages: [pre-push]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --skip=*.json,*.lock,.git,__pycache__,.venv

View File

@ -1,70 +0,0 @@
# feedback_inbox
Turns every game's dev-feedback bundle into one todo-app task, so feedback
kuhy saves with F12 is never forgotten.
A game saves `~/data/<repo>_binaries/feedback/<stamp>/` (`report.json`,
screenshots, logs) and appends a ready-to-run Claude prompt to
`feedback-<date>.md` beside it. This package imports each complete bundle once
as a task in the todo app for `kuhyx/<repo>`.
## Task shape
- Title: `MANUAL WORK REQUIRED: <repo> feedback: <first line of his note>` (note capped
at 120 characters). The prefix makes todoImplementer list it as a human part and never
run it. No note: `... feedback: <game> <combo> <HH:MM>`. A `report.json` that does
not parse still gets a task, tagged `[corrupt report.json]`; the note then
comes from the markdown entry.
- Body: a `Due: <next day 10:00>` line, then the bundle's markdown entry (the
prompt with absolute paths).
- The task is its own note (`add_task` with `new_note`). The server stores that as
`<repo> todos: <title>` plus a `## <title>` section, so right after adding, the inbox
rewrites it with `update_task` to the exact title and a section-free body (the entry's
own `##` headings become `###`). todoImplementer splits notes at `## ` headings, and a
title that does not start with `MANUAL WORK REQUIRED: ` would make a runnable part.
The marker records `format: 2` once the rewrite worked; a failed rewrite is retried on
the next sweep (never a second task), and markers without it are repaired the same way.
- Closing the task cancels the feedback. "Act on feedback" in a Claude session
means working every open `<repo> feedback:` task.
The todo app has no due-date or reminder field yet, so the due time is only
text in the body.
## How it runs
| Unit | Does |
|---|---|
| `feedback-inbox.path` | `PathChanged` on every existing `*_binaries/feedback` folder; runs `feedback-inbox run` |
| `feedback-inbox-sync.path` | `PathChanged` on `~/data` and every `*_binaries` folder; runs `sync` then `run` |
| `feedback-inbox-sync.timer` | every 5 min, the same sweep (events during a run are lost by `.path` units) |
`.path` units take no globs: `feedback-inbox sync` writes the folder list into
the drop-ins `feedback-inbox{,-sync}.path.d/dirs.conf` and reloads systemd only
when the set of games changed.
A bundle is complete when `report.json` is at least 2 s old and its markdown
entry exists (matched by the stamp folder in the entry's paths). Incomplete
bundles are retried; `run` waits up to 15 s for them.
The import marker is `.inbox-imported.json` inside the bundle (task id, title,
due). It is written only after the todo server accepted the task, so a server
outage leaves no marker, exits non-zero, and the next sweep retries.
## Install
```bash
scripts/run_subproject_tests.sh feedback_inbox # from the monorepo root
feedback_inbox/install.sh
```
`install.sh` builds a stdlib-only venv, links `feedback-inbox` into
`~/.local/bin`, mints a todo-server token into `~/.config/feedback-inbox/token`
(0600; revoke by deleting its line in `~/.config/todo-mcp/tokens`) and enables
the units. Enabling sweeps once, so existing bundles are imported then.
## Tests
`pytest` from this directory (100 % branch coverage enforced). Tests use a temp
`HOME`, a fake todo client, and a local HTTP server for the MCP client; the
autouse `conftest.py` redirects the one path resolver and points the todo URL
at a dead port.

View File

@ -1,2 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Turns every game dev-feedback bundle into one todo-app task."""

View File

@ -1,15 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``python -m feedback_inbox``."""
from __future__ import annotations
from feedback_inbox._cli import main
def run() -> int:
"""Run the CLI on ``sys.argv``."""
return main()
if __name__ == "__main__":
raise SystemExit(run())

View File

@ -1,57 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Finding games' feedback folders and bundles, and the stamp arithmetic."""
from __future__ import annotations
from datetime import datetime, timedelta
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
BINARIES_SUFFIX = "_binaries"
STAMP_FORMAT = "%Y-%m-%dT%H-%M-%S"
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}$")
_REMINDER_HOUR = 10
def binaries_dirs(data_root: Path) -> list[Path]:
"""Every ``<game>_binaries`` directory directly under ``data_root``."""
if not data_root.is_dir():
return []
return sorted(
p
for p in data_root.iterdir()
if p.name.endswith(BINARIES_SUFFIX) and p.is_dir()
)
def feedback_dirs(data_root: Path) -> list[Path]:
"""Every existing ``<game>_binaries/feedback`` directory."""
return [
d / "feedback" for d in binaries_dirs(data_root) if (d / "feedback").is_dir()
]
def repo_name(feedback_dir: Path) -> str:
"""The game's repo name: ``cleanup-plate_binaries`` -> ``cleanup-plate``."""
return feedback_dir.parent.name.removesuffix(BINARIES_SUFFIX)
def bundles_in(feedback_dir: Path) -> list[Path]:
"""Bundle folders (named by a millisecond stamp), oldest first."""
return sorted(
p for p in feedback_dir.iterdir() if p.is_dir() and _STAMP.match(p.name)
)
def parse_stamp(stamp: str) -> datetime:
"""The local time a bundle was saved, from its folder name."""
return datetime.strptime(stamp[:19], STAMP_FORMAT)
def due_at(stamp: str) -> datetime:
"""The reminder time: the day after the bundle, at 10:00 local."""
day = parse_stamp(stamp) + timedelta(days=1)
return day.replace(hour=_REMINDER_HOUR, minute=0, second=0, microsecond=0)

View File

@ -1,80 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``feedback-inbox run | sync``: what the systemd units execute."""
from __future__ import annotations
import argparse
import fcntl
import logging
import time
from typing import TYPE_CHECKING
from feedback_inbox._inbox import Importer, Outcome, Waiter, run_settled
from feedback_inbox._paths import resolve_paths
from feedback_inbox._todo import McpTodoClient
from feedback_inbox._units import run_systemctl, sync_units
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from feedback_inbox._todo import TodoClient
LOG = logging.getLogger("feedback_inbox")
OWNER = "kuhyx"
DEFAULT_WAIT = 15.0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="feedback-inbox")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="import new bundles as todo tasks")
run.add_argument("--wait", type=float, default=DEFAULT_WAIT)
sub.add_parser("sync", help="re-arm the path units for the current games")
return parser
def _run(wait: float, client: TodoClient | None) -> int:
paths = resolve_paths()
todo = client or McpTodoClient(paths.mcp_url, paths.token_file)
paths.lock_file.parent.mkdir(parents=True, exist_ok=True)
# Blocking lock: a second run waits, then finds the markers and skips.
with paths.lock_file.open("a", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
results = run_settled(
paths.data_root,
Importer(todo, OWNER),
Waiter(wait, time.time, time.sleep),
)
failed = 0
for result in results:
if result.outcome is Outcome.FAILED:
failed += 1
LOG.error("failed %s: %s", result.bundle, result.detail)
elif result.outcome is not Outcome.SKIPPED:
LOG.info("%s %s %s", result.outcome, result.bundle, result.task_id or "")
return 1 if failed else 0
def _sync(systemctl: Callable[[Sequence[str]], int]) -> int:
paths = resolve_paths()
try:
changed = sync_units(paths.data_root, paths.unit_dir, systemctl)
except RuntimeError:
LOG.exception("sync failed")
return 1
LOG.info("watch list %s", "rewritten" if changed else "unchanged")
return 0
def main(
argv: Sequence[str] | None = None,
*,
client: TodoClient | None = None,
systemctl: Callable[[Sequence[str]], int] = run_systemctl,
) -> int:
"""Entry point; ``client`` and ``systemctl`` are test seams."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
args = _parser().parse_args(argv)
if args.command == "run":
return _run(args.wait, client)
return _sync(systemctl)

View File

@ -1,74 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A bundle's markdown entry and its report.json."""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
_SECTION = re.compile(r"(?m)^(?=## )")
_GAME = re.compile(r"Game `([^`]*)`, combo `([^`]*)`")
_BODY_END = re.compile(r"(?m)^- Screenshot")
@dataclass(frozen=True)
class Report:
"""The fields of report.json the inbox needs."""
note: str
game: str
combo: str
def find_entry(feedback_dir: Path, bundle: Path) -> str | None:
"""The ``feedback-<date>.md`` section whose paths point into ``bundle``.
Matched by the stamp folder in its paths, not by the heading time (which
drops the milliseconds), so a bundle never picks up a neighbour's entry.
"""
needle = f"/{bundle.name}/"
for md in sorted(feedback_dir.glob("feedback-*.md")):
try:
text = md.read_text(encoding="utf-8")
except OSError, UnicodeDecodeError:
continue
for section in _SECTION.split(text):
if needle in section:
return section.strip()
return None
def read_report(path: Path) -> Report | None:
"""Parse report.json; ``None`` when it cannot be read as a JSON object."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except OSError, UnicodeDecodeError, json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
return Report(
note=_text(data.get("note")),
game=_text(data.get("game")),
combo=_text(data.get("combo")),
)
def _text(value: object) -> str:
return value if isinstance(value, str) else ""
def note_from_entry(entry: str) -> str:
"""His note, as written between the heading and the file list."""
_head, _, rest = entry.partition("\n")
return _BODY_END.split(rest, maxsplit=1)[0].strip()
def game_and_combo(entry: str) -> tuple[str, str]:
"""Game and combo from the entry's ``Game ..., combo ...`` line."""
found = _GAME.search(entry)
return (found.group(1), found.group(2)) if found else ("", "")

View File

@ -1,235 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Turn complete feedback bundles into todo tasks, once each."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import json
import logging
import re
from typing import TYPE_CHECKING
from feedback_inbox._bundle import (
bundles_in,
due_at,
feedback_dirs,
parse_stamp,
repo_name,
)
from feedback_inbox._entry import (
Report,
find_entry,
game_and_combo,
note_from_entry,
read_report,
)
from feedback_inbox._todo import TodoError
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from feedback_inbox._todo import TodoClient
LOG = logging.getLogger("feedback_inbox")
MARKER = ".inbox-imported.json"
# Marker format 2: the stored note was rewritten to our exact title and body.
FORMAT = 2
SETTLE_SECONDS = 2.0
TITLE_CAP = 120
MANUAL_PREFIX = "MANUAL WORK REQUIRED: "
CORRUPT_TAG = " [corrupt report.json]"
CORRUPT_LINE = "Warning: report.json is corrupt; the note below comes from the entry."
class Outcome(StrEnum):
"""What happened to one bundle in a run."""
IMPORTED = "imported"
SKIPPED = "skipped"
PENDING = "pending"
FAILED = "failed"
@dataclass(frozen=True)
class Result:
"""One bundle's outcome, for the journal and the exit code."""
bundle: Path
outcome: Outcome
task_id: str | None = None
detail: str = ""
@dataclass(frozen=True)
class TaskDraft:
"""The task to create: title, body and due time."""
title: str
body: str
due: str
def _demote(entry: str) -> str:
"""Turn ``## `` headings into ``### ``: no section may start a todo part.
The todo app splits a note into runnable parts at every ``## `` heading,
so the entry's own headings must not look like sections.
"""
return re.sub(r"(?m)^##(?=\s)", "###", entry)
def _first_line(note: str, fallback: str) -> str:
first = next((x.strip() for x in note.splitlines() if x.strip()), "")
if len(first) > TITLE_CAP:
first = first[: TITLE_CAP - 1] + "…"
return first or fallback
def build_task(repo: str, stamp: str, entry: str, report: Report | None) -> TaskDraft:
"""Compose the title and body from the entry and (maybe corrupt) report."""
found_game, found_combo = game_and_combo(entry)
note = note_from_entry(entry) if report is None else report.note
game = (report.game if report else "") or found_game
combo = (report.combo if report else "") or found_combo
clock = parse_stamp(stamp).strftime("%H:%M")
fallback = " ".join(x for x in (game, combo, clock) if x)
suffix = CORRUPT_TAG if report is None else ""
title = f"{MANUAL_PREFIX}{repo} feedback: {_first_line(note, fallback)}{suffix}"
due = due_at(stamp)
lines = [f"Due: {due:%Y-%m-%d %H:%M}", ""]
if report is None:
lines += [CORRUPT_LINE, ""]
return TaskDraft(title, "\n".join([*lines, _demote(entry)]), due.isoformat())
def _write_marker(bundle: Path, payload: dict[str, object]) -> None:
tmp = bundle / f"{MARKER}.tmp"
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
tmp.replace(bundle / MARKER)
def _read_marker(bundle: Path) -> dict[str, object] | None:
"""The bundle's marker, or None when it is not a usable marker."""
try:
data = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
except OSError, UnicodeDecodeError, json.JSONDecodeError:
return None
ok = isinstance(data, dict) and isinstance(data.get("task_id"), str)
return data if ok and isinstance(data.get("title"), str) else None
@dataclass(frozen=True)
class Importer:
"""Who files the tasks (``client``) under which GitHub owner."""
client: TodoClient
owner: str
settle: float = SETTLE_SECONDS
def import_bundle(self, feedback_dir: Path, bundle: Path, now: float) -> Result:
"""Import one bundle if complete; repair the title of one already filed."""
if (bundle / MARKER).exists():
return self._repair(feedback_dir, bundle)
report_path = bundle / "report.json"
entry = find_entry(feedback_dir, bundle)
if entry is None or not report_path.is_file():
return Result(bundle, Outcome.PENDING, detail="report or entry missing")
if now - report_path.stat().st_mtime < self.settle:
return Result(bundle, Outcome.PENDING, detail="report.json settling")
repo = repo_name(feedback_dir)
draft = build_task(repo, bundle.name, entry, read_report(report_path))
return self._file(bundle, f"{self.owner}/{repo}", draft)
def _file(self, bundle: Path, repo: str, draft: TaskDraft) -> Result:
"""Create the task, write the marker, then fix the title."""
try:
ref = self.client.add_task(repo, draft.title, draft.body)
except TodoError as exc:
return Result(bundle, Outcome.FAILED, detail=str(exc))
# Marker first: once the task exists a retry must never add a second.
# A merged task belongs to another note, which is not ours to rewrite.
payload: dict[str, object] = {
"task_id": ref.task_id,
"title": draft.title,
"due": draft.due,
"merged": ref.merged,
}
if ref.merged:
payload["format"] = FORMAT
try:
_write_marker(bundle, payload)
except OSError as exc:
return Result(
bundle,
Outcome.FAILED,
ref.task_id,
f"task {ref.task_id} filed but marker not written: {exc}",
)
if ref.merged:
return Result(
bundle, Outcome.IMPORTED, ref.task_id, "merged into an existing note"
)
return self._finish(bundle, payload, draft)
def _repair(self, feedback_dir: Path, bundle: Path) -> Result:
"""Finish a filed task whose rewrite never completed (or predates it)."""
data = _read_marker(bundle)
if data is None or data.get("merged") or data.get("format") == FORMAT:
return Result(bundle, Outcome.SKIPPED)
entry = find_entry(feedback_dir, bundle)
if entry is None:
return Result(bundle, Outcome.PENDING, detail="entry missing for repair")
report = read_report(bundle / "report.json")
draft = build_task(repo_name(feedback_dir), bundle.name, entry, report)
return self._finish(bundle, data, draft)
def _finish(
self, bundle: Path, data: dict[str, object], draft: TaskDraft
) -> Result:
"""Rewrite the stored note to the exact title/body, then mark it done."""
task_id = str(data["task_id"])
try:
self.client.update_task(task_id, draft.title, draft.body)
except TodoError as exc:
return Result(bundle, Outcome.FAILED, task_id, f"update: {exc}")
marked = {**data, "title": draft.title, "due": draft.due, "format": FORMAT}
try:
_write_marker(bundle, marked)
except OSError as exc:
return Result(bundle, Outcome.FAILED, task_id, f"marker not written: {exc}")
return Result(bundle, Outcome.IMPORTED, task_id)
@dataclass(frozen=True)
class Waiter:
"""How long to wait for half-written bundles, and the clock to do it by."""
wait: float
clock: Callable[[], float]
sleep: Callable[[float], None]
def run_once(data_root: Path, importer: Importer, now: float) -> list[Result]:
"""Import every complete, not yet imported bundle of every game."""
return [
importer.import_bundle(folder, bundle, now)
for folder in feedback_dirs(data_root)
for bundle in bundles_in(folder)
]
def run_settled(data_root: Path, importer: Importer, waiter: Waiter) -> list[Result]:
"""Like :func:`run_once`, retrying up to ``wait`` s while bundles are pending.
A bundle is written in pieces; the watch fires on the first piece, so a
short wait lets the rest land instead of leaving it to the timer.
"""
deadline = waiter.clock() + waiter.wait
while True:
results = run_once(data_root, importer, waiter.clock())
pending = any(r.outcome is Outcome.PENDING for r in results)
if not pending or waiter.clock() >= deadline:
return results
waiter.sleep(1.0)

View File

@ -1,51 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Every on-disk and network location, resolved in one place.
Tests redirect :func:`resolve_paths` once; nothing else names a real path.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
MCP_URL = "http://127.0.0.1:8735/mcp"
# Port 9 (discard) refuses connections: a test that forgets to inject a client
# fails closed instead of reaching the live todo server.
DEAD_URL = "http://127.0.0.1:9/mcp"
@dataclass(frozen=True)
class Paths:
"""Where the inbox reads games, its token, its lock and its units."""
data_root: Path
token_file: Path
lock_file: Path
unit_dir: Path
mcp_url: str
@classmethod
def under(cls, root: Path) -> Paths:
"""A throwaway layout below ``root`` that can never reach the live app."""
return cls(
data_root=root / "data",
token_file=root / "token",
lock_file=root / "state" / "lock",
unit_dir=root / "units",
mcp_url=DEAD_URL,
)
def resolve_paths(given: Paths | None = None) -> Paths:
"""Return ``given`` or the real layout under the user's home."""
if given is not None:
return given
home = Path.home()
return Paths(
data_root=home / "data",
token_file=home / ".config" / "feedback-inbox" / "token",
lock_file=home / ".local" / "share" / "feedback-inbox" / "lock",
unit_dir=home / ".config" / "systemd" / "user",
mcp_url=MCP_URL,
)

View File

@ -1,165 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The todo app's MCP server as a minimal ``add_task`` client."""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import TYPE_CHECKING, Protocol
import urllib.error
import urllib.request
if TYPE_CHECKING:
from pathlib import Path
_PROTOCOL = "2025-03-26"
_TIMEOUT = 20
_REPLY = re.compile(r"^(added|merged into|appended to) (\S+)")
class TodoError(Exception):
"""The todo server could not take the task."""
@dataclass(frozen=True)
class TaskRef:
"""The task a call created (or the note it was merged into)."""
task_id: str
merged: bool
class TodoClient(Protocol):
"""What the inbox needs from a todo app."""
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
"""Create one task in its own note and return its id."""
raise NotImplementedError
def update_task(self, task_id: str, title: str, body: str) -> None:
"""Replace the task's stored title and body with exactly these."""
raise NotImplementedError
class McpTodoClient:
"""Streamable-HTTP MCP client: initialize, then one ``add_task`` call."""
def __init__(self, url: str, token_file: Path) -> None:
"""Remember where the server and the (lazily read) token are."""
self._url = url
self._token_file = token_file
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
"""Create a task with its own note via ``add_task``.
The server stores a ``new_note`` title as ``<name> todos: <title>``;
:meth:`update_task` restores the exact title (and drops the
``## <title>`` section the server adds) afterwards.
"""
arguments = {"repo": repo, "title": title, "body": body, "new_note": True}
said = self._call("add_task", arguments)
found = _REPLY.match(said)
if not found:
msg = f"add_task refused: {said[:120]}"
raise TodoError(msg)
return TaskRef(task_id=found.group(2), merged=found.group(1) != "added")
def update_task(self, task_id: str, title: str, body: str) -> None:
"""Rewrite via ``update_task``: exact title, and body with no sections."""
said = self._call("update_task", {"id": task_id, "title": title, "body": body})
if not said.startswith("updated"):
msg = f"update_task refused: {said[:120]}"
raise TodoError(msg)
def _call(self, tool: str, arguments: dict[str, object]) -> str:
token = self._token()
session = self._initialize(token)
payload = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
}
return _tool_text(self._post(token, payload, session)[1])
def _token(self) -> str:
try:
return self._token_file.read_text(encoding="utf-8").strip()
except OSError as exc:
msg = f"token file unreadable: {self._token_file}"
raise TodoError(msg) from exc
def _initialize(self, token: str) -> str:
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": _PROTOCOL,
"capabilities": {},
"clientInfo": {"name": "feedback-inbox", "version": "0.1.0"},
},
}
headers, _body = self._post(token, init, None)
session = headers.get("mcp-session-id")
if not session:
msg = "todo server sent no session id"
raise TodoError(msg)
self._post(
token,
{"jsonrpc": "2.0", "method": "notifications/initialized"},
session,
)
return session
def _post(
self,
token: str,
payload: dict[str, object],
session: str | None,
) -> tuple[dict[str, str], str]:
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if session:
headers["mcp-session-id"] = session
request = urllib.request.Request(
self._url,
data=json.dumps(payload).encode(),
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp:
return (
{k.lower(): v for k, v in resp.headers.items()},
resp.read().decode(),
)
except urllib.error.HTTPError as exc:
exc.close()
msg = f"todo server answered HTTP {exc.code}"
raise TodoError(msg) from exc
except OSError as exc:
msg = f"todo server unreachable: {exc}"
raise TodoError(msg) from exc
def _tool_text(body: str) -> str:
"""The text a tool call answered; an MCP-level error becomes TodoError."""
text = body.strip()
if not text.startswith("{"):
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
text = next((x for x in lines if x.startswith("{")), "")
try:
result = json.loads(text)["result"]
said = str(result["content"][0]["text"])
failed = bool(result.get("isError"))
except (ValueError, KeyError, IndexError, TypeError) as exc:
msg = f"unparsable MCP reply: {body[:80]!r}"
raise TodoError(msg) from exc
if failed:
msg = f"tool refused: {said[:120]}"
raise TodoError(msg)
return said

View File

@ -1,67 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Keep the path units' watch lists equal to the games that exist.
systemd ``.path`` units take no globs, so each ``*_binaries`` folder is listed
in a generated drop-in. The set changes only when a game is added or removed;
the drop-in is rewritten, and systemd reloaded, only then (a reload per tick
would be the daemon-reload loop the fleet rules forbid).
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from feedback_inbox._bundle import binaries_dirs, feedback_dirs
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from pathlib import Path
WATCH_PATH = "feedback-inbox.path"
SYNC_PATH = "feedback-inbox-sync.path"
SYSTEMCTL = "/usr/bin/systemctl"
_HEADER = "# Generated by `feedback-inbox sync`; do not edit.\n[Path]\n"
def _dropin(unit_dir: Path, unit: str) -> Path:
return unit_dir / f"{unit}.d" / "dirs.conf"
def _render(folders: list[Path]) -> str:
return _HEADER + "".join(f"PathChanged={d}\n" for d in folders)
def desired(data_root: Path, unit_dir: Path) -> dict[Path, str]:
"""The drop-ins that should exist: feedback folders, and game folders."""
return {
_dropin(unit_dir, WATCH_PATH): _render(feedback_dirs(data_root)),
_dropin(unit_dir, SYNC_PATH): _render(binaries_dirs(data_root)),
}
def run_systemctl(args: Sequence[str]) -> int:
"""Run ``systemctl --user <args>`` and return its exit code."""
return subprocess.run([SYSTEMCTL, "--user", *args], check=False).returncode
def sync_units(
data_root: Path,
unit_dir: Path,
systemctl: Callable[[Sequence[str]], int],
) -> bool:
"""Rewrite drifted drop-ins, then reload and re-arm once; True if changed."""
changed = False
for path, text in desired(data_root, unit_dir).items():
if path.is_file() and path.read_text(encoding="utf-8") == text:
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
changed = True
if not changed:
return False
for args in (["daemon-reload"], ["restart", WATCH_PATH, SYNC_PATH]):
if systemctl(args):
msg = f"systemctl --user {' '.join(args)} failed"
raise RuntimeError(msg)
return True

View File

@ -1,2 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for feedback_inbox."""

View File

@ -1,31 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Redirect the one path resolver, so no test can touch real user state.
Every writer resolves its paths through ``feedback_inbox._paths.resolve_paths``;
redirecting that one function moves all of them (kuhy's real ``~/data``,
token file, lock and systemd unit dir included).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from feedback_inbox._paths import Paths
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Paths:
"""Point every default path at ``tmp_path`` before any test runs."""
redirected = Paths.under(tmp_path / "home")
def _resolve(given: Paths | None = None) -> Paths:
return given if given is not None else redirected
monkeypatch.setattr("feedback_inbox._cli.resolve_paths", _resolve)
redirected.data_root.mkdir(parents=True)
return redirected

View File

@ -1,42 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Discovery, stamps and due dates."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from feedback_inbox._bundle import bundles_in, due_at, feedback_dirs, repo_name
from feedback_inbox.tests.test_support import make_bundle
if TYPE_CHECKING:
from feedback_inbox._paths import Paths
def test_feedback_dirs_covers_every_game(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate")
make_bundle(paths.data_root, game="dread-grid")
(paths.data_root / "other" / "feedback").mkdir(parents=True)
(paths.data_root / "empty_binaries").mkdir()
(paths.data_root / "file_binaries").write_text("x", encoding="utf-8")
found = feedback_dirs(paths.data_root)
assert [repo_name(d) for d in found] == ["cleanup-plate", "dread-grid"]
def test_feedback_dirs_without_data_root(paths: Paths) -> None:
assert feedback_dirs(paths.data_root / "missing") == []
def test_bundles_in_keeps_only_stamp_folders(paths: Paths) -> None:
first = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124")
make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162")
feedback = first.parent
(feedback / "notes").mkdir()
(feedback / "2026-11-11T13-52-33-999").write_text("x", encoding="utf-8")
names = [b.name for b in bundles_in(feedback)]
assert names == ["2026-11-11T13-52-33-162", "2026-11-11T13-53-19-124"]
def test_due_is_next_day_ten(paths: Paths) -> None:
assert due_at("2026-11-11T13-52-33-162") == datetime(2026, 11, 12, 10, 0)
assert due_at("2026-12-31T23-59-59-001") == datetime(2027, 1, 1, 10, 0)

View File

@ -1,103 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The command line: run, sync, exit codes, lock."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
from feedback_inbox import __main__ as entry
from feedback_inbox._cli import main
from feedback_inbox._inbox import MARKER
from feedback_inbox._paths import Paths, resolve_paths
from feedback_inbox.tests.test_support import FakeTodo, make_bundle
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
def test_run_imports_and_exits_zero(
paths: Paths, caplog: pytest.LogCaptureFixture
) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo()
with caplog.at_level(logging.INFO):
assert main(["run", "--wait", "0"], client=todo) == 0
assert (bundle / MARKER).exists()
assert len(todo.calls) == 1
assert "imported" in caplog.text
assert main(["run", "--wait", "0"], client=todo) == 0
assert len(todo.calls) == 1
def test_run_fails_loudly_when_todo_is_down(
paths: Paths, caplog: pytest.LogCaptureFixture
) -> None:
bundle = make_bundle(paths.data_root)
assert main(["run", "--wait", "0"], client=FakeTodo(fail=True)) == 1
assert not (bundle / MARKER).exists()
assert "todo down" in caplog.text
def test_run_pending_is_not_an_error(paths: Paths) -> None:
make_bundle(paths.data_root, entry=False)
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
def test_run_builds_the_real_client_from_paths(paths: Paths) -> None:
make_bundle(paths.data_root)
assert main(["run", "--wait", "0"]) == 1
def test_run_with_nothing_to_do_needs_no_token(paths: Paths) -> None:
assert main(["run", "--wait", "0"]) == 0
def test_run_takes_the_lock_file(paths: Paths) -> None:
make_bundle(paths.data_root)
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
assert paths.lock_file.exists()
def test_sync_command(paths: Paths) -> None:
make_bundle(paths.data_root)
calls: list[list[str]] = []
def fake(args: Sequence[str]) -> int:
calls.append(list(args))
return 0
assert main(["sync"], systemctl=fake) == 0
assert calls[0] == ["daemon-reload"]
def test_sync_failure_exit_code(paths: Paths, caplog: pytest.LogCaptureFixture) -> None:
make_bundle(paths.data_root)
assert main(["sync"], systemctl=lambda _a: 1) == 1
assert "daemon-reload" in caplog.text
def test_usage_error() -> None:
with pytest.raises(SystemExit):
main([])
def test_real_paths_are_under_home(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr("feedback_inbox._paths.Path.home", lambda: tmp_path)
real = resolve_paths()
assert real.data_root == tmp_path / "data"
assert real.token_file == tmp_path / ".config" / "feedback-inbox" / "token"
assert real.unit_dir == tmp_path / ".config" / "systemd" / "user"
assert real.mcp_url == "http://127.0.0.1:8735/mcp"
given = Paths.under(tmp_path)
assert resolve_paths(given) is given
def test_module_entry_point(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.argv", ["feedback-inbox", "run", "--wait", "0"])
assert entry.run() == 0

View File

@ -1,68 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Finding a bundle's markdown entry and reading its report."""
from __future__ import annotations
from typing import TYPE_CHECKING
from feedback_inbox._entry import (
find_entry,
game_and_combo,
note_from_entry,
read_report,
)
from feedback_inbox.tests.test_support import make_bundle
if TYPE_CHECKING:
from pathlib import Path
from feedback_inbox._paths import Paths
def test_find_entry_picks_the_matching_section(paths: Paths) -> None:
first = make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162", note="alpha")
second = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124", note="beta")
entry = find_entry(first.parent, second)
assert entry is not None
assert "beta" in entry
assert "alpha" not in entry
assert find_entry(first.parent, first) is not None
def test_find_entry_missing(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, entry=False)
assert find_entry(bundle.parent, bundle) is None
(bundle.parent / "feedback-2026-11-11.md").write_bytes(b"\xff\xfe")
assert find_entry(bundle.parent, bundle) is None
def test_read_report(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, note=" hello \nworld")
report = read_report(bundle / "report.json")
assert report is not None
assert report.note == " hello \nworld"
assert (report.game, report.combo) == ("museum", "loop=P2")
def test_read_report_corrupt_forms(tmp_path: Path) -> None:
path = tmp_path / "report.json"
for bad in ("{broken", "[1]", '{"note": 3, "game": null}'):
path.write_text(bad, encoding="utf-8")
report = read_report(path)
if bad == '{"note": 3, "game": null}':
assert report is not None
assert (report.note, report.game, report.combo) == ("", "", "")
else:
assert report is None
path.write_bytes(b"\xff\xfe")
assert read_report(path) is None
def test_entry_fallbacks(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, note="line one\nline two")
entry = find_entry(bundle.parent, bundle)
assert entry is not None
assert note_from_entry(entry) == "line one\nline two"
assert game_and_combo(entry) == ("museum", "loop=P2,carry=d,id=c")
assert note_from_entry("## head only") == ""
assert game_and_combo("nothing here") == ("", "")

View File

@ -1,194 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Import rules: complete, incomplete, idempotent, titles, due date, failures."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from feedback_inbox._entry import Report
from feedback_inbox._inbox import (
MARKER,
Importer,
Outcome,
Waiter,
build_task,
run_once,
run_settled,
)
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
if TYPE_CHECKING:
from pathlib import Path
from feedback_inbox._paths import Paths
NOW = 1_000_000.0
OWNER = "kuhyx"
MANUAL = "MANUAL WORK REQUIRED: "
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
def test_complete_bundle_becomes_one_task(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
((repo, title, body),) = todo.calls
assert repo == "kuhyx/cleanup-plate"
assert (
title
== MANUAL + "cleanup-plate feedback: I would like to see the painting IN FULL"
)
assert body.startswith("Due: 2026-11-12 10:00\n\n### 2026-11-11T13:52:33")
assert f"{bundle}/report.json" in body
marker = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
assert marker["task_id"] == "id0001" + "0" * 28
assert marker["due"] == "2026-11-12T10:00:00"
def test_rerun_never_duplicates(paths: Paths) -> None:
make_bundle(paths.data_root)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
assert _run(paths, todo) == [Outcome.SKIPPED]
assert len(todo.calls) == 1
def test_incomplete_bundles_wait(paths: Paths) -> None:
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", report=None)
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", entry=False)
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", age=0.5)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.PENDING] * 3
assert todo.calls == []
later = run_once(paths.data_root, Importer(todo, OWNER), NOW + 10)
assert [r.outcome for r in later] == [
Outcome.PENDING,
Outcome.PENDING,
Outcome.IMPORTED,
]
def test_multiple_games(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate", note="a")
make_bundle(paths.data_root, game="dread-grid", note="b")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED] * 2
assert [(c[0], c[1]) for c in todo.calls] == [
("kuhyx/cleanup-plate", "MANUAL WORK REQUIRED: cleanup-plate feedback: a"),
("kuhyx/dread-grid", "MANUAL WORK REQUIRED: dread-grid feedback: b"),
]
def test_corrupt_report_still_a_task(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, report="{broken", note="keep me")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
((_, title, body),) = todo.calls
assert (
title
== "MANUAL WORK REQUIRED: cleanup-plate feedback: keep me [corrupt report.json]"
)
assert "report.json is corrupt" in body
assert (bundle / MARKER).exists()
def test_corrupt_report_without_note(paths: Paths) -> None:
make_bundle(paths.data_root, report="[]", note="")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
title = todo.calls[0][1]
game_part = "museum loop=P2,carry=d,id=c 13:52"
assert title == f"{MANUAL}cleanup-plate feedback: {game_part} [corrupt report.json]"
def test_no_note_title_names_game_combo_time(paths: Paths) -> None:
make_bundle(paths.data_root, note="")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
assert (
todo.calls[0][1]
== "MANUAL WORK REQUIRED: cleanup-plate feedback: museum loop=P2 13:52"
)
def test_title_uses_first_line_and_caps_length() -> None:
report = Report(note="\n " + "x" * 300 + "\nsecond", game="g", combo="c")
draft = build_task("repo", STAMP, "## e", report)
assert draft.title == "MANUAL WORK REQUIRED: repo feedback: " + "x" * 119 + "…"
bare = build_task("repo", STAMP, "## e", Report(note="", game="", combo=""))
assert bare.title == "MANUAL WORK REQUIRED: repo feedback: 13:52"
def test_todo_failure_leaves_no_marker(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo(fail=True)
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
assert [r.outcome for r in results] == [Outcome.FAILED]
assert "todo down" in results[0].detail
assert not (bundle / MARKER).exists()
assert _run(paths, FakeTodo()) == [Outcome.IMPORTED]
def test_merge_is_flagged_but_marked(paths: Paths) -> None:
make_bundle(paths.data_root)
results = run_once(paths.data_root, Importer(FakeTodo(merged=True), OWNER), NOW)
assert results[0].outcome is Outcome.IMPORTED
assert "merged" in results[0].detail
def test_marker_write_failure_is_reported(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
(bundle / MARKER).mkdir()
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
assert results[0].outcome is Outcome.SKIPPED
(bundle / MARKER).rmdir()
bundle.chmod(0o500)
try:
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
finally:
bundle.chmod(0o700)
assert results[0].outcome is Outcome.FAILED
assert "marker" in results[0].detail
def test_run_settled_waits_then_gives_up(paths: Paths, tmp_path: Path) -> None:
make_bundle(paths.data_root, age=0.0, now=NOW)
clock = [NOW]
slept: list[float] = []
def sleep(seconds: float) -> None:
slept.append(seconds)
clock[0] += seconds
todo = FakeTodo()
results = run_settled(
paths.data_root, Importer(todo, OWNER), Waiter(5.0, lambda: clock[0], sleep)
)
assert [r.outcome for r in results] == [Outcome.IMPORTED]
assert slept == [1.0, 1.0]
other = tmp_path / "other"
other.mkdir()
make_bundle(other, report=None)
slept.clear()
results = run_settled(
other, Importer(todo, OWNER), Waiter(3.0, lambda: clock[0], sleep)
)
assert [r.outcome for r in results] == [Outcome.PENDING]
assert slept == [1.0, 1.0, 1.0]
def test_every_title_starts_with_the_manual_prefix(paths: Paths) -> None:
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", note="a")
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", note="")
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", report="{")
make_bundle(paths.data_root, game="dread-grid", note="b")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED] * 4
titles = [call[1] for call in todo.calls]
assert len(titles) == 4
assert all(t.startswith("MANUAL WORK REQUIRED: ") for t in titles)

View File

@ -1,145 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Exact titles, section-free bodies, and repair of half-finished tasks."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from feedback_inbox._entry import Report
from feedback_inbox._inbox import (
FORMAT,
MARKER,
Importer,
Outcome,
build_task,
run_once,
)
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
if TYPE_CHECKING:
from pathlib import Path
from feedback_inbox._paths import Paths
NOW = 1_000_000.0
OWNER = "kuhyx"
MANUAL = "MANUAL WORK REQUIRED: "
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
def _marker(bundle: Path) -> dict[str, object]:
data: dict[str, object] = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
return data
def _no_extra_sections(text: str) -> bool:
return not any(line.startswith("## ") for line in text.splitlines())
def test_stored_note_is_one_manual_part(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
(stored,) = todo.stored.values()
assert stored.startswith(MANUAL)
assert _no_extra_sections(stored)
assert "### 2026-11-11T13:52:33" in stored
assert _marker(bundle)["format"] == FORMAT
def test_entry_headings_never_become_sections() -> None:
entry = "## a heading\ntext\n## another\n### kept"
draft = build_task("r", STAMP, entry, Report(note="n", game="", combo=""))
assert _no_extra_sections(draft.body)
assert "### a heading\ntext\n### another\n### kept" in draft.body
def test_failed_update_keeps_marker_and_retries_without_a_second_task(
paths: Paths,
) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo(fail_update=True)
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
assert [r.outcome for r in results] == [Outcome.FAILED]
assert "update refused" in results[0].detail
assert "format" not in _marker(bundle)
todo.fail_update = False
assert _run(paths, todo) == [Outcome.IMPORTED]
assert len(todo.calls) == 1
(stored,) = todo.stored.values()
assert stored.startswith(MANUAL)
assert _no_extra_sections(stored)
assert _run(paths, todo) == [Outcome.SKIPPED]
def test_old_marker_is_repaired_from_the_bundle(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
task_id = "abc" + "0" * 33
(bundle / MARKER).write_text(
json.dumps({"task_id": task_id, "title": "old", "title_fixed": True}),
encoding="utf-8",
)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.IMPORTED]
assert todo.calls == []
assert todo.stored[task_id].startswith(MANUAL)
assert _no_extra_sections(todo.stored[task_id])
assert _marker(bundle)["format"] == FORMAT
def test_old_marker_whose_entry_vanished_waits(paths: Paths) -> None:
bundle = make_bundle(paths.data_root, entry=False)
(bundle / MARKER).write_text(
json.dumps({"task_id": "t" * 36, "title": "old"}), encoding="utf-8"
)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.PENDING]
assert todo.updated == []
def test_merged_task_is_not_rewritten(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
todo = FakeTodo(merged=True)
assert _run(paths, todo) == [Outcome.IMPORTED]
assert todo.updated == []
assert _marker(bundle)["format"] == FORMAT
assert _run(paths, todo) == [Outcome.SKIPPED]
def test_old_merged_marker_is_left_alone(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
(bundle / MARKER).write_text(
json.dumps({"task_id": "t" * 36, "title": "x", "merged": True}),
encoding="utf-8",
)
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.SKIPPED]
assert todo.updated == []
def test_unreadable_marker_is_left_alone(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
for junk in ("{broken", "[1]", '{"task_id": 5}'):
(bundle / MARKER).write_text(junk, encoding="utf-8")
todo = FakeTodo()
assert _run(paths, todo) == [Outcome.SKIPPED]
assert todo.calls == []
assert todo.updated == []
def test_repair_reports_an_unwritable_marker(paths: Paths) -> None:
bundle = make_bundle(paths.data_root)
(bundle / MARKER).write_text(
json.dumps({"task_id": "t" * 36, "title": "T"}), encoding="utf-8"
)
bundle.chmod(0o500)
try:
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
finally:
bundle.chmod(0o700)
assert results[0].outcome is Outcome.FAILED
assert "marker not written" in results[0].detail

View File

@ -1,95 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Builders for fake feedback bundles and a fake todo client."""
from __future__ import annotations
from dataclasses import dataclass, field
import json
import os
from typing import TYPE_CHECKING
from feedback_inbox._todo import TaskRef, TodoError
if TYPE_CHECKING:
from pathlib import Path
STAMP = "2026-11-11T13-52-33-162"
NOTE = "I would like to see the painting IN FULL\n\nsecond paragraph"
def entry_text(bundle: Path, note: str = NOTE, game: str = "museum") -> str:
"""The markdown entry a game appends for ``bundle``."""
head = note.splitlines()[0][:70] if note else ""
parts = [f"## 2026-11-11T13:52:33: {head}", ""]
if note:
parts += [note, ""]
parts += [
f"- Screenshot: `{bundle}/annotated.png`",
f"- Report: `{bundle}/report.json`",
f"- Game `{game}`, combo `loop=P2,carry=d,id=c`, build abc, session `s`",
"",
"Task: Read the annotated screenshot and fix it.",
"",
]
return "\n".join(parts)
def make_bundle(
data_root: Path,
*,
game: str = "cleanup-plate",
stamp: str = STAMP,
note: str = NOTE,
report: str | None = "ok",
entry: bool = True,
age: float = 60.0,
now: float = 1_000_000.0,
) -> Path:
"""Create ``<game>_binaries/feedback/<stamp>/`` and its md entry."""
feedback = data_root / f"{game}_binaries" / "feedback"
bundle = feedback / stamp
bundle.mkdir(parents=True, exist_ok=True)
if report is not None:
path = bundle / "report.json"
if report == "ok":
body = json.dumps({"note": note, "game": "museum", "combo": "loop=P2"})
else:
body = report
path.write_text(body, encoding="utf-8")
os.utime(path, (now - age, now - age))
if entry:
md = feedback / f"feedback-{stamp[:10]}.md"
with md.open("a", encoding="utf-8") as handle:
handle.write(entry_text(bundle, note))
return bundle
@dataclass
class FakeTodo:
"""A todo server stand-in that stores notes the way the real one does."""
calls: list[tuple[str, str, str]] = field(default_factory=list)
stored: dict[str, str] = field(default_factory=dict)
fail: bool = False
fail_update: bool = False
merged: bool = False
updated: list[str] = field(default_factory=list)
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
if self.fail:
msg = "todo down"
raise TodoError(msg)
self.calls.append((repo, title, body))
task_id = f"id{len(self.calls):04d}" + "0" * 28
# A new_note task: title "<name> todos: <title>", then a "## <title>"
# section holding the body.
name = repo.rsplit("/", maxsplit=1)[-1]
self.stored[task_id] = f"{name} todos: {title}\n\n## {title}\n{body}"
return TaskRef(task_id=task_id, merged=self.merged)
def update_task(self, task_id: str, title: str, body: str) -> None:
if self.fail_update:
msg = "update refused"
raise TodoError(msg)
self.updated.append(task_id)
self.stored[task_id] = f"{title}\n\n{body}"

View File

@ -1,200 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The MCP client against a real local HTTP server (never the live todo app)."""
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import threading
from typing import TYPE_CHECKING, ClassVar
import pytest
from feedback_inbox._todo import McpTodoClient, TodoError
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
FULL_ID = "a1b2c3d4" + "e" * 28
class _Handler(BaseHTTPRequestHandler):
seen: ClassVar[list[dict[str, object]]] = []
headers_seen: ClassVar[list[dict[str, str]]] = []
tool_text: ClassVar[str] = f"added {FULL_ID}\nline"
is_error: ClassVar[bool] = False
status: ClassVar[int] = 200
sse: ClassVar[bool] = True
def do_POST(self) -> None:
length = int(self.headers["Content-Length"])
body = json.loads(self.rfile.read(length))
type(self).seen.append(body)
type(self).headers_seen.append({k.lower(): v for k, v in self.headers.items()})
if type(self).status != 200:
self.send_response(type(self).status)
self.end_headers()
return
self.send_response(200)
self.send_header("mcp-session-id", "sess-1")
self.end_headers()
method = body.get("method")
if method == "initialize":
self.wfile.write(
b'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n'
)
elif method == "tools/call":
result: dict[str, object] = {
"content": [{"type": "text", "text": type(self).tool_text}],
}
if type(self).is_error:
result["isError"] = True
reply = json.dumps({"jsonrpc": "2.0", "id": 2, "result": result})
out = f"event: message\ndata: {reply}\n\n" if type(self).sse else reply
self.wfile.write(out.encode())
def log_message(self, *_args: object) -> None:
return
@pytest.fixture
def server() -> Iterator[str]:
_Handler.seen = []
_Handler.headers_seen = []
_Handler.tool_text = f"added {FULL_ID}\nline"
_Handler.is_error = False
_Handler.status = 200
_Handler.sse = True
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{httpd.server_port}/mcp"
httpd.shutdown()
httpd.server_close()
thread.join()
def _client(url: str, tmp_path: Path) -> McpTodoClient:
token = tmp_path / "token"
token.write_text("secret-token\n", encoding="utf-8")
return McpTodoClient(url, token)
def test_add_task_round_trip(server: str, tmp_path: Path) -> None:
ref = _client(server, tmp_path).add_task("kuhyx/cleanup-plate", "t", "b")
assert (ref.task_id, ref.merged) == (FULL_ID, False)
methods = [m.get("method") for m in _Handler.seen]
assert methods == ["initialize", "notifications/initialized", "tools/call"]
call = _Handler.seen[2]["params"]
assert call == {
"name": "add_task",
"arguments": {
"repo": "kuhyx/cleanup-plate",
"title": "t",
"body": "b",
"new_note": True,
},
}
assert _Handler.headers_seen[0]["authorization"] == "Bearer secret-token"
assert _Handler.headers_seen[2]["mcp-session-id"] == "sess-1"
def test_plain_json_reply_and_merge(server: str, tmp_path: Path) -> None:
_Handler.sse = False
_Handler.tool_text = f"merged into {FULL_ID}\nstart: skipped"
ref = _client(server, tmp_path).add_task("r/x", "t", "b")
assert (ref.task_id, ref.merged) == (FULL_ID, True)
def test_tool_error_is_reported(server: str, tmp_path: Path) -> None:
_Handler.is_error = True
_Handler.tool_text = "boom"
with pytest.raises(TodoError, match="boom"):
_client(server, tmp_path).add_task("r/x", "t", "b")
def test_unexpected_reply_text(server: str, tmp_path: Path) -> None:
_Handler.tool_text = "who knows"
with pytest.raises(TodoError, match="who knows"):
_client(server, tmp_path).add_task("r/x", "t", "b")
def test_http_error(server: str, tmp_path: Path) -> None:
_Handler.status = 403
with pytest.raises(TodoError, match="403"):
_client(server, tmp_path).add_task("r/x", "t", "b")
def test_unreachable_server(tmp_path: Path) -> None:
with pytest.raises(TodoError, match="unreachable"):
_client("http://127.0.0.1:9/mcp", tmp_path).add_task("r/x", "t", "b")
def test_missing_token(tmp_path: Path) -> None:
client = McpTodoClient("http://127.0.0.1:9/mcp", tmp_path / "none")
with pytest.raises(TodoError, match="token"):
client.add_task("r/x", "t", "b")
def test_missing_session_header(server: str, tmp_path: Path) -> None:
class _NoSession(_Handler):
def do_POST(self) -> None:
self.rfile.read(int(self.headers["Content-Length"]))
self.send_response(200)
self.end_headers()
self.wfile.write(b"{}")
httpd = HTTPServer(("127.0.0.1", 0), _NoSession)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
with pytest.raises(TodoError, match="session"):
client.add_task("r/x", "t", "b")
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
def test_garbage_json(server: str, tmp_path: Path) -> None:
_Handler.sse = False
_Handler.tool_text = "x"
class _Bad(_Handler):
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
self.send_response(200)
self.send_header("mcp-session-id", "s")
self.end_headers()
if body.get("method") == "tools/call":
self.wfile.write(b"not json")
httpd = HTTPServer(("127.0.0.1", 0), _Bad)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
with pytest.raises(TodoError, match="unparsable"):
client.add_task("r/x", "t", "b")
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
def test_update_task_replaces_title_and_body(server: str, tmp_path: Path) -> None:
_Handler.tool_text = f"updated {FULL_ID}\nline"
_client(server, tmp_path).update_task(FULL_ID, "MANUAL WORK REQUIRED: x", "b")
call = _Handler.seen[2]["params"]
assert call == {
"name": "update_task",
"arguments": {"id": FULL_ID, "title": "MANUAL WORK REQUIRED: x", "body": "b"},
}
def test_update_task_refused(server: str, tmp_path: Path) -> None:
_Handler.tool_text = "no such task"
with pytest.raises(TodoError, match="no such task"):
_client(server, tmp_path).update_task(FULL_ID, "t", "b")

View File

@ -1,97 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The path-unit drop-ins follow the set of game folders, and only then reload."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from feedback_inbox._units import (
SYNC_PATH,
WATCH_PATH,
desired,
run_systemctl,
sync_units,
)
from feedback_inbox.tests.test_support import make_bundle
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from feedback_inbox._paths import Paths
class _Systemctl:
def __init__(self, code: int = 0) -> None:
self.calls: list[list[str]] = []
self.code = code
def __call__(self, args: Sequence[str]) -> int:
self.calls.append(list(args))
return self.code
def test_desired_lists_feedback_and_binaries_dirs(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate")
(paths.data_root / "dread-grid_binaries").mkdir()
wanted = desired(paths.data_root, paths.unit_dir)
watch = wanted[paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf"]
sync = wanted[paths.unit_dir / f"{SYNC_PATH}.d" / "dirs.conf"]
feedback = paths.data_root / "cleanup-plate_binaries" / "feedback"
assert f"PathChanged={feedback}\n" in watch
assert "dread-grid" not in watch
assert f"PathChanged={paths.data_root}/cleanup-plate_binaries\n" in sync
assert f"PathChanged={paths.data_root}/dread-grid_binaries\n" in sync
def test_sync_writes_reloads_and_restarts_once(paths: Paths) -> None:
make_bundle(paths.data_root)
ctl = _Systemctl()
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
assert ctl.calls == [
["daemon-reload"],
["restart", WATCH_PATH, SYNC_PATH],
]
again = _Systemctl()
assert sync_units(paths.data_root, paths.unit_dir, again) is False
assert again.calls == []
def test_new_game_triggers_a_rewrite(paths: Paths) -> None:
make_bundle(paths.data_root, game="cleanup-plate")
sync_units(paths.data_root, paths.unit_dir, _Systemctl())
make_bundle(paths.data_root, game="dread-grid")
ctl = _Systemctl()
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
text = (paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf").read_text(
encoding="utf-8"
)
assert "dread-grid_binaries/feedback" in text
def test_systemctl_failure_is_an_error(paths: Paths) -> None:
make_bundle(paths.data_root)
ctl = _Systemctl(code=1)
with pytest.raises(RuntimeError, match="daemon-reload"):
sync_units(paths.data_root, paths.unit_dir, ctl)
assert ctl.calls == [["daemon-reload"]]
def test_run_systemctl_uses_the_user_manager(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen: list[list[str]] = []
class _Done:
returncode = 3
def fake_run(cmd: list[str], *, check: bool) -> _Done:
assert check is False
seen.append(cmd)
return _Done()
monkeypatch.setattr("feedback_inbox._units.subprocess.run", fake_run)
assert run_systemctl(["restart", "x"]) == 3
assert seen == [["/usr/bin/systemctl", "--user", "restart", "x"]]

View File

@ -1,87 +0,0 @@
#!/bin/bash
# ============================================================================
# Install feedback-inbox for the current user: its own venv, the CLI on PATH,
# a todo-server token, and the systemd user units that watch every game's
# ~/data/<game>_binaries/feedback/ folder. Idempotent; rerun after any change.
#
# Enabling the units runs the inbox once (the sweep timer fires at once), so
# every bundle already on disk becomes a task: that is the backfill.
# Run the test suite first (scripts/run_subproject_tests.sh feedback_inbox).
# ============================================================================
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_DIR
readonly STATE_DIR="${HOME}/.local/share/feedback-inbox"
readonly VENV_DIR="${STATE_DIR}/venv"
readonly BIN_DIR="${HOME}/.local/bin"
readonly UNIT_DIR="${HOME}/.config/systemd/user"
readonly CONFIG_DIR="${HOME}/.config/feedback-inbox"
readonly TOKEN_FILE="${CONFIG_DIR}/token"
readonly MINT="${HOME}/src/todo/mcp/mint_token.sh"
readonly SYSTEM_PYTHON="/usr/bin/python3"
readonly UNITS=(
feedback-inbox.service feedback-inbox.path
feedback-inbox-sync.service feedback-inbox-sync.path feedback-inbox-sync.timer
)
log() { printf 'install: %s\n' "$1" >&2; }
fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; }
build_venv() {
log "building the venv at $VENV_DIR"
mkdir -p "$STATE_DIR"
[[ -x "$VENV_DIR/bin/python" ]] || "$SYSTEM_PYTHON" -m venv "$VENV_DIR" \
|| fail "could not create the venv"
"$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \
|| fail "feedback-inbox itself (stdlib only, no other packages)"
mkdir -p "$BIN_DIR"
ln -sf "$VENV_DIR/bin/feedback-inbox" "$BIN_DIR/feedback-inbox"
}
ensure_token() {
# The service has no shell, so TODO_MCP_TOKEN from ~/.zshenv is invisible
# to it. It gets its own revocable token (delete its line in
# ~/.config/todo-mcp/tokens); the value goes straight to a 0600 file.
[[ -s "$TOKEN_FILE" ]] && return 0
[[ -x "$MINT" ]] || fail "$MINT is missing (the todo repo)"
log "minting a todo-server token for feedback-inbox"
mkdir -p "$CONFIG_DIR"
(umask 077 && "$MINT" feedback-inbox >"$TOKEN_FILE") || fail "token mint"
[[ -s "$TOKEN_FILE" ]] || fail "token file is empty"
}
install_units() {
log "installing and enabling the units"
mkdir -p "$UNIT_DIR"
local unit
for unit in "${UNITS[@]}"; do
install -m 644 "$REPO_DIR/systemd/$unit" "$UNIT_DIR/"
done
systemctl --user daemon-reload || fail "daemon-reload"
# Drop-ins first, so the paths are armed with every current game folder.
"$VENV_DIR/bin/feedback-inbox" sync || fail "writing the watch lists"
systemctl --user enable --now feedback-inbox.path feedback-inbox-sync.path \
feedback-inbox-sync.timer || fail "enabling the units"
}
verify() {
local unit
for unit in feedback-inbox.path feedback-inbox-sync.path feedback-inbox-sync.timer; do
systemctl --user is-active --quiet "$unit" || fail "$unit is not active"
done
local folders
folders="$(systemctl --user show feedback-inbox.path -p Paths | grep -c '/feedback (PathChanged)' || true)"
log "ok: watching ${folders} feedback folder(s)"
}
main() {
build_venv
ensure_token
install_units
verify
}
main "$@"

View File

@ -1,175 +0,0 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "feedback-inbox"
version = "0.1.0"
description = "Turns every game dev-feedback bundle into one todo-app task"
requires-python = ">=3.14"
dependencies = []
[project.scripts]
feedback-inbox = "feedback_inbox._cli:main"
[tool.setuptools.packages.find]
include = ["feedback_inbox*"]
[tool.ruff]
target-version = "py314"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203",
"D213",
"COM812",
"ISC001",
"S603",
# Stamps are local wall-clock names the games write; naive on purpose.
"DTZ001",
"DTZ007",
# The todo server is loopback http by design (MCP_URL), never user input.
"S310",
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["feedback_inbox"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = true
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
disallow_any_unimported = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_subclassing_any = true
strict_equality = true
extra_checks = true
ignore_missing_imports = true
show_error_codes = true
color_output = true
exclude = [".venv/"]
[tool.pylint.main]
# The repo root on the path, so pylint resolves the package's own imports the
# same way pytest does instead of reporting import-error for them.
init-hook = "import sys; sys.path.insert(0, 'feedback_inbox')"
analyse-fallback-blocks = true
persistent = true
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
# Test suites intentionally use patterns (protected-access, fixture-arg
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
# basename of what pylint is *given*, and pre-commit hands it explicit file
# paths, so a directory name in `ignore` never fires under the hook.
ignore-paths = ["^(.*/)?tests/.*$"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
enable = "all"
disable = []
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
max-attributes = 10
[tool.pylint.typecheck]
generated-members = [
".*\\.assert_called_once_with",
".*\\.assert_called_once",
".*\\.assert_called",
".*\\.assert_not_called",
".*\\.assert_any_call",
".*\\.call_args",
".*\\.call_args_list",
".*\\.call_count",
]
[tool.bandit]
exclude_dirs = ["tests", ".venv"]
[tool.pytest.ini_options]
testpaths = ["feedback_inbox/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=feedback_inbox",
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
source = ["feedback_inbox"]
branch = true
omit = [
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"raise AssertionError",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]
partial_branches = ["pragma: no branch"]

View File

@ -1,10 +0,0 @@
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
# floor is satisfied by anything newer, so it tells a checker nothing
# about whether this repo is actually current.
bandit[toml]==1.9.4
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
ruff==0.17.4

View File

@ -1,12 +0,0 @@
[Unit]
Description=feedback-inbox: a game folder appeared or went away
[Path]
# ~/data fires when a *_binaries folder is created; the generated drop-in
# feedback-inbox-sync.path.d/dirs.conf adds each existing *_binaries folder, so
# a game's later `feedback/` subfolder is seen too.
PathChanged=%h/data
Unit=feedback-inbox-sync.service
[Install]
WantedBy=default.target

View File

@ -1,12 +0,0 @@
[Unit]
Description=feedback-inbox: re-arm the watches for new games, then import
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
StartLimitIntervalSec=0
[Service]
Type=oneshot
WorkingDirectory=/
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox sync
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 0
Nice=10
TimeoutStartSec=2min

View File

@ -1,12 +0,0 @@
[Unit]
Description=feedback-inbox: fallback sweep for events the path units missed
[Timer]
# Events that land while a run is active are lost by .path units; this catches
# them. No Persistent=: a missed sweep is harmless, the next one runs soon.
OnBootSec=2min
OnUnitActiveSec=5min
Unit=feedback-inbox-sync.service
[Install]
WantedBy=timers.target

View File

@ -1,12 +0,0 @@
[Unit]
Description=feedback-inbox: watch every game's feedback folder
[Path]
# systemd paths take no globs. The folders themselves are listed in the
# generated drop-in feedback-inbox.path.d/dirs.conf (`feedback-inbox sync`);
# this baseline is the token file, which exists once installed and never churns.
PathChanged=%h/.config/feedback-inbox/token
Unit=feedback-inbox.service
[Install]
WantedBy=default.target

View File

@ -1,15 +0,0 @@
[Unit]
Description=feedback-inbox: turn new game feedback bundles into todo tasks
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
# The .path unit fires on every write in a feedback folder; bursts (a bundle is
# written in pieces) must not trip the default start limit.
StartLimitIntervalSec=0
[Service]
Type=oneshot
WorkingDirectory=/
# Waits up to 15 s for a half-written bundle to finish before leaving it to the
# timer. Exits non-zero when the todo server refuses, so the failure is visible.
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 15
Nice=10
TimeoutStartSec=2min

View File

@ -9,7 +9,7 @@ clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
earns only once the real clock reaches it. An unreadable registry reads as 0 earns only once the real clock reaches it. An unreadable registry reads as 0
(logged): fail closed. (logged): fail closed.
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused Unbacked minutes (0.8.0): timer time the anti-cheat gate refused
(:mod:`focus_project.sources.activity`) and no other source covers, on the (:mod:`focus_project.sources.activity`) and no other source covers, on the
local day up to "now"; carry and the cap play no part. Shown, never paid. local day up to "now"; carry and the cap play no part. Shown, never paid.
""" """

View File

@ -1,131 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
The core rule lives in ``test_backing.py``; these pin the edges the spec
named (C5-C7) with leetcode-ledger and other-project fixtures.
"""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import minutes
from focus_project._paths import activity_path
from focus_project.tests.test_leetcode_source import ac_row, write
from focus_project.tests.test_minutes import at
if TYPE_CHECKING:
from pathlib import Path
DAY = date(2026, 11, 9) # after the cutover
PRE = date(2026, 11, 7) # before it
@pytest.fixture(autouse=True)
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Nothing may fall back to the real home directory."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
fp.create_project(
pid,
name=pid,
start=date(2026, 11, 6),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=240,
unlock_minutes=30,
site_patterns=list(patterns),
)
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for item in items:
source, kind, ts = item[:3]
pid = item[3] if len(item) == 4 else "hr"
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
handle.write(json.dumps(row) + "\n")
def timer(day: date, *marks: tuple[int, int]) -> None:
"""A segment: start at the first mark, check-ins, stop at the last."""
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
rows(
*(
("timer", k, at(h, m, day=day))
for k, (h, m) in zip(kinds, marks, strict=True)
)
)
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
rows((source, "heartbeat", at(h, m, day=day), pid))
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
beat(source, 9, 5)
assert minutes.minutes_for(DAY) == 10
def test_browser_and_other_project_beats_do_not_back() -> None:
make() # "other" needs no registry entry: rows are filtered by id
timer(DAY, (9, 0), (9, 5), (9, 10))
beat("browser", 9, 4)
beat("tutor", 9, 4, pid="other")
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
make("hr", ("sqlzoo.net",))
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 0
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
make()
rows(
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
)
assert minutes.minutes_for(date(2026, 11, 7)) == 2
assert minutes.minutes_for(date(2026, 11, 8)) == 0
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
nothing bridges it, so it shows as unbacked.
"""
make()
rows(
("timer", "start", at(9, 0, day=DAY)),
("timer", "checkin", at(9, 8, day=DAY)),
)
beat("tutor", 9, 16)
assert minutes.minutes_for(DAY) == 8
assert minutes.unbacked_minutes_for(DAY) == 8

View File

@ -97,25 +97,6 @@ A claim from a pre-0.11 writer has no key and reads as `grab != "none"`. Root
has no `XDG_RUNTIME_DIR`, so a root reader passes the user's dir has no `XDG_RUNTIME_DIR`, so a root reader passes the user's dir
(`/run/user/1000/gatelock/claims`). (`/run/user/1000/gatelock/claims`).
`LockWindow` yields to a system credential prompt that holds the X grab. If the
global grab stays unobtainable for more than 2 s AND a mapped window whose
WM_CLASS is in `gatelock._prompt_names.SYSTEM_PROMPT_CLASSES` (`gcr-prompter`,
`pinentry*`, polkit agents, ssh-askpass variants) is on screen, that prompt's
top-level window is raised above the lock and the lock's periodic re-raise
stops covering it while it stays mapped. The lock stays mapped, VT switching
stays disabled and the grab keeps being retried; once the grab is taken the lock
is back on top. Any other grab holder (a fullscreen game) never gets this.
`scripts/verify_prompt_yield.py` proves it on a private Xvfb (never `:0`).
Lockers that start together do not fight. `wait_for_turn` waits
`STARTUP_GRACE_SECONDS` (1.5 s) after `publish()` before its first check, so
simultaneous starters see each other. An armed lock also steps back (`_stepback`)
when a strictly higher-ranked, live claim that locks at least as firmly appears
later: it hides, releases the grab and its holder lock (the claim stays, so
lower ranks keep queueing), waits windowless, and re-arms fully once nothing
outranks it. `scripts/verify_all_lockers.py` checks this with five real
processes on a private Xvfb.
## Development ## Development
```bash ```bash

View File

@ -222,19 +222,14 @@ class Arbiter:
return self._claim return self._claim
return read_claim_if_held(path) return read_claim_if_held(path)
def drop_holder(self) -> None:
"""Drop only the holder lock; the claim stays (see ``_stepback``)."""
if self._holder_handle is not None:
self._holder_handle.close()
self._holder_handle = None
def release(self) -> None: def release(self) -> None:
"""Drop the holder lock and this app's claim. """Drop the holder lock and this app's claim.
Must run on every clean exit path (``flock`` covers a killed process): Must run on every exit path. ``flock`` covers a killed process for
the morning routine runs the alarm and then the workout locker as free, but a *cleanly* exiting app has to release explicitly -- the
sequential subprocesses, and a lingering claim would make the second morning routine runs the alarm and then the workout locker as
stand down against an app that had already finished. sequential subprocesses, and a lingering claim would make the workout
locker stand down against an app that had already finished.
""" """
for handle in (self._holder_handle, self._claim_handle): for handle in (self._holder_handle, self._claim_handle):
if handle is not None: if handle is not None:

View File

@ -22,12 +22,11 @@ Nothing here reaches back into the window, so the arming sequence can be read
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
from dataclasses import dataclass, field from dataclasses import dataclass
import logging import logging
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from gatelock._prompts import PromptYield
from gatelock._surfaces import needs_backdrop_root from gatelock._surfaces import needs_backdrop_root
from gatelock._vt import disable_vt_switching from gatelock._vt import disable_vt_switching
@ -71,8 +70,6 @@ class ArmingCollaborators:
still blocked, to name the holder and stand a weaker one down. still blocked, to name the holder and stand a weaker one down.
notify_grab_held: Called once a grab call has actually succeeded, so notify_grab_held: Called once a grab call has actually succeeded, so
the claim's ``grab_held`` turns true only then. the claim's ``grab_held`` turns true only then.
prompt_yield: Times the blocked grab (shared with the recovery loop) and
decides whether a system credential prompt gets raised over us.
""" """
config: LockConfig config: LockConfig
@ -83,7 +80,6 @@ class ArmingCollaborators:
notify_focus_ready: Callable[[], None] notify_focus_ready: Callable[[], None]
log_grab_blocked: Callable[[int], None] log_grab_blocked: Callable[[int], None]
notify_grab_held: Callable[[], None] = _no_grab_listener notify_grab_held: Callable[[], None] = _no_grab_listener
prompt_yield: PromptYield = field(default_factory=PromptYield)
def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool: def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool:
@ -171,16 +167,14 @@ def acquire_global_grab(
""" """
retry_ms = collab.config.grab.retry_ms retry_ms = collab.config.grab.retry_ms
try: try:
collab.prompt_yield.check_grab()
root.grab_set_global() root.grab_set_global()
except tk.TclError as exc: except tk.TclError:
if retry_ms == 0: if retry_ms == 0:
_logger.warning("Global grab failed (%s), falling back to local grab", exc) _logger.warning("Global grab failed, falling back to local grab")
with contextlib.suppress(tk.TclError): with contextlib.suppress(tk.TclError):
root.grab_set() root.grab_set()
collab.notify_grab_held() collab.notify_grab_held()
return return
collab.prompt_yield.grab_blocked(str(exc))
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
if not attempt % collab.config.grab.log_every: if not attempt % collab.config.grab.log_every:
collab.log_grab_blocked(attempt) collab.log_grab_blocked(attempt)
@ -189,8 +183,6 @@ def acquire_global_grab(
lambda: acquire_global_grab(root, collab, attempt=attempt + 1), lambda: acquire_global_grab(root, collab, attempt=attempt + 1),
) )
return return
if collab.prompt_yield.grab_held():
collab.surfaces.raise_all()
collab.notify_grab_held() collab.notify_grab_held()
with contextlib.suppress(tk.TclError): with contextlib.suppress(tk.TclError):
root.focus_force() root.focus_force()

View File

@ -1,132 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Asking X whether the global grab is free, without blocking Tk.
Tk's ``grab -global`` retries internally and blocks the WHOLE event loop for
about 1.0 s on every failed attempt (measured 2026-11-11). The lock retries
every 200 ms and once per recovery tick, so while a foreign client held the
grab (the keyring prompt) the loop was starved: no repaint, no clicks, rows and
buttons without their text.
The probe asks first, on a throwaway python-xlib connection: grab the keyboard
and the pointer on the root with ``GrabModeAsync`` and give them straight
back. ``AlreadyGrabbed`` means somebody else holds one, so the caller skips
Tk's call for this attempt; a failed request costs microseconds instead of a
second. Anything else -- free, or any status or error we do not understand --
means "unknown", and the caller makes today's Tk call: the probe can only ever
*spare* a doomed attempt, never prevent one that might have worked.
Deliberately not part of the ``_recovery_*`` family: it releases the grab it
briefly took, which that family's static invariant forbids by name.
"""
from __future__ import annotations
import contextlib
import logging
import tkinter as tk
from typing import TYPE_CHECKING, Protocol
from gatelock._xlib import load_xlib, xlib_failures
if TYPE_CHECKING:
from types import ModuleType
# python-xlib ships no stubs; spell out the surface used (see _randr.py).
class _XRoot(Protocol):
def grab_keyboard(self, *args: int | bool) -> int:
"""Mirror ``Window.grab_keyboard()``."""
def grab_pointer(self, *args: int | bool) -> int:
"""Mirror ``Window.grab_pointer()``."""
class _XScreen(Protocol):
root: _XRoot
class _XDisplay(Protocol):
def screen(self) -> _XScreen:
"""Mirror ``Display.screen()``."""
def ungrab_keyboard(self, time: int) -> None:
"""Mirror ``Display.ungrab_keyboard()``."""
def ungrab_pointer(self, time: int) -> None:
"""Mirror ``Display.ungrab_pointer()``."""
def sync(self) -> None:
"""Mirror ``Display.sync()``."""
_logger = logging.getLogger(__name__)
_OWNER_EVENTS = True
_NO_EVENTS = 0
class GrabProbe:
"""Answers "is another X client holding the keyboard or pointer?"."""
def __init__(self) -> None:
"""Start with nothing to repeat."""
self._last_warning: str | None = None
def _warn_once(self, message: str) -> None:
"""Warn about a failure, but only when the message changes."""
if message != self._last_warning:
_logger.warning("%s", message)
self._last_warning = message
def check(self) -> None:
"""Raise ``tk.TclError`` if the grab is known to be held elsewhere.
Raised as Tk's own exception so the callers keep a single ``except``
for "could not grab", whoever noticed.
"""
reason = self.blocked()
if reason is not None:
raise tk.TclError(reason)
def blocked(self) -> str | None:
"""Why the grab cannot be taken right now, or None if unknown or free."""
modules = load_xlib("Xlib.display", "Xlib.X", "Xlib.error")
if modules is None:
self._warn_once("python-xlib is not installed; cannot probe the grab")
return None
xdisplay, xconst, xerror = modules
failures = xlib_failures(xerror)
try:
display = xdisplay.Display()
except failures as exc:
self._warn_once(f"cannot open an X connection to probe the grab: {exc}")
return None
try:
return _probe(display, xconst)
except failures as exc:
self._warn_once(f"probing the grab failed: {exc}")
return None
finally:
with contextlib.suppress(OSError):
display.close()
def _probe(display: _XDisplay, xconst: ModuleType) -> str | None:
"""Grab keyboard then pointer on the root, release both, report a holder."""
root = display.screen().root
modes = (xconst.GrabModeAsync, xconst.GrabModeAsync)
device = "keyboard"
status = root.grab_keyboard(_OWNER_EVENTS, *modes, xconst.CurrentTime)
if status == xconst.GrabSuccess:
device = "pointer"
status = root.grab_pointer(
_OWNER_EVENTS,
_NO_EVENTS,
*modes,
xconst.NONE,
xconst.NONE,
xconst.CurrentTime,
)
display.ungrab_keyboard(xconst.CurrentTime)
display.ungrab_pointer(xconst.CurrentTime)
display.sync()
if status == xconst.AlreadyGrabbed:
return f"grab failed: another application has grab ({device}, probed)"
return None

View File

@ -6,9 +6,8 @@ windows. This half owns the geometry questions -- is the window mapped, is it
still exactly on its output -- and the re-assertion pass that answers "no" by still exactly on its output -- and the re-assertion pass that answers "no" by
correcting it. correcting it.
Everything the recovery loop reaches here only ever *adds* visibility; that Everything here only ever *adds* visibility. Nothing in this module can hide
asymmetry is what makes it monotonic. The one hider, :func:`set_visible`, is or unmap a surface; that asymmetry is what makes the recovery loop monotonic.
reached only by :mod:`gatelock._stepback`.
""" """
from __future__ import annotations from __future__ import annotations
@ -56,9 +55,7 @@ def geometry_matches(surface: _Surface) -> bool:
) )
def enforce_one( def enforce_one(surface: _Surface, *, overrideredirect: bool) -> bool:
surface: _Surface, *, overrideredirect: bool, lift: bool = True
) -> bool:
"""Re-assert one surface's placement and stacking. """Re-assert one surface's placement and stacking.
Args: Args:
@ -66,9 +63,6 @@ def enforce_one(
overrideredirect: Whether the lock runs WM-unmanaged, in which case overrideredirect: Whether the lock runs WM-unmanaged, in which case
the flag is re-set -- it can be dropped across an unmap/map cycle, the flag is re-set -- it can be dropped across an unmap/map cycle,
and a managed lock window would be moved by the WM. and a managed lock window would be moved by the WM.
lift: Whether to raise the window. False only while a system prompt
is being yielded to (:mod:`gatelock._prompts`); placement and
mapping are corrected either way.
Returns: Returns:
True if anything needed correcting. True if anything needed correcting.
@ -83,32 +77,10 @@ def enforce_one(
changed = True changed = True
if overrideredirect: if overrideredirect:
window.overrideredirect(boolean=True) window.overrideredirect(boolean=True)
if lift: window.lift()
window.lift()
return changed return changed
def set_visible(
surfaces: Mapping[str, _Surface], *, visible: bool, overrideredirect: bool
) -> None:
"""Hide or show every surface. Only a stepping-back lock ever hides.
Showing restores the creation order (override-redirect while still
unmapped, geometry, then map and raise) rather than ``enforce_one``'s
map-first order, so a surface cannot come back window-manager-managed.
"""
for surface in surfaces.values():
window = surface.window
if not visible:
window.withdraw()
continue
if overrideredirect:
window.overrideredirect(boolean=True)
set_geometry(window, surface.info.rect)
window.deiconify()
window.lift()
def blind_outputs( def blind_outputs(
surfaces: Mapping[str, _Surface], names: frozenset[str] surfaces: Mapping[str, _Surface], names: frozenset[str]
) -> tuple[str, ...]: ) -> tuple[str, ...]:
@ -133,14 +105,13 @@ def blind_outputs(
def enforce_all( def enforce_all(
surfaces: Mapping[str, _Surface], *, overrideredirect: bool, lift: bool = True surfaces: Mapping[str, _Surface], *, overrideredirect: bool
) -> tuple[str, ...]: ) -> tuple[str, ...]:
"""Re-assert every surface's placement and stacking. """Re-assert every surface's placement and stacking.
Args: Args:
surfaces: The live surfaces, keyed by output name. surfaces: The live surfaces, keyed by output name.
overrideredirect: Whether the lock runs WM-unmanaged. overrideredirect: Whether the lock runs WM-unmanaged.
lift: Whether to raise the surfaces; see :func:`enforce_one`.
Returns: Returns:
The names of the outputs whose surfaces needed correcting, sorted. The names of the outputs whose surfaces needed correcting, sorted.
@ -148,7 +119,7 @@ def enforce_all(
corrected = [ corrected = [
surface.info.output_name surface.info.output_name
for surface in surfaces.values() for surface in surfaces.values()
if enforce_one(surface, overrideredirect=overrideredirect, lift=lift) if enforce_one(surface, overrideredirect=overrideredirect)
] ]
return tuple(sorted(corrected)) return tuple(sorted(corrected))

View File

@ -22,39 +22,22 @@ if TYPE_CHECKING:
from gatelock._arbiter import Arbiter from gatelock._arbiter import Arbiter
from gatelock._claims import Claim from gatelock._claims import Claim
from gatelock._config import LockConfig from gatelock._config import LockConfig
from gatelock._prompts import PromptYield
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def _foreign_holder(arbiter: Arbiter | None) -> Claim | None:
"""The gatelock app holding the screen, unless that app is this process.
``Arbiter.describe_holder`` answers "who owns the holder file", and when
this process wrote it that is our own claim. Reporting it as the grab's
holder made the lock blame itself (2026-11-11) and hid the real holder.
"""
holder = arbiter.describe_holder() if arbiter else None
if holder is None or arbiter is None:
return None
return None if holder.instance_id == arbiter.claim.instance_id else holder
def log_grab_blocked( def log_grab_blocked(
attempt: int, attempt: int,
*, *,
arbiter: Arbiter | None, arbiter: Arbiter | None,
config: LockConfig, config: LockConfig,
preempted_pids: set[int], preempted_pids: set[int],
prompt_yield: PromptYield | None = None,
) -> None: ) -> None:
"""Say who is actually holding the grab, rather than guessing. """Say who is actually holding the grab, rather than guessing.
v0.1.1 always blamed "a fullscreen game". On 2026-07-25 the holder was v0.1.1 always blamed "a fullscreen game". On 2026-07-25 the holder was
the other locker, and that guess sent the diagnosis in the wrong the other locker, and that guess sent the diagnosis in the wrong
direction for the length of the outage. On 2026-11-11 the lock blamed direction for the length of the outage.
*itself* (its own holder file) while the holder was the GNOME Keyring
prompt, so a holder that is this process counts as "no gatelock holder".
A holder that outranks us is left alone entirely -- retrying is the A holder that outranks us is left alone entirely -- retrying is the
correct behavior there, matching :func:`gatelock.wait_for_turn`'s own correct behavior there, matching :func:`gatelock.wait_for_turn`'s own
@ -63,36 +46,24 @@ def log_grab_blocked(
as long as it takes to finish on its own (diet_guard held the grab for as long as it takes to finish on its own (diet_guard held the grab for
~3 minutes against a higher-ranked screen_locker on 2026-08-21). ~3 minutes against a higher-ranked screen_locker on 2026-08-21).
X cannot say which client holds a grab, so credential prompts seen on
screen are named as *present*, not as the verdict.
Args: Args:
attempt: 1-based attempt counter, reported in the log line. attempt: 1-based attempt counter, reported in the log line.
arbiter: This app's arbiter, or None when arbitration is disabled. arbiter: This app's arbiter, or None when arbitration is disabled.
config: The window's configuration, consulted for the preempt opt-in. config: The window's configuration, consulted for the preempt opt-in.
preempted_pids: Pids already signalled; mutated by the preempt path. preempted_pids: Pids already signalled; mutated by the preempt path.
prompt_yield: The grab-block policy; supplies Tk's error text for the
failed grab and the system prompts seen on screen.
""" """
reason = prompt_yield.last_reason if prompt_yield else "" holder = arbiter.describe_holder() if arbiter else None
prompts = prompt_yield.prompt_names if prompt_yield else ()
cause = f" ({reason})" if reason else ""
holder = _foreign_holder(arbiter)
if holder is None: if holder is None:
_logger.warning( _logger.warning(
"global grab still blocked after %d attempts%s; no other gatelock " "global grab still blocked after %d attempts; no gatelock app "
"app holds it -- a non-gatelock X client does (e.g. a fullscreen " "holds it -- likely another X client (e.g. a fullscreen game)",
"game or a credential prompt)%s",
attempt, attempt,
cause,
f"; system prompt on screen: {', '.join(prompts)}" if prompts else "",
) )
return return
_logger.warning( _logger.warning(
"global grab still blocked after %d attempts%s; held by gatelock app " "global grab still blocked after %d attempts; held by gatelock app "
"%r (rank %d, pid %d) since %s", "%r (rank %d, pid %d) since %s",
attempt, attempt,
cause,
holder.app, holder.app,
holder.rank, holder.rank,
holder.pid, holder.pid,

View File

@ -1,62 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Which X windows are known system credential prompts.
Incident 2026-11-11: at login the GNOME Keyring unlock prompt (``gcr-prompter``)
took the X keyboard and pointer grab ~1 s before a lock armed. The lock's
override-redirect windows then covered the prompt, so the grab could never be
taken and nothing on screen could be clicked. Yielding to *these* windows --
and only these -- is the one deliberate exception to "the lock is always on
top"; anything else holding the grab (a fullscreen game) stays covered.
"""
from __future__ import annotations
from dataclasses import dataclass
from fnmatch import fnmatchcase
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Iterable
SYSTEM_PROMPT_CLASSES: Final = (
"gcr-prompter",
"pinentry*",
"polkit-gnome-authentication-agent-1",
"lxpolkit",
"ssh-askpass*",
"x11-ssh-askpass",
"ksshaskpass",
"gnome-ssh-askpass",
)
"""``fnmatch`` patterns, lowercase, matched against WM_CLASS instance or class."""
@dataclass(frozen=True)
class PromptWindow:
"""A mapped system prompt: the client and the ancestor below the root.
Attributes:
window_id: The client window that carries the WM_CLASS.
toplevel_id: Its ancestor that is a direct child of the root. Under a
reparenting window manager (i3) that is the WM's frame, and it is
the window whose stacking position matters.
wm_class: The ``(instance, class)`` pair.
"""
window_id: int
toplevel_id: int
wm_class: tuple[str, str]
@property
def name(self) -> str:
"""The WM_CLASS instance, for log lines."""
return self.wm_class[0]
def is_system_prompt(wm_class: Iterable[str]) -> bool:
"""Whether a WM_CLASS (instance or class, any case) is an allowlisted prompt."""
return any(
fnmatchcase(part.lower(), pattern)
for part in wm_class
for pattern in SYSTEM_PROMPT_CLASSES
)

View File

@ -1,151 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Stepping aside for a system credential prompt that holds the X grab.
Incident 2026-11-11: the GNOME Keyring prompt (``gcr-prompter``) was mapped and
holding the X keyboard+pointer grab when a lock armed. The lock's
override-redirect windows covered it, so the grab could never be taken, every
click went to a window nobody could see, and the lock's own UI was dead.
The policy (approved by the user): when the global grab has been unobtainable
for more than :data:`YIELD_AFTER_S` AND a mapped window on the allowlist
(:data:`gatelock._prompt_names.SYSTEM_PROMPT_CLASSES`) is on screen, raise that
prompt above the lock and stop the lock's own periodic re-raise from covering
it again, for as long as the prompt stays mapped. Everything else stays as it
was: the lock is still mapped everywhere, VT switching stays disabled and the
grab is still retried every tick. Once the grab is taken the lock stacks on top
again. Anything not on the allowlist -- a fullscreen game -- is never yielded
to.
Known limitation of an allowlist keyed on WM_CLASS: a window that merely
*claims* one of those classes while holding a grab would be raised too.
This module is the policy and the clock. All X access is in
:mod:`gatelock._prompts_x`. It is deliberately NOT named ``_recovery_*``: the
recovery family is statically checked to contain no release call, and keeping
the X plumbing out of it keeps that check meaningful.
"""
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING, Final, Protocol
from gatelock._grabprobe import GrabProbe
from gatelock._prompts_x import XPromptProbe
if TYPE_CHECKING:
from collections.abc import Callable
from gatelock._prompt_names import PromptWindow
_logger = logging.getLogger(__name__)
YIELD_AFTER_S: Final = 2.0
"""How long the grab must stay unobtainable before looking for a prompt."""
class PromptProbe(Protocol):
"""What the policy needs from the X side."""
def raise_prompts(self) -> tuple[PromptWindow, ...]:
"""Raise every mapped allowlisted prompt; return those it raised."""
class PromptYield:
"""Times a blocked grab and decides whether to raise a prompt over the lock."""
def __init__(
self,
probe: PromptProbe | None = None,
*,
grab_probe: GrabProbe | None = None,
clock: Callable[[], float] = time.monotonic,
after_s: float = YIELD_AFTER_S,
) -> None:
"""Build the policy; nothing is blocked yet.
Args:
probe: Finds and raises prompts. Defaults to the python-xlib one.
grab_probe: Asks X whether the grab is free. Defaults to the real one.
clock: Monotonic seconds; injected by tests.
after_s: Seconds the grab must stay blocked before yielding.
"""
self._probe: PromptProbe = probe if probe is not None else XPromptProbe()
self._grab_probe = grab_probe if grab_probe is not None else GrabProbe()
self._clock = clock
self._after_s = after_s
self._blocked_since: float | None = None
self._reason = ""
self._prompts: tuple[PromptWindow, ...] = ()
@property
def last_reason(self) -> str:
"""Tk's message for the most recent failed grab (empty if none yet)."""
return self._reason
@property
def yielding(self) -> bool:
"""Whether a prompt is being yielded to right now."""
return bool(self._prompts)
@property
def prompt_names(self) -> tuple[str, ...]:
"""WM_CLASS instances of the prompts currently yielded to."""
return tuple(prompt.name for prompt in self._prompts)
def check_grab(self) -> None:
"""Raise ``tk.TclError`` if the grab is known to be held elsewhere.
Call before every ``grab_set_global()``: Tk blocks its whole event
loop for ~1 s on each failed attempt, so a doomed one is skipped.
"""
self._grab_probe.check()
def grab_blocked(self, reason: str) -> None:
"""Record a failed grab; the first failure starts the clock.
Args:
reason: Tk's error text ("another application has grab", ...).
"""
self._reason = reason
if self._blocked_since is None:
self._blocked_since = self._clock()
def grab_held(self) -> bool:
"""Record that the grab is ours; end any yield.
Returns:
True when a yield was in progress, so the caller must lift the
lock over whatever it had stepped aside for.
"""
self._blocked_since = None
was_yielding = self.yielding
self._prompts = ()
if was_yielding:
_logger.warning("global grab taken; the lock stacks on top again")
return was_yielding
def settle(self) -> None:
"""Re-decide, and re-raise the prompt. Call LAST in a recovery tick.
Last, so that nothing the tick lifted can end up above the prompt.
"""
since = self._blocked_since
if since is None or self._clock() - since < self._after_s:
return
previous = self.prompt_names
self._prompts = self._probe.raise_prompts()
if self.prompt_names == previous:
return
if self._prompts:
_logger.warning(
"global grab blocked for %.1fs (%s) and system prompt %s is on "
"screen; raising it above the lock so it can be answered. The "
"lock stays up, VT stays disabled, the grab keeps being retried",
self._clock() - since,
self._reason,
", ".join(self.prompt_names),
)
else:
_logger.info("system prompt %s is gone; lock on top", ", ".join(previous))

View File

@ -1,176 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Finding a system credential prompt on screen and raising it, over python-xlib.
Kept apart from the policy in :mod:`gatelock._prompts` so that everything that
touches the X server lives in one place, behind :class:`XPromptProbe`. It opens
a short-lived connection per call: the caller asks at most once a second, and
only while the grab has been blocked for a while, so a persistent connection
would need a teardown path for no gain.
Candidates come from the window manager's ``_NET_CLIENT_LIST`` *and* a walk of
the root's children and grandchildren. The walk is not redundant: a bare X
server has no ``_NET_CLIENT_LIST``, and under i3 the WM_CLASS sits on the
client, one level below the frame i3 reparents it into. What gets raised is
the ancestor directly below the root (the frame), never the client.
Every X failure -- no python-xlib, no connection, a window that vanished
between listing and reading it (BadWindow) -- ends the scan with "no prompt
found", warned once per distinct message: the lock must stay on top whenever
it cannot be sure.
"""
from __future__ import annotations
import contextlib
import logging
from typing import TYPE_CHECKING, Protocol
from gatelock._prompt_names import PromptWindow, is_system_prompt
from gatelock._xlib import load_xlib, xlib_failures
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from types import ModuleType
# python-xlib ships no stubs; spell out the surface used (see _randr.py).
class _Tree(Protocol):
children: Sequence[_XWindow]
parent: _XWindow
class _Attributes(Protocol):
map_state: int
class _Property(Protocol):
value: Sequence[int]
class _XWindow(Protocol):
id: int
def query_tree(self) -> _Tree:
"""Mirror ``Window.query_tree()``."""
def get_wm_class(self) -> tuple[str, str] | None:
"""Mirror ``Window.get_wm_class()``."""
def get_attributes(self) -> _Attributes:
"""Mirror ``Window.get_attributes()``."""
def get_full_property(self, atom: object, kind: int) -> _Property | None:
"""Mirror ``Window.get_full_property()``."""
def configure(
self, *, stack_mode: int, onerror: Callable[[object], None]
) -> None:
"""Mirror ``Window.configure()``."""
class _XScreen(Protocol):
root: _XWindow
class _XDisplay(Protocol):
def screen(self) -> _XScreen:
"""Mirror ``Display.screen()``."""
def intern_atom(self, name: str) -> object:
"""Mirror ``Display.intern_atom()``."""
def create_resource_object(self, kind: str, window_id: int) -> _XWindow:
"""Mirror ``Display.create_resource_object()``."""
def sync(self) -> None:
"""Mirror ``Display.sync()``."""
def close(self) -> None:
"""Mirror ``Display.close()``."""
_logger = logging.getLogger(__name__)
class XPromptProbe:
"""Looks for allowlisted prompts and raises their top-level windows."""
def __init__(self) -> None:
"""Start with nothing to repeat."""
self._last_warning: str | None = None
def _warn_once(self, message: str) -> None:
"""Warn about a failure, but only when the message changes."""
if message != self._last_warning:
_logger.warning("%s", message)
self._last_warning = message
def raise_prompts(self) -> tuple[PromptWindow, ...]:
"""Raise every mapped system prompt above everything else.
Returns:
The prompts that are on screen and were raised; empty when there
are none, or when anything went wrong (the lock then stays on top).
"""
modules = load_xlib("Xlib.display", "Xlib.X", "Xlib.error")
if modules is None:
self._warn_once("python-xlib is not installed; cannot look for a prompt")
return ()
xdisplay, xconst, xerror = modules
failures = xlib_failures(xerror)
try:
display: _XDisplay = xdisplay.Display()
except failures as exc:
self._warn_once(f"cannot open an X connection to look for a prompt: {exc}")
return ()
refused: list[object] = []
try:
found = _find(display, xconst)
for prompt in found:
top = display.create_resource_object("window", prompt.toplevel_id)
top.configure(stack_mode=xconst.Above, onerror=refused.append)
display.sync()
except failures as exc:
self._warn_once(f"looking for a prompt failed: {exc}")
return ()
finally:
with contextlib.suppress(OSError):
display.close()
if refused:
self._warn_once(f"the X server refused to raise the prompt: {refused[0]}")
return ()
self._last_warning = None
return found
def _find(display: _XDisplay, xconst: ModuleType) -> tuple[PromptWindow, ...]:
"""Every viewable allowlisted window, with its top-level ancestor."""
root = display.screen().root
ids: dict[int, None] = {}
listed = root.get_full_property(
display.intern_atom("_NET_CLIENT_LIST"), xconst.AnyPropertyType
)
if listed is not None:
ids.update(dict.fromkeys(int(value) for value in listed.value))
for child in root.query_tree().children:
ids[child.id] = None
ids.update(dict.fromkeys(grand.id for grand in child.query_tree().children))
found: list[PromptWindow] = []
for window_id in ids:
window = display.create_resource_object("window", window_id)
wm_class = window.get_wm_class()
if not wm_class or not is_system_prompt(wm_class):
continue
if window.get_attributes().map_state != xconst.IsViewable:
continue
found.append(
PromptWindow(
window_id=window_id,
toplevel_id=_toplevel(window, root.id),
wm_class=(str(wm_class[0]), str(wm_class[1])),
)
)
return tuple(found)
def _toplevel(window: _XWindow, root_id: int) -> int:
"""The id of ``window``'s ancestor that is a direct child of the root."""
top = window
parent = top.query_tree().parent
while parent.id != root_id:
top, parent = parent, parent.query_tree().parent
return top.id

View File

@ -30,12 +30,6 @@ from typing import TYPE_CHECKING, Final
_logger: Final = logging.getLogger(__name__) _logger: Final = logging.getLogger(__name__)
QUEUE_POLL_SECONDS: Final = 2.0 QUEUE_POLL_SECONDS: Final = 2.0
# After publishing, wait this long before the FIRST turn check, so lockers that
# start together see each other's claims. Without it a weaker locker whose
# check ran before a stronger one had published armed alone, and the stronger
# then armed on top: two windows, the weaker holding the grab, the visible one
# unclickable. Read at call time (tests set it to 0).
STARTUP_GRACE_SECONDS: Final = 1.5
QUEUE_DEADLINE_SECONDS: Final = 6 * 60 * 60 QUEUE_DEADLINE_SECONDS: Final = 6 * 60 * 60
# How often to re-state a still-blocked wait at WARNING. A long wait used # How often to re-state a still-blocked wait at WARNING. A long wait used
# to produce exactly one INFO line at the moment it started and nothing # to produce exactly one INFO line at the moment it started and nothing
@ -212,8 +206,6 @@ def wait_for_turn(
started = clock() started = clock()
wait = _Wait(arbiter, on_state, started) wait = _Wait(arbiter, on_state, started)
if STARTUP_GRACE_SECONDS > 0:
time_source.sleep(STARTUP_GRACE_SECONDS)
while True: while True:
blockers = stronger_claims(arbiter) blockers = stronger_claims(arbiter)

View File

@ -41,7 +41,6 @@ import logging
import tkinter as tk import tkinter as tk
from gatelock._grabwatch import GrabWatch from gatelock._grabwatch import GrabWatch
from gatelock._recovery_grab import reassert_grab
from gatelock._recovery_types import RecoveryCollaborators, RecoveryReport from gatelock._recovery_types import RecoveryCollaborators, RecoveryReport
from gatelock._vt import disable_vt_switching from gatelock._vt import disable_vt_switching
@ -64,11 +63,6 @@ class RecoveryLoop:
self._verify_job: str | None = None self._verify_job: str | None = None
self._grab = GrabWatch(root) self._grab = GrabWatch(root)
@property
def running(self) -> bool:
"""Whether the loop is enforcing (False once stopped, e.g. study mode)."""
return self._running
@property @property
def ticks(self) -> int: def ticks(self) -> int:
"""How many full verify passes have run.""" """How many full verify passes have run."""
@ -152,17 +146,13 @@ class RecoveryLoop:
) )
return RecoveryReport(scan_ok=False, source=scan.source) return RecoveryReport(scan_ok=False, source=scan.source)
# Read BEFORE enforce: while a system prompt is being yielded to, the
# tick must not lift the lock over it. The prompt is re-raised LAST.
yielding = self._parts.prompt_yield.yielding
self._parts.surfaces.update_backdrop() self._parts.surfaces.update_backdrop()
delta = self._parts.surfaces.apply(scan) delta = self._parts.surfaces.apply(scan)
corrected = self._parts.surfaces.enforce(lift=not yielding) corrected = self._parts.surfaces.enforce()
grab_reasserted = self._reassert_grab() grab_reasserted = self._reassert_grab()
vt_reasserted = self._reassert_vt() vt_reasserted = self._reassert_vt()
if delta.created: if delta.created:
self._reassert_focus() self._reassert_focus()
self._parts.prompt_yield.settle()
if delta.unverified: if delta.unverified:
_logger.error( _logger.error(
@ -198,9 +188,20 @@ class RecoveryLoop:
def _reassert_grab(self) -> bool: def _reassert_grab(self) -> bool:
"""Re-take the global grab if we lost it. Never releases one. """Re-take the global grab if we lost it. Never releases one.
Delegates to :func:`gatelock._recovery_grab.reassert_grab`. Returns:
True if the grab had to be re-taken.
""" """
return reassert_grab(self._root, self._parts, self._grab) if self._parts.config.resolved_grab() != "global":
return False
held = self.holds_grab()
reasserted = False
if not held:
with contextlib.suppress(tk.TclError):
self._root.grab_set_global()
_logger.warning("global grab had been lost; re-acquired it")
reasserted = held = True
self._parts.on_grab_held(held=held)
return reasserted
def holds_grab(self) -> bool: def holds_grab(self) -> bool:
"""Whether the grab is held by a window this application owns. """Whether the grab is held by a window this application owns.

View File

@ -1,57 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Re-taking a lost global grab, once per recovery tick.
Split out of :mod:`gatelock._recovery` for the 250-line cap, and kept in its
``_recovery_*`` family on purpose: the static invariant test globs the family,
so this file is held to the same rule -- it may take the grab, never release it.
"""
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from gatelock._grabwatch import GrabWatch
from gatelock._recovery_types import RecoveryCollaborators
_logger = logging.getLogger(__name__)
def reassert_grab(
root: tk.Misc, parts: RecoveryCollaborators, watch: GrabWatch
) -> bool:
"""Re-take the global grab if we lost it. Never releases one.
A failed attempt is reported with Tk's own message (once per distinct
message: this runs every tick) and timed by the prompt-yield policy. A
grab we hold ends any yield, and then the lock is lifted over whatever it
had stepped aside for.
Args:
root: The Tk root that holds the grab.
parts: The loop's collaborators.
watch: Answers "is the grab ours?" and throttles the warning.
Returns:
True if the grab had to be re-taken.
"""
if parts.config.resolved_grab() != "global":
return False
held = watch.holds_grab()
reasserted = False
if not held:
try:
parts.prompt_yield.check_grab()
root.grab_set_global()
except tk.TclError as exc:
parts.prompt_yield.grab_blocked(str(exc))
watch.warn_once(f"could not re-take the global grab ({exc})")
else:
_logger.warning("global grab had been lost; re-acquired it")
reasserted = held = True
if held and parts.prompt_yield.grab_held():
parts.surfaces.raise_all()
parts.on_grab_held(held=held)
return reasserted

View File

@ -8,11 +8,9 @@ against a report without pulling in the machinery that builds one.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
from gatelock._prompts import PromptYield
if TYPE_CHECKING: if TYPE_CHECKING:
from gatelock._config import LockConfig from gatelock._config import LockConfig
from gatelock._detect import OutputChangeDetector from gatelock._detect import OutputChangeDetector
@ -62,5 +60,3 @@ class RecoveryCollaborators:
hooks: LockWindowHooks hooks: LockWindowHooks
on_grab_held: GrabHeldListener = ignore_grab_held on_grab_held: GrabHeldListener = ignore_grab_held
"""Told each tick whether the grab is really ours (``Claim.grab_held``).""" """Told each tick whether the grab is really ours (``Claim.grab_held``)."""
prompt_yield: PromptYield = field(default_factory=PromptYield)
"""Times a blocked grab and raises a system prompt over the lock."""

View File

@ -1,186 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A weaker armed lock steps back for a stronger one that appears.
Lockers that start together can still race: a weaker one that armed before a
stronger one had published stays armed, and the stronger then arms on top. Two
windows, the weaker one holding the grab, the visible one unclickable -- the
same symptom as the 2026-11-11 keyring incident. The startup grace in
:mod:`gatelock._queue` closes most of that window; this closes the rest (a
stronger locker that starts later, e.g. three seconds after).
While armed, the lock checks about once a second for a **live** claim that is
**strictly** higher ranked AND locks at least as firmly (grab kind and VT:
the arbiter's own rule that rank must never weaken a lock). Equal, lower,
weaker-locking and dead claims never trigger it. On a hit the lock suspends:
recovery and detector stop, the grab is released, surfaces and backdrop are
hidden and the holder lock is dropped. The **claim stays published**, so
lower-ranked apps keep queueing behind it. VT switching is left as it is: the
stronger lock disables it itself, and re-disabling is part of re-arming. It then
waits windowless -- the same turn logic as :func:`gatelock.wait_for_turn` --
and re-arms fully (VT, backdrop, surfaces, grab) once nothing qualifying is
live. It is the same process and lock: the app's state and callbacks are
untouched.
This module holds the library's ONLY ``grab_release`` and the only caller of
``SurfaceSet.set_visible(visible=False)``; a test pins both. It is kept out of
the ``_recovery*`` family, whose static invariant forbids release calls.
"""
from __future__ import annotations
import logging
import time
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock._queue import QUEUE_DEADLINE_SECONDS, stronger_claims
from gatelock._vt import disable_vt_switching
if TYPE_CHECKING:
from collections.abc import Callable
from gatelock._arbiter import Arbiter
from gatelock._arming import ArmingCollaborators
from gatelock._claims import Claim
_logger = logging.getLogger(__name__)
def outranking(arbiter: Arbiter) -> tuple[Claim, ...]:
"""Live claims that must make us step back: higher rank, at least as firm.
``stronger_claims`` proves liveness with ``flock`` (a dead owner's claim is
reaped, never returned) and compares rank alone; the extra strength test is
what stops a stronger-ranked but weaker-locking app from disarming us.
"""
mine = arbiter.claim
return tuple(c for c in stronger_claims(arbiter) if c.at_least_as_strong_as(mine))
def _attempt(action: Callable[[], None], what: str) -> None:
"""Run one Tk step; a failure is logged and the rest still happens."""
try:
action()
except tk.TclError as exc:
_logger.warning("step-back: %s failed (%s)", what, exc)
class StepBack:
"""Suspends an armed lock for a stronger one, and re-arms it afterwards."""
def __init__(
self,
root: tk.Tk,
arming: ArmingCollaborators,
arbiter: Arbiter | None,
rearm: Callable[[], None],
*,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""Wire the controller; nothing is scheduled until :meth:`start`.
Args:
root: The Tk root (backdrop and grab holder).
arming: The window's arming collaborators (surfaces, loop, ...).
arbiter: This app's arbiter; None disables stepping back.
rearm: Re-takes the grab and restarts the loops
(``LockWindow.grab_input``).
clock: Monotonic seconds; injected by tests.
"""
self._root = root
self._arming = arming
self._arbiter = arbiter
self._rearm = rearm
self._clock = clock
self._job: str | None = None
self._since: float | None = None
@property
def suspended(self) -> bool:
"""Whether the lock is currently stepped back."""
return self._since is not None
def start(self) -> None:
"""Begin (or restart) the periodic check. Idempotent."""
if self._arbiter is not None:
self._schedule()
def stop(self) -> None:
"""Cancel the periodic check."""
if self._job is not None:
_attempt(lambda: self._root.after_cancel(str(self._job)), "cancel")
self._job = None
def _schedule(self) -> None:
"""Queue the next check, replacing any pending one."""
self.stop()
self._job = self._root.after(self._arming.config.recovery_tick_ms, self._tick)
def _tick(self) -> None:
"""One check; a raise must not end the checking."""
self._job = None
try:
if self._since is None:
self._maybe_suspend()
else:
self._maybe_resume(self._since)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("step-back check raised; checking continues")
finally:
self._schedule()
def _maybe_suspend(self) -> None:
"""Step back if, and only if, a qualifying claim is live."""
arbiter = self._arbiter
# A stopped loop means the app took the lock down on purpose (study
# mode); it is not ours to suspend or, later, to re-arm.
if arbiter is None or not self._arming.recovery.running:
return
blockers = outranking(arbiter)
if not blockers:
return
_logger.warning(
"%s (rank %d) steps back: %s is live and outranks it; hiding, "
"releasing the grab, waiting with no window",
arbiter.claim.app,
arbiter.claim.rank,
", ".join(f"{c.app} (rank {c.rank})" for c in blockers),
)
# First: if any step below raised, the next tick must still see us as
# suspended and re-arm, never as armed with a half-stopped loop.
self._since = self._clock()
self._arming.recovery.stop()
self._arming.detector.stop()
_attempt(self._root.grab_release, "grab release")
_attempt(lambda: self._arming.surfaces.set_visible(visible=False), "hide")
_attempt(self._root.withdraw, "backdrop hide")
arbiter.set_grab_held(held=False)
arbiter.drop_holder()
def _maybe_resume(self, since: float) -> None:
"""Re-arm once nothing qualifying is live (or the backstop is hit)."""
arbiter = self._arbiter
if arbiter is None: # pragma: no cover - never suspended without one
return
waited = self._clock() - since
if outranking(arbiter):
if waited < QUEUE_DEADLINE_SECONDS:
return
_logger.error(
"%s waited %.0fs stepped back and gave up -- re-arming anyway "
"rather than leaving the machine unlocked",
arbiter.claim.app,
waited,
)
_logger.warning(
"%s re-arms after %.0fs stepped back: nothing outranks it now",
arbiter.claim.app,
waited,
)
self._since = None
arbiter.acquire_holder()
if self._arming.config.resolved_disable_vt():
disable_vt_switching()
_attempt(self._root.deiconify, "backdrop show")
_attempt(lambda: self._arming.surfaces.set_visible(visible=True), "show")
self._rearm()

View File

@ -27,8 +27,10 @@ recording, because each rules out an approach that looks reasonable:
afterwards and the placement is ignored. afterwards and the placement is ignored.
3. **A window-manager-managed toplevel cannot be placed per-output** -- i3 3. **A window-manager-managed toplevel cannot be placed per-output** -- i3
rewrites the geometry wholesale. Per-output placement *requires* rewrites the geometry wholesale. Per-output placement *requires*
override-redirect, so ``attributes(fullscreen=True)`` is gone: an EWMH override-redirect, so ``attributes(fullscreen=True)`` is gone from the lock
request an override-redirect window never sees. path entirely: it is an EWMH request that an override-redirect window is
invisible to, and where it does apply it targets one monitor or the whole
bounding box. Neither is what per-output locking needs.
""" """
from __future__ import annotations from __future__ import annotations
@ -43,7 +45,6 @@ from gatelock._placement import (
enforce_all, enforce_all,
needs_backdrop_root, needs_backdrop_root,
set_geometry, set_geometry,
set_visible,
) )
from gatelock._surface_types import ( from gatelock._surface_types import (
SurfaceBuilder, SurfaceBuilder,
@ -53,7 +54,9 @@ from gatelock._surface_types import (
from gatelock._surfaces_focus import focus_surface, preferred_focus_index from gatelock._surfaces_focus import focus_surface, preferred_focus_index
from gatelock._textmirror import TextMirror, mirror_text_widgets from gatelock._textmirror import TextMirror, mirror_text_widgets
# Re-exports after the 250-line split; listed so the autofixer keeps them. # Names this module re-exports after the 250-line split. Listed explicitly so
# the autofixer cannot prune an import that exists *for* its re-export -- it
# stripped mirror_text_widgets once, and `gatelock` itself stopped importing.
__all__ = [ __all__ = [
"SurfaceBuilder", "SurfaceBuilder",
"SurfaceDelta", "SurfaceDelta",
@ -158,36 +161,27 @@ class SurfaceSet:
) )
def verify(self, *, live_names: frozenset[str] | None = None) -> tuple[str, ...]: def verify(self, *, live_names: frozenset[str] | None = None) -> tuple[str, ...]:
"""Return live outputs that are NOT currently showing a lock.""" """Return live outputs that are NOT currently showing a lock.
This is the alarm for the original defect: an output the user can see,
with no lock on it, means the escape UI is somewhere they cannot reach.
"""
names = live_names if live_names is not None else self.names() names = live_names if live_names is not None else self.names()
return blind_outputs(self._surfaces, names) return blind_outputs(self._surfaces, names)
def enforce(self, *, lift: bool = True) -> tuple[str, ...]: def enforce(self) -> tuple[str, ...]:
"""Re-assert every surface's placement and stacking. """Re-assert every surface's placement and stacking.
Only adds visibility: remaps, corrects drifted geometry, raises. Only ever *adds* visibility: remaps what became unmapped, corrects
drifted geometry, and raises. Nothing here can hide a surface.
Args:
lift: False while a system prompt is yielded to (no raise).
""" """
return enforce_all( return enforce_all(
self._surfaces, self._surfaces,
overrideredirect=self._config.resolved_overrideredirect(), overrideredirect=self._config.resolved_overrideredirect(),
lift=lift,
)
def set_visible(self, *, visible: bool) -> None:
"""Hide or show every surface; only ``_stepback`` hides."""
set_visible(
self._surfaces,
visible=visible,
overrideredirect=self._config.resolved_overrideredirect(),
) )
def raise_all(self) -> None: def raise_all(self) -> None:
"""Lift the backdrop and every surface above whatever else is on screen.""" """Lift every surface above whatever else is on screen."""
if needs_backdrop_root(self._config):
self._root.lift()
for surface in self._surfaces.values(): for surface in self._surfaces.values():
surface.window.lift() surface.window.lift()

View File

@ -31,9 +31,7 @@ from typing import TYPE_CHECKING
from gatelock import _arming, _preempt from gatelock import _arming, _preempt
from gatelock._detect import OutputChangeDetector from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator from gatelock._outputs import OutputEnumerator
from gatelock._prompts import PromptYield
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
from gatelock._stepback import StepBack
from gatelock._surfaces import SurfaceSet from gatelock._surfaces import SurfaceSet
from gatelock._vt import restore_vt_switching from gatelock._vt import restore_vt_switching
@ -82,7 +80,6 @@ class LockWindow:
self._closed = False self._closed = False
self._focus_notified = False self._focus_notified = False
self._preempted_pids: set[int] = set() self._preempted_pids: set[int] = set()
prompt_yield = PromptYield()
surfaces = SurfaceSet(root, config, hooks) surfaces = SurfaceSet(root, config, hooks)
enumerator = OutputEnumerator(root) enumerator = OutputEnumerator(root)
detector = OutputChangeDetector(root) detector = OutputChangeDetector(root)
@ -95,7 +92,6 @@ class LockWindow:
detector=detector, detector=detector,
hooks=hooks, hooks=hooks,
on_grab_held=self.report_grab_held, on_grab_held=self.report_grab_held,
prompt_yield=prompt_yield,
), ),
) )
# The arming bundle is also where the window reaches its # The arming bundle is also where the window reaches its
@ -109,9 +105,7 @@ class LockWindow:
notify_focus_ready=self._notify_focus_ready, notify_focus_ready=self._notify_focus_ready,
log_grab_blocked=self._log_grab_blocked, log_grab_blocked=self._log_grab_blocked,
notify_grab_held=lambda: self.report_grab_held(held=True), notify_grab_held=lambda: self.report_grab_held(held=True),
prompt_yield=prompt_yield,
) )
self._stepback = StepBack(root, self._arming, arbiter, self.grab_input)
@property @property
def surfaces(self) -> SurfaceSet: def surfaces(self) -> SurfaceSet:
@ -146,16 +140,24 @@ class LockWindow:
# -- window mechanics ----------------------------------------------------- # -- window mechanics -----------------------------------------------------
def setup(self) -> None: def setup(self) -> None:
"""Build the backdrop and a surface per live output (``_arming.setup``).""" """Configure the backdrop and build a surface on every live output.
Delegates to :func:`gatelock._arming.setup`.
"""
self._vt_disabled = _arming.setup(self.root, self._arming) self._vt_disabled = _arming.setup(self.root, self._arming)
def grab_input(self) -> None: def grab_input(self) -> None:
"""Take the configured grab, then watch outputs (``_arming.grab_input``).""" """Take the configured grab, then start watching for output changes.
Delegates to :func:`gatelock._arming.grab_input`.
"""
_arming.grab_input(self.root, self._arming) _arming.grab_input(self.root, self._arming)
self._stepback.start()
def _acquire_global_grab(self, *, attempt: int) -> None: def _acquire_global_grab(self, *, attempt: int) -> None:
"""Acquire the global grab, retrying (``_arming.acquire_global_grab``).""" """Acquire the global grab, retrying per ``grab_retry_ms``.
Delegates to :func:`gatelock._arming.acquire_global_grab`.
"""
_arming.acquire_global_grab(self.root, self._arming, attempt=attempt) _arming.acquire_global_grab(self.root, self._arming, attempt=attempt)
def _log_grab_blocked(self, attempt: int) -> None: def _log_grab_blocked(self, attempt: int) -> None:
@ -169,7 +171,6 @@ class LockWindow:
arbiter=self._arbiter, arbiter=self._arbiter,
config=self._config, config=self._config,
preempted_pids=self._preempted_pids, preempted_pids=self._preempted_pids,
prompt_yield=self._arming.prompt_yield,
) )
def _notify_focus_ready(self) -> None: def _notify_focus_ready(self) -> None:
@ -227,7 +228,6 @@ class LockWindow:
if self._closed: if self._closed:
return return
self._closed = True self._closed = True
self._stepback.stop()
self._arming.recovery.stop() self._arming.recovery.stop()
self._arming.detector.stop() self._arming.detector.stop()
self._hooks.on_close() self._hooks.on_close()

View File

@ -31,22 +31,3 @@ def load_xlib(*names: str) -> tuple[ModuleType, ...] | None:
return tuple(importlib.import_module(name) for name in names) return tuple(importlib.import_module(name) for name in names)
except ImportError: except ImportError:
return None return None
def xlib_failures(xerror: ModuleType) -> tuple[type[BaseException], ...]:
"""Every exception a python-xlib connection or request can raise.
Args:
xerror: The loaded ``Xlib.error`` module.
Returns:
A tuple usable in an ``except`` clause.
"""
return (
OSError,
ValueError,
xerror.DisplayError,
xerror.XauthError,
xerror.ConnectionClosedError,
xerror.XError,
)

View File

@ -17,7 +17,6 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from gatelock._arbiter import Arbiter
from gatelock._config import LockConfig from gatelock._config import LockConfig
from gatelock._outputs import Output, OutputRect, OutputScan from gatelock._outputs import Output, OutputRect, OutputScan
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
@ -79,12 +78,6 @@ def _hermetic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[None
patch("gatelock._randr.RandrBackend.create", return_value=None), patch("gatelock._randr.RandrBackend.create", return_value=None),
patch("gatelock._outputs.scan_xrandr", return_value=DEFAULT_OUTPUTS), patch("gatelock._outputs.scan_xrandr", return_value=DEFAULT_OUTPUTS),
patch("gatelock._detect._RandrEventSource.start", return_value=False), patch("gatelock._detect._RandrEventSource.start", return_value=False),
# A lock that yields to a prompt asks X what is on screen; no test may
# reach the developer's display for that.
patch("gatelock._prompts_x.load_xlib", return_value=None),
patch("gatelock._grabprobe.load_xlib", return_value=None),
# The startup grace is real time; the queue tests pass it explicitly.
patch("gatelock._queue.STARTUP_GRACE_SECONDS", 0.0),
patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"), patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"),
patch("gatelock._vt.subprocess.run"), patch("gatelock._vt.subprocess.run"),
): ):
@ -169,39 +162,3 @@ def build_loop(
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]: def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
"""A recovery loop in hard mode, the configuration most tests want.""" """A recovery loop in hard mode, the configuration most tests want."""
return build_loop(mock_root, LockConfig(mode="hard")) return build_loop(mock_root, LockConfig(mode="hard"))
_MADE: list[Arbiter] = []
def make_arbiter(
runtime: Path, app: str, rank: int, *, grab: str = "global", vt: bool = True
) -> Arbiter:
"""A published arbiter in ``runtime``; released after the test."""
arbiter = Arbiter(app, rank, grab=grab, disable_vt=vt, runtime_dir=runtime)
arbiter.publish()
_MADE.append(arbiter)
return arbiter
@pytest.fixture(autouse=True)
def _release_arbiters() -> Generator[None]:
"""Close every claim file a test opened."""
yield
while _MADE:
_MADE.pop().release()
@pytest.fixture
def armed(
mock_root: MagicMock, tmp_path: Path
) -> Generator[tuple[LockWindow, Arbiter]]:
"""A mid-rank (150) hard lock that is armed, holds the screen, and checks."""
arbiter = make_arbiter(tmp_path, "leetcode_guard", 150)
arbiter.acquire_holder()
config = LockConfig(mode="hard", app_name="leetcode_guard", rank=150)
window = LockWindow(mock_root, config, MagicMock(), arbiter=arbiter)
window.setup()
window.grab_input()
yield window, arbiter
window.close()

View File

@ -1,202 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for WHO a blocked grab is blamed on, and the error text that explains it.
Incident 2026-11-11: the lock logged "held by gatelock app 'leetcode_guard'
(pid <its own pid>)" -- it blamed itself, because the holder file it had just
written is the one ``describe_holder`` reads -- while the real holder was the
GNOME Keyring prompt, an ordinary X client. Tk's own error text ("another
application has grab" vs "window not viewable") was swallowed as well.
"""
from __future__ import annotations
from dataclasses import replace
import logging
import os
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from gatelock import _preempt
from gatelock._arbiter import RANK_SCREEN_LOCKER, Arbiter
from gatelock._config import GrabPolicy, LockConfig
from gatelock._prompts import PromptYield
from gatelock.tests.conftest import make_window
if TYPE_CHECKING:
from pathlib import Path
import pytest
REASON = "grab failed: another application has grab"
def _own_arbiter(tmp_path: Path) -> Arbiter:
"""A real arbiter that holds the screen, as a running lock does."""
arbiter = Arbiter(
"leetcode_guard",
RANK_SCREEN_LOCKER,
grab="global",
disable_vt=True,
runtime_dir=tmp_path,
)
arbiter.publish()
assert arbiter.acquire_holder()
return arbiter
class TestNoSelfBlame:
"""When the holder file is our own, the grab holder is somebody else."""
def test_own_claim_is_not_reported_as_the_holder(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""No "held by gatelock app", no own pid, and a pointer at X clients."""
arbiter = _own_arbiter(tmp_path)
config = LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True))
with (
caplog.at_level(logging.WARNING),
patch("gatelock._preempt.os.kill") as kill,
):
_preempt.log_grab_blocked(
25, arbiter=arbiter, config=config, preempted_pids=set()
)
arbiter.release()
assert "held by gatelock app" not in caplog.text
assert f"pid {os.getpid()}" not in caplog.text
assert "non-gatelock X client" in caplog.text
kill.assert_not_called()
def test_prompt_on_screen_is_named(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A detected credential prompt is named as present, not as a verdict."""
arbiter = _own_arbiter(tmp_path)
with caplog.at_level(logging.WARNING):
_preempt.log_grab_blocked(
25,
arbiter=arbiter,
config=LockConfig(),
preempted_pids=set(),
prompt_yield=MagicMock(last_reason="", prompt_names=("gcr-prompter",)),
)
arbiter.release()
assert "gcr-prompter" in caplog.text
def test_without_arbiter_names_the_prompt_too(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Arbitration disabled: same message, same prompt."""
with caplog.at_level(logging.WARNING):
_preempt.log_grab_blocked(
25,
arbiter=None,
config=LockConfig(),
preempted_pids=set(),
prompt_yield=MagicMock(last_reason="", prompt_names=("pinentry",)),
)
assert "pinentry" in caplog.text
assert "fullscreen game" in caplog.text
def test_another_gatelock_app_is_still_named(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Only the self-reference changed: a real foreign holder is still blamed."""
arbiter = MagicMock()
arbiter.claim.instance_id = "ours"
arbiter.describe_holder.return_value = MagicMock(
app="diet_guard", rank=100, pid=4242, started="t", instance_id="theirs"
)
with caplog.at_level(logging.WARNING):
_preempt.log_grab_blocked(
25, arbiter=arbiter, config=LockConfig(), preempted_pids=set()
)
assert "held by gatelock app 'diet_guard'" in caplog.text
class TestReasonInLogs:
"""Tk's own message rides along on every blocked-grab warning."""
def test_reason_with_no_gatelock_holder(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""The no-holder branch carries it."""
with caplog.at_level(logging.WARNING):
_preempt.log_grab_blocked(
25,
arbiter=None,
config=LockConfig(),
preempted_pids=set(),
prompt_yield=MagicMock(last_reason=REASON, prompt_names=()),
)
assert REASON in caplog.text
def test_reason_with_a_gatelock_holder(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""The holder branch carries it too."""
arbiter = MagicMock()
arbiter.claim.instance_id = "ours"
arbiter.describe_holder.return_value = MagicMock(
app="diet_guard", rank=100, pid=4242, started="t", instance_id="theirs"
)
with caplog.at_level(logging.WARNING):
_preempt.log_grab_blocked(
25,
arbiter=arbiter,
config=LockConfig(),
preempted_pids=set(),
prompt_yield=MagicMock(last_reason=REASON, prompt_names=()),
)
assert REASON in caplog.text
def test_window_passes_the_last_reason_and_prompts(
self, mock_root: MagicMock, caplog: pytest.LogCaptureFixture
) -> None:
"""``LockWindow`` hands the policy's state to the logger."""
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
stub = MagicMock(last_reason=REASON, prompt_names=("gcr-prompter",))
window._arming = replace(window._arming, prompt_yield=stub)
with caplog.at_level(logging.WARNING):
window._log_grab_blocked(25)
assert REASON in caplog.text
assert "gcr-prompter" in caplog.text
class TestArming:
"""The arming retry loop feeds the same policy and surfaces the error."""
def test_failures_feed_the_policy(self, mock_root: MagicMock) -> None:
"""Each failed attempt records Tk's message."""
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
window._acquire_global_grab(attempt=1)
assert window._arming.prompt_yield.last_reason == REASON
def test_local_fallback_warning_has_the_reason(
self, mock_root: MagicMock, caplog: pytest.LogCaptureFixture
) -> None:
"""``retry_ms=0``: the fallback says why the global grab failed."""
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
config = LockConfig(mode="hard", grab=GrabPolicy(retry_ms=0))
window, _ = make_window(mock_root, config=config)
with caplog.at_level(logging.WARNING):
window._acquire_global_grab(attempt=1)
assert REASON in caplog.text
def test_success_ends_a_yield_and_relifts_the_lock(
self, mock_root: MagicMock
) -> None:
"""The prompt closed and the retry won: the lock covers the screen again."""
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
policy = MagicMock(spec=PromptYield)
policy.grab_held.return_value = True
window._arming = replace(window._arming, prompt_yield=policy)
window._acquire_global_grab(attempt=3)
policy.grab_held.assert_called_once_with()
mock_root.lift.assert_called()
def test_window_shares_one_policy(self, mock_root: MagicMock) -> None:
"""Arming and recovery must time the same block."""
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
assert window.recovery._parts.prompt_yield is window._arming.prompt_yield

View File

@ -1,238 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for the non-blocking grab probe (:mod:`gatelock._grabprobe`).
Tk's ``grab -global`` blocks the whole event loop for ~1 s per FAILED attempt.
The probe asks X first, on a throwaway connection, so a lock whose grab is held
by someone else skips the Tk call instead of freezing its own UI. python-xlib
is replaced by an in-memory fake; ``scripts/verify_grab_responsive.py`` runs the
real thing against a real foreign grab.
"""
from __future__ import annotations
from dataclasses import replace
import logging
import tkinter as tk
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from gatelock._config import LockConfig
from gatelock._grabprobe import GrabProbe
from gatelock._prompts import PromptYield
from gatelock.tests.conftest import BOTH, build_loop, make_window
if TYPE_CHECKING:
from collections.abc import Callable
OK, TAKEN, NOT_VIEWABLE = 0, 1, 3
class XError(Exception):
"""Stands in for ``Xlib.error.XError``."""
class DisplayError(Exception):
"""Stands in for ``Xlib.error.DisplayError``."""
class FakeRoot:
"""Answers grab requests from a script and records the ungrabs."""
def __init__(self, keyboard: int, pointer: int) -> None:
self.statuses = {"keyboard": keyboard, "pointer": pointer}
self.asked: list[str] = []
def grab_keyboard(self, *_args: object) -> int:
self.asked.append("keyboard")
return self.statuses["keyboard"]
def grab_pointer(self, *_args: object) -> int:
self.asked.append("pointer")
if self.statuses["pointer"] < 0:
message = "BadWindow"
raise XError(message)
return self.statuses["pointer"]
class FakeDisplay:
"""The slice of ``Xlib.display.Display`` the probe uses."""
def __init__(self, root: FakeRoot) -> None:
self.root = root
self.ungrabbed: list[str] = []
self.closed = False
def screen(self) -> SimpleNamespace:
return SimpleNamespace(root=self.root)
def ungrab_keyboard(self, _time: int) -> None:
self.ungrabbed.append("keyboard")
def ungrab_pointer(self, _time: int) -> None:
self.ungrabbed.append("pointer")
def sync(self) -> None:
self.ungrabbed.append("sync")
def close(self) -> None:
self.closed = True
def loaded(factory: Callable[[], FakeDisplay]) -> tuple[SimpleNamespace, ...]:
"""What ``load_xlib("Xlib.display", "Xlib.X", "Xlib.error")`` returns."""
return (
SimpleNamespace(Display=factory),
SimpleNamespace(
GrabSuccess=OK,
AlreadyGrabbed=TAKEN,
GrabModeAsync=1,
CurrentTime=0,
NONE=0,
),
SimpleNamespace(
DisplayError=DisplayError,
XauthError=DisplayError,
ConnectionClosedError=DisplayError,
XError=XError,
),
)
def probe_with(display: FakeDisplay) -> tuple[GrabProbe, object]:
"""A probe whose X connection is ``display`` (patch is the second item)."""
patcher = patch(
"gatelock._grabprobe.load_xlib", return_value=loaded(lambda: display)
)
patcher.start()
return GrabProbe(), patcher
@pytest.fixture(autouse=True)
def _unpatch() -> object:
yield
patch.stopall()
class TestProbe:
"""What the probe reports, and that it leaves nothing grabbed."""
def test_free_grab_is_released_at_once(self) -> None:
"""Nobody holds it: report free, and give the probe's own grab back."""
display = FakeDisplay(FakeRoot(OK, OK))
probe, _ = probe_with(display)
assert probe.blocked() is None
assert display.root.asked == ["keyboard", "pointer"]
assert display.ungrabbed == ["keyboard", "pointer", "sync"]
assert display.closed
@pytest.mark.parametrize(
("keyboard", "pointer", "which"),
[(TAKEN, OK, "keyboard"), (OK, TAKEN, "pointer")],
)
def test_foreign_grab_is_reported(
self, keyboard: int, pointer: int, which: str
) -> None:
"""Either device held elsewhere blocks us; the reason names it."""
display = FakeDisplay(FakeRoot(keyboard, pointer))
probe, _ = probe_with(display)
reason = probe.blocked()
assert reason is not None
assert "another application has grab" in reason
assert which in reason
assert display.ungrabbed[-1] == "sync"
assert display.root.asked[-1] == which
def test_other_statuses_defer_to_tk(self) -> None:
"""Not viewable and the like are not a foreign holder: let Tk decide."""
display = FakeDisplay(FakeRoot(NOT_VIEWABLE, OK))
probe, _ = probe_with(display)
assert probe.blocked() is None
def test_check_raises_tcl_error_when_blocked(self) -> None:
"""``check`` speaks Tk's dialect so callers keep one except clause."""
probe, _ = probe_with(FakeDisplay(FakeRoot(TAKEN, OK)))
with pytest.raises(tk.TclError, match="another application has grab"):
probe.check()
def test_check_is_silent_when_free(self) -> None:
"""Free grab: nothing raised."""
probe, _ = probe_with(FakeDisplay(FakeRoot(OK, OK)))
probe.check()
class TestFailClosed:
"""When X cannot be asked, fall back to today's behaviour: call Tk."""
def test_missing_xlib(self, caplog: pytest.LogCaptureFixture) -> None:
"""No python-xlib: unknown, said once."""
probe = GrabProbe()
with (
patch("gatelock._grabprobe.load_xlib", return_value=None),
caplog.at_level(logging.WARNING),
):
assert probe.blocked() is None
assert probe.blocked() is None
assert caplog.text.count("python-xlib") == 1
def test_no_connection(self, caplog: pytest.LogCaptureFixture) -> None:
"""A connection that will not open: unknown, warned."""
def refuse() -> FakeDisplay:
message = "no display"
raise DisplayError(message)
probe = GrabProbe()
with (
patch("gatelock._grabprobe.load_xlib", return_value=loaded(refuse)),
caplog.at_level(logging.WARNING),
):
assert probe.blocked() is None
assert "X connection" in caplog.text
def test_request_error_still_closes(self, caplog: pytest.LogCaptureFixture) -> None:
"""An X error mid-probe: unknown, warned, connection dropped."""
display = FakeDisplay(FakeRoot(OK, -1))
probe, _ = probe_with(display)
with caplog.at_level(logging.WARNING):
assert probe.blocked() is None
assert "BadWindow" in caplog.text
assert display.closed
class TestWiring:
"""The probe gates every global-grab attempt, in arming and in recovery."""
def test_policy_exposes_check_grab(self) -> None:
"""``PromptYield.check_grab`` is the one entry point."""
probe = MagicMock()
PromptYield(grab_probe=probe).check_grab()
probe.check.assert_called_once_with()
def test_arming_skips_tk_when_blocked(self, mock_root: MagicMock) -> None:
"""A foreign holder: the 1 s Tk call is never made, the reason is kept."""
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
policy = PromptYield(grab_probe=MagicMock())
policy._grab_probe.check.side_effect = tk.TclError("held")
window._arming = replace(window._arming, prompt_yield=policy)
window._acquire_global_grab(attempt=1)
mock_root.grab_set_global.assert_not_called()
assert policy.last_reason == "held"
mock_root.after.assert_called()
def test_recovery_skips_tk_when_blocked(self, mock_root: MagicMock) -> None:
"""Same in the recovery tick."""
recovery, enumerator, _ = build_loop(mock_root, LockConfig(mode="hard"))
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
policy = PromptYield(grab_probe=MagicMock())
policy._grab_probe.check.side_effect = tk.TclError("held")
recovery._parts = recovery._parts.__class__(
**{**vars(recovery._parts), "prompt_yield": policy}
)
report = recovery.tick()
mock_root.grab_set_global.assert_not_called()
assert report.grab_reasserted is False
assert policy.last_reason == "held"

View File

@ -1,214 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for the yield-to-system-prompt policy (:mod:`gatelock._prompts`).
Incident 2026-11-11: the GNOME Keyring prompt held the X grab, the lock
covered it, and nothing was clickable. The policy: once the grab has been
blocked for a couple of seconds AND an allowlisted credential prompt is on
screen, raise that prompt above the lock and stop covering it again.
"""
from __future__ import annotations
import logging
import pytest
from gatelock._prompt_names import (
SYSTEM_PROMPT_CLASSES,
PromptWindow,
is_system_prompt,
)
from gatelock._prompts import YIELD_AFTER_S, PromptYield
from gatelock._prompts_x import XPromptProbe
GCR = PromptWindow(
window_id=11, toplevel_id=10, wm_class=("gcr-prompter", "Gcr-prompter")
)
class FakeClock:
"""A settable monotonic clock."""
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
class FakeProbe:
"""Answers ``raise_prompts`` from a script and counts the calls."""
def __init__(self, *prompts: PromptWindow) -> None:
self.prompts = prompts
self.calls = 0
def raise_prompts(self) -> tuple[PromptWindow, ...]:
self.calls += 1
return self.prompts
class TestAllowlist:
"""Only known credential prompts are ever yielded to."""
@pytest.mark.parametrize(
"wm_class",
[
("gcr-prompter", "Gcr-prompter"),
("pinentry-gtk-2", "Pinentry-gtk-2"),
("pinentry", "Pinentry"),
(
"polkit-gnome-authentication-agent-1",
"Polkit-gnome-authentication-agent-1",
),
("lxpolkit", "Lxpolkit"),
("ssh-askpass", "SSH-Askpass"),
("x11-ssh-askpass", "X11-ssh-askpass"),
("ksshaskpass", "ksshaskpass"),
("gnome-ssh-askpass", "Gnome-ssh-askpass"),
("whatever", "GCR-PROMPTER"),
("Pinentry-Qt", "x"),
],
)
def test_known_prompt_matches(self, wm_class: tuple[str, str]) -> None:
"""Instance or class, any case, with the documented globs."""
assert is_system_prompt(wm_class)
@pytest.mark.parametrize(
"wm_class",
[
("steam_game", "Steam_game"),
("firefox", "Firefox"),
("tk", "Tk"),
("pinentr", "x"),
("xpinentry", "x"),
("", ""),
],
)
def test_anything_else_does_not(self, wm_class: tuple[str, str]) -> None:
"""A fullscreen game must keep today's behaviour: lock stays on top."""
assert not is_system_prompt(wm_class)
def test_the_allowlist_is_one_named_constant(self) -> None:
"""Every pattern the policy approved is in ``SYSTEM_PROMPT_CLASSES``."""
assert set(SYSTEM_PROMPT_CLASSES) == {
"gcr-prompter",
"pinentry*",
"polkit-gnome-authentication-agent-1",
"lxpolkit",
"ssh-askpass*",
"x11-ssh-askpass",
"ksshaskpass",
"gnome-ssh-askpass",
}
def test_prompt_window_name(self) -> None:
"""The log names the window by its WM_CLASS instance."""
assert GCR.name == "gcr-prompter"
class TestYieldPolicy:
"""When the lock steps aside, and when it does not."""
def _yielder(
self, *prompts: PromptWindow
) -> tuple[PromptYield, FakeClock, FakeProbe]:
clock, probe = FakeClock(), FakeProbe(*prompts)
return PromptYield(probe, clock=clock), clock, probe
def test_default_probe_is_the_xlib_one(self) -> None:
"""Without an explicit probe the real X one is used."""
assert isinstance(PromptYield()._probe, XPromptProbe)
def test_idle_before_anything_is_blocked(self) -> None:
"""No failed grab, no look at the screen."""
policy, _, probe = self._yielder(GCR)
policy.settle()
assert not policy.yielding
assert probe.calls == 0
def test_waits_out_the_delay(self) -> None:
"""A grab blocked for less than the delay is a blip, not a prompt."""
policy, clock, probe = self._yielder(GCR)
policy.grab_blocked("another application has grab")
clock.now += YIELD_AFTER_S - 0.1
policy.settle()
assert not policy.yielding
assert probe.calls == 0
def test_yields_to_a_prompt_after_the_delay(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Blocked past the delay with a prompt on screen: yield, and say so."""
policy, clock, _ = self._yielder(GCR)
policy.grab_blocked("another application has grab")
clock.now += YIELD_AFTER_S + 0.1
with caplog.at_level(logging.WARNING):
policy.settle()
assert policy.yielding
assert policy.prompt_names == ("gcr-prompter",)
assert "gcr-prompter" in caplog.text
def test_keeps_raising_it_every_settle(self) -> None:
"""Each tick re-raises the prompt, last, over anything the tick lifted."""
policy, clock, probe = self._yielder(GCR)
policy.grab_blocked("x")
clock.now += 5
policy.settle()
policy.settle()
assert probe.calls == 2
assert policy.yielding
def test_no_prompt_means_no_yield(self) -> None:
"""A game holding the grab: the probe finds nothing, the lock stays on top."""
policy, clock, probe = self._yielder()
policy.grab_blocked("another application has grab")
clock.now += 30
policy.settle()
assert not policy.yielding
assert policy.prompt_names == ()
assert probe.calls == 1
def test_stops_when_the_prompt_goes_away(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Prompt unmapped but grab still blocked: stack the lock normally again."""
policy, clock, probe = self._yielder(GCR)
policy.grab_blocked("x")
clock.now += 5
policy.settle()
probe.prompts = ()
with caplog.at_level(logging.INFO):
policy.settle()
assert not policy.yielding
assert "gone" in caplog.text
def test_the_clock_starts_at_the_first_failure(self) -> None:
"""Later failures must not restart the delay."""
policy, clock, _ = self._yielder(GCR)
policy.grab_blocked("first")
clock.now += 1.5
policy.grab_blocked("second")
clock.now += 0.6
policy.settle()
assert policy.yielding
assert policy.last_reason == "second"
def test_grab_held_ends_the_yield_and_says_so(self) -> None:
"""Taking the grab reports whether the lock must now re-raise itself."""
policy, clock, probe = self._yielder(GCR)
policy.grab_blocked("x")
clock.now += 5
policy.settle()
assert policy.grab_held() is True
assert not policy.yielding
assert policy.prompt_names == ()
policy.settle()
assert probe.calls == 1 # nothing blocked any more: no more X work
def test_grab_held_when_never_yielding(self) -> None:
"""The common case: nothing to undo."""
policy, _, _ = self._yielder(GCR)
assert policy.grab_held() is False
policy.grab_blocked("x")
assert policy.grab_held() is False

View File

@ -1,209 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for finding and raising a credential prompt over python-xlib.
python-xlib is optional and there is no X server in the suite, so the library
is replaced by a small in-memory window tree. The real thing is exercised by
``scripts/verify_prompt_yield.py`` on a private Xvfb.
"""
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import patch
from gatelock._prompts_x import XPromptProbe
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from gatelock._prompt_names import PromptWindow
ABOVE = 0
ANY = 0
VIEWABLE = 2
class XError(Exception):
"""Stands in for ``Xlib.error.XError``."""
class DisplayError(Exception):
"""Stands in for ``Xlib.error.DisplayError``."""
class ClosedError(Exception):
"""Stands in for ``Xlib.error.ConnectionClosedError``."""
class NoAuthError(Exception):
"""Stands in for ``Xlib.error.XauthError``."""
class FakeWindow:
"""One node of the fake window tree."""
def __init__(
self,
display: FakeDisplay,
wid: int,
wm_class: tuple[str, str] | None = None,
*,
viewable: bool = True,
) -> None:
self.display = display
self.id = wid
self.wm_class = wm_class
self.viewable = viewable
self.parent: FakeWindow | None = None
self.children: list[FakeWindow] = []
self.client_list: list[int] | None = None
self.raised = False
self.broken = False
self.configure_error: XError | None = None
display.windows[wid] = self
def add(self, child: FakeWindow) -> FakeWindow:
child.parent = self
self.children.append(child)
return child
def get_wm_class(self) -> tuple[str, str] | None:
if self.broken:
message = "BadWindow"
raise XError(message)
return self.wm_class
def get_attributes(self) -> SimpleNamespace:
return SimpleNamespace(map_state=VIEWABLE if self.viewable else 0)
def query_tree(self) -> SimpleNamespace:
return SimpleNamespace(children=list(self.children), parent=self.parent)
def get_full_property(self, atom: str, _kind: int) -> SimpleNamespace | None:
if atom != "_NET_CLIENT_LIST" or self.client_list is None:
return None
return SimpleNamespace(value=self.client_list)
def configure(self, *, stack_mode: int, onerror: Callable[[XError], None]) -> None:
assert stack_mode == ABOVE
self.raised = True
if self.configure_error is not None:
onerror(self.configure_error)
class FakeDisplay:
"""The slice of ``Xlib.display.Display`` the probe uses."""
def __init__(self) -> None:
self.windows: dict[int, FakeWindow] = {}
self.root = FakeWindow(self, 1)
self.closed = False
self.synced = False
def screen(self) -> SimpleNamespace:
return SimpleNamespace(root=self.root)
def intern_atom(self, name: str) -> str:
return name
def create_resource_object(self, _kind: str, wid: int) -> FakeWindow:
return self.windows[wid]
def sync(self) -> None:
self.synced = True
def close(self) -> None:
self.closed = True
def modules(display_factory: Callable[[], FakeDisplay]) -> tuple[SimpleNamespace, ...]:
"""What ``load_xlib("Xlib.display", "Xlib.X", "Xlib.error")`` returns."""
return (
SimpleNamespace(Display=display_factory),
SimpleNamespace(Above=ABOVE, AnyPropertyType=ANY, IsViewable=VIEWABLE),
SimpleNamespace(
DisplayError=DisplayError,
XauthError=NoAuthError,
ConnectionClosedError=ClosedError,
XError=XError,
),
)
@contextmanager
def probe_over(display: FakeDisplay) -> Iterator[XPromptProbe]:
"""A probe whose X connection is ``display``."""
loaded = modules(lambda: display)
with patch("gatelock._prompts_x.load_xlib", return_value=loaded):
yield XPromptProbe()
def scan(display: FakeDisplay) -> tuple[PromptWindow, ...]:
"""One ``raise_prompts()`` over ``display``."""
with probe_over(display) as probe:
return probe.raise_prompts()
class TestFinding:
"""Which windows count, and what gets raised."""
def test_framed_client_raises_the_frame(self) -> None:
"""i3 reparents: the class is on the client, the frame is the top-level."""
display = FakeDisplay()
frame = display.root.add(FakeWindow(display, 10, ("i3-frame", "i3-frame")))
client = frame.add(FakeWindow(display, 11, ("gcr-prompter", "Gcr-prompter")))
found = scan(display)
assert [(p.window_id, p.toplevel_id) for p in found] == [(11, 10)]
assert frame.raised
assert not client.raised
assert display.synced
assert display.closed
def test_unframed_client_is_its_own_toplevel(self) -> None:
"""Without a window manager the prompt is a direct root child."""
display = FakeDisplay()
prompt = display.root.add(
FakeWindow(display, 20, ("pinentry-gtk-2", "Pinentry"))
)
found = scan(display)
assert [(p.window_id, p.toplevel_id) for p in found] == [(20, 20)]
assert prompt.raised
def test_net_client_list_is_used_and_deduplicated(self) -> None:
"""A client listed by the WM and also found in the tree is raised once."""
display = FakeDisplay()
frame = display.root.add(FakeWindow(display, 10))
frame.add(FakeWindow(display, 11, ("gcr-prompter", "x")))
display.root.client_list = [11, 11]
found = scan(display)
assert [p.window_id for p in found] == [11]
def test_net_client_list_alone_finds_a_deeper_window(self) -> None:
"""A client the tree walk would not reach is still found via the WM list."""
display = FakeDisplay()
outer = display.root.add(FakeWindow(display, 10))
mid = outer.add(FakeWindow(display, 11))
deep = mid.add(FakeWindow(display, 12, ("lxpolkit", "Lxpolkit")))
display.root.client_list = [12]
found = scan(display)
assert [(p.window_id, p.toplevel_id) for p in found] == [(12, 10)]
assert outer.raised
assert not deep.raised
def test_unmapped_prompt_is_ignored(self) -> None:
"""A prompt on another workspace is not on screen: leave the lock alone."""
display = FakeDisplay()
display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x"), viewable=False))
assert scan(display) == ()
def test_other_windows_are_ignored(self) -> None:
"""No class, or a class off the allowlist, is not a prompt."""
display = FakeDisplay()
display.root.add(FakeWindow(display, 10, ("steam_game", "Steam")))
display.root.add(FakeWindow(display, 11, None))
found = scan(display)
assert found == ()
assert not display.windows[10].raised
assert display.closed

View File

@ -1,98 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Failure handling of :class:`XPromptProbe`: every error fails closed.
Split from ``test_prompts_x.py`` (250-line cap), whose in-memory window tree
and python-xlib stand-ins it reuses.
"""
from __future__ import annotations
import logging
from unittest.mock import patch
import pytest
from gatelock._prompts_x import XPromptProbe
from gatelock.tests.test_prompts_x import (
ClosedError,
DisplayError,
FakeDisplay,
FakeWindow,
NoAuthError,
XError,
modules,
probe_over,
)
class TestFailures:
"""Anything going wrong fails closed: no prompt found, lock stays on top."""
def test_missing_xlib_is_reported_once(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""No python-xlib: nothing to do, said once."""
probe = XPromptProbe()
with (
patch("gatelock._prompts_x.load_xlib", return_value=None),
caplog.at_level(logging.WARNING),
):
assert probe.raise_prompts() == ()
assert probe.raise_prompts() == ()
assert caplog.text.count("python-xlib") == 1
@pytest.mark.parametrize(
"error",
[OSError("gone"), DisplayError("no display"), ClosedError(), NoAuthError()],
)
def test_no_connection(
self, error: Exception, caplog: pytest.LogCaptureFixture
) -> None:
"""An X connection that will not open is a warning, not a crash."""
def refuse() -> FakeDisplay:
raise error
probe = XPromptProbe()
with (
patch("gatelock._prompts_x.load_xlib", return_value=modules(refuse)),
caplog.at_level(logging.WARNING),
):
assert probe.raise_prompts() == ()
assert "X connection" in caplog.text
def test_window_vanishing_mid_scan(self, caplog: pytest.LogCaptureFixture) -> None:
"""BadWindow from a window that went away aborts this scan, once-warned."""
display = FakeDisplay()
display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x"))).broken = True
with probe_over(display) as probe, caplog.at_level(logging.WARNING):
assert probe.raise_prompts() == ()
assert probe.raise_prompts() == ()
assert caplog.text.count("BadWindow") == 1
assert display.closed
def test_a_good_scan_rearms_the_warning(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""After a success the same failure is news again."""
display = FakeDisplay()
window = display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x")))
with probe_over(display) as probe, caplog.at_level(logging.WARNING):
window.broken = True
probe.raise_prompts()
window.broken = False
assert probe.raise_prompts()
window.broken = True
probe.raise_prompts()
assert caplog.text.count("BadWindow") == 2
def test_raise_refused_is_not_a_yield(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""If the X server rejects the restack, claim nothing."""
display = FakeDisplay()
prompt = display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x")))
prompt.configure_error = XError("BadMatch")
with probe_over(display) as probe, caplog.at_level(logging.WARNING):
assert probe.raise_prompts() == ()
assert "BadMatch" in caplog.text

View File

@ -64,17 +64,3 @@ class TestStaticInvariant:
assert "self._sink.put" in loop_body assert "self._sink.put" in loop_body
for forbidden in ("geometry(", "deiconify(", "lift(", "grab_"): for forbidden in ("geometry(", "deiconify(", "lift(", "grab_"):
assert forbidden not in loop_body assert forbidden not in loop_body
def test_stepback_is_the_only_release_path(self) -> None:
"""Only the step-back module may release the grab.
It triggers on a strictly stronger, live, at-least-as-firm claim and
nowhere else; any other module growing a release call is a bypass.
"""
callers = {
path.name
for path in SOURCE_ROOT.glob("*.py")
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
if isinstance(node, ast.Attribute) and node.attr == "grab_release"
}
assert callers == {"_stepback.py"}

View File

@ -1,43 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""An output that comes up while the recovery loop is stopped is covered on restart.
leetcode-guard's study mode stops the recovery loop and the change detector on
purpose. On 2026-11-11 the 4K output (DP-2) only got a mode 3m20s after the
lock armed -- long after study mode had stopped both -- so the backdrop stayed
6400x1440 and no surface was built for it. That is the stopped loop doing what
it was told, not a detection bug: this pins that the FIRST tick after
``start()`` sizes the backdrop to the new screen and builds the missing surface.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from gatelock._config import LockConfig
from gatelock._outputs import OutputScan
from gatelock.tests.conftest import BOTH, HDMI, build_loop
if TYPE_CHECKING:
from unittest.mock import MagicMock
HDMI_ONLY = OutputScan((HDMI,), "randr", ok=True)
def test_output_that_appeared_while_stopped_is_covered_on_restart(
mock_root: MagicMock,
) -> None:
"""Stop, grow the screen, start: one tick later the new output is locked."""
recovery, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="hard"))
enumerator.scan.return_value = HDMI_ONLY
mock_root.winfo_screenheight.return_value = 1440
recovery.tick()
assert surfaces.names() == {"HDMI-0"}
recovery.stop()
enumerator.scan.return_value = BOTH # DP-0 is the 4K output here
mock_root.winfo_screenwidth.return_value = 6400
mock_root.winfo_screenheight.return_value = 2160
recovery.tick() # study mode off: the first tick after start() is this one
assert surfaces.names() == {"DP-0", "HDMI-0"}
mock_root.geometry.assert_called_with("6400x2160+0+0")

View File

@ -1,204 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for how a recovery tick treats a blocked grab and a waiting prompt.
The tick must (a) say WHY the re-grab failed, (b) feed the yield policy,
(c) stop lifting the lock over a prompt it is yielding to, and (d) re-raise
the prompt last, so nothing the tick lifted covers it.
"""
from __future__ import annotations
from dataclasses import replace
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from gatelock._config import LockConfig
from gatelock._prompts import PromptYield
from gatelock.tests.conftest import BOTH, build_loop
if TYPE_CHECKING:
import pytest
from gatelock._recovery import RecoveryLoop
from gatelock._surfaces import SurfaceSet
REASON = "grab failed: another application has grab"
def _with_yield(recovery: RecoveryLoop, policy: MagicMock) -> MagicMock:
"""Swap the loop's prompt policy for a mock."""
recovery._parts = replace(recovery._parts, prompt_yield=policy)
return policy
def _policy(*, yielding: bool = False, was_yielding: bool = False) -> MagicMock:
policy = MagicMock(spec=PromptYield)
policy.yielding = yielding
policy.grab_held.return_value = was_yielding
return policy
def _tick_lifts(surfaces: SurfaceSet) -> int:
"""How many lift() calls the surfaces have seen."""
return sum(s.window.lift.call_count for s in surfaces._surfaces.values())
class TestBlockedGrab:
"""A failed re-grab is explained and handed to the yield policy."""
def test_failure_text_is_logged_once(
self,
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
mock_root: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Tk's own message names the cause; the same cause is not repeated."""
recovery, enumerator, _ = loop
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
with caplog.at_level(logging.WARNING):
recovery.tick()
recovery.tick()
assert caplog.text.count(REASON) == 1
def test_a_different_cause_is_logged_again(
self,
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
mock_root: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
""" "Window not viewable" after "another application has grab" is news."""
recovery, enumerator, _ = loop
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
with caplog.at_level(logging.WARNING):
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
recovery.tick()
mock_root.grab_set_global.side_effect = tk.TclError("not viewable")
recovery.tick()
assert "not viewable" in caplog.text
def test_failure_feeds_the_policy(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""The reason reaches the policy that times the block."""
recovery, enumerator, _ = loop
policy = _with_yield(recovery, _policy())
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
recovery.tick()
policy.grab_blocked.assert_called_once_with(REASON)
class TestYielding:
"""While a prompt is being yielded to, the lock stops covering it."""
def test_no_lift_while_yielding(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""enforce() still places and maps surfaces, but does not raise them."""
recovery, enumerator, surfaces = loop
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
recovery.tick() # creates the surfaces (a create lifts once)
_with_yield(recovery, _policy(yielding=True))
before = _tick_lifts(surfaces)
recovery.tick()
assert _tick_lifts(surfaces) == before
def test_lifts_normally_when_not_yielding(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""Anything that is not a known prompt keeps today's behaviour."""
recovery, enumerator, surfaces = loop
enumerator.scan.return_value = BOTH
recovery.tick()
before = _tick_lifts(surfaces)
_with_yield(recovery, _policy(yielding=False))
recovery.tick()
assert _tick_lifts(surfaces) > before
def test_settles_last(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""The prompt is re-raised after everything else the tick did."""
recovery, enumerator, _ = loop
enumerator.scan.return_value = BOTH
policy = _with_yield(recovery, _policy(yielding=True))
seen: list[str] = []
def grab_current() -> MagicMock:
seen.append("grab")
return mock_root
def settle() -> None:
seen.append("settle")
policy.settle.side_effect = settle
mock_root.grab_current.side_effect = grab_current
recovery.tick()
assert seen == ["grab", "settle"]
def test_grab_retaken_lifts_the_lock_again(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""The prompt closed, the grab came back: the lock covers everything again."""
recovery, enumerator, surfaces = loop
enumerator.scan.return_value = BOTH
recovery.tick()
_with_yield(recovery, _policy(yielding=True, was_yielding=True))
before = _tick_lifts(surfaces)
mock_root.grab_current.return_value = None # lost, then re-taken
recovery.tick()
assert _tick_lifts(surfaces) > before
mock_root.lift.assert_called()
def test_grab_held_is_reported_when_nothing_was_lost(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""Holding the grab already (the arming loop took it) also ends the yield."""
recovery, enumerator, _ = loop
policy = _with_yield(recovery, _policy())
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = mock_root
recovery.tick()
policy.grab_held.assert_called_once_with()
def test_surface_set_enforce_without_lift(mock_root: MagicMock) -> None:
"""``enforce(lift=False)`` corrects drift but never restacks."""
recovery, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="hard"))
enumerator.scan.return_value = BOTH
recovery.tick()
window = surfaces._surfaces["DP-0"].window
window.withdraw() # unmapped: must be remapped even when not lifting
window.lift.reset_mock()
assert surfaces.enforce(lift=False) == ("DP-0",)
window.deiconify.assert_called()
window.lift.assert_not_called()
def test_holds_grab_is_the_loops_own_question(
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""The public probe embedders and verification harnesses use."""
recovery, _, _ = loop
mock_root.grab_current.return_value = mock_root
assert recovery.holds_grab() is True
mock_root.grab_current.return_value = None
assert recovery.holds_grab() is False
def test_raise_all_leaves_the_root_alone_without_a_backdrop(
mock_root: MagicMock,
) -> None:
"""A soft, grab-less lock has no backdrop root to lift."""
config = LockConfig(mode="soft")
_, _, surfaces = build_loop(mock_root, config)
surfaces.raise_all()
mock_root.lift.assert_not_called()

View File

@ -1,120 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for a weaker armed lock stepping back for a stronger one.
The trigger is the whole safety argument, so most tests are about what must NOT
trigger it: equal rank, lower rank, a dead claim, a stronger-ranked app that
locks more weakly, a loop the app stopped on purpose. See ``_stepback.py``.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from gatelock._arbiter import RANK_DIET_GUARD, RANK_SCREEN_LOCKER
from gatelock._config import LockConfig
from gatelock._window import LockWindow
from gatelock.tests.conftest import make_arbiter
if TYPE_CHECKING:
from pathlib import Path
from gatelock._arbiter import Arbiter
RANK_MID = 150
def _tick(window: LockWindow) -> None:
window._stepback._tick()
class TestTrigger:
"""Only a strictly stronger, live, at-least-as-firm claim makes it step back."""
def test_stronger_live_claim_suspends(
self,
armed: tuple[LockWindow, Arbiter],
mock_root: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Hide, release the grab, drop the holder; the claim stays published."""
window, arbiter = armed
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
windows = [s.window for s in window.surfaces._surfaces.values()]
with caplog.at_level(logging.WARNING):
_tick(window)
assert window._stepback.suspended
assert "steps back" in caplog.text
assert "screen_locker" in caplog.text
mock_root.grab_release.assert_called_once_with()
mock_root.withdraw.assert_called_once_with()
assert windows
assert all(w.withdraw.called for w in windows)
assert not window.recovery.running
assert not arbiter.holds_screen
assert arbiter.claim.grab_held is False
assert arbiter.claim.instance_id in {
c.instance_id for c in strong.live_claims()
}
strong.release()
@pytest.mark.parametrize(
("app", "rank"),
[("twin", RANK_MID), ("diet_guard", RANK_DIET_GUARD)],
)
def test_equal_or_lower_rank_never_does(
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path, app: str, rank: int
) -> None:
"""Rank must be strictly higher."""
window, _ = armed
other = make_arbiter(tmp_path, app, rank)
_tick(window)
assert not window._stepback.suspended
other.release()
def test_dead_claim_never_does(
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
) -> None:
"""A stale claim file (its owner died, flock gone) is no reason."""
window, _ = armed
ghost = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
assert ghost._claim_handle is not None
ghost._claim_handle.close() # the kernel's view of a dead owner
_tick(window)
assert not window._stepback.suspended
def test_a_weaker_locking_app_never_does(
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
) -> None:
"""Rank must never weaken the lock: no grab, no VT -> no step back."""
window, _ = armed
soft = make_arbiter(
tmp_path, "alarm", RANK_SCREEN_LOCKER, grab="none", vt=False
)
_tick(window)
assert not window._stepback.suspended
soft.release()
def test_a_loop_the_app_stopped_is_left_alone(
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
) -> None:
"""Study mode stops recovery on purpose; that is not ours to suspend."""
window, _ = armed
window.recovery.stop()
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
_tick(window)
assert not window._stepback.suspended
strong.release()
def test_without_an_arbiter_it_is_inert(self, mock_root: MagicMock) -> None:
"""No arbitration, nothing to step back for."""
window = LockWindow(mock_root, LockConfig(mode="hard"), MagicMock())
window.setup()
window.grab_input()
mock_root.after.reset_mock()
window._stepback.start()
assert window._stepback._job is None

View File

@ -1,109 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for the small pieces step-back is built from.
The startup grace in ``wait_for_turn``, ``Arbiter.drop_holder`` and
``SurfaceSet.set_visible``. The controller itself is in ``test_stepback.py``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, call, patch
from gatelock import RANK_DIET_GUARD, RANK_SCREEN_LOCKER, Arbiter
from gatelock._config import LockConfig
from gatelock._queue import STARTUP_GRACE_SECONDS, Timebase, wait_for_turn
from gatelock.tests.conftest import DP0, build_loop, make_arbiter
if TYPE_CHECKING:
from pathlib import Path
class TestStartupGrace:
"""Simultaneous starters must see each other before anyone arms."""
def test_the_real_constant_is_set(self) -> None:
"""Not zero in production (the test fixture zeroes the patched copy)."""
assert STARTUP_GRACE_SECONDS == 1.5
def test_waits_before_the_first_check_and_sees_a_late_publisher(
self, tmp_path: Path
) -> None:
"""A stronger claim published DURING the grace is queued behind."""
weak = make_arbiter(tmp_path, "diet_guard", RANK_DIET_GUARD)
strong: list[Arbiter] = []
slept: list[float] = []
def sleep(seconds: float) -> None:
slept.append(seconds)
if not strong:
strong.append(
make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
)
elif len(slept) > 2:
strong[0].release()
clock = [0.0]
base = Timebase(sleep=sleep, now=lambda: clock[0])
with patch("gatelock._queue.STARTUP_GRACE_SECONDS", 1.5):
result = wait_for_turn(weak, timebase=base, poll=0.5)
assert slept[0] == 1.5
assert result.queued
assert result.blocked_by == ("screen_locker",)
def test_zero_grace_does_not_sleep(self, tmp_path: Path) -> None:
"""The fixture's zero skips the sleep entirely."""
slept: list[float] = []
base = Timebase(sleep=slept.append, now=lambda: 0.0)
wait_for_turn(
make_arbiter(tmp_path, "diet_guard", RANK_DIET_GUARD), timebase=base
)
assert slept == []
class TestDropHolder:
"""Dropping the holder lock leaves the claim, and is idempotent."""
def test_drop_keeps_the_claim_and_frees_the_screen(self, tmp_path: Path) -> None:
"""Another app can take the holder lock; ours is still queued-behind."""
ours = make_arbiter(tmp_path, "diet_guard", RANK_DIET_GUARD)
other = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
assert ours.acquire_holder()
assert not other.acquire_holder()
ours.drop_holder()
ours.drop_holder()
assert not ours.holds_screen
assert ours.claim.instance_id in {c.instance_id for c in other.live_claims()}
assert other.acquire_holder()
class TestSetVisible:
"""Hide, and show in creation order."""
def test_hide_then_show(self, mock_root: MagicMock) -> None:
"""Withdrawn windows come back override-redirect BEFORE they map."""
_, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="hard"))
enumerator.scan.return_value = MagicMock(live=(DP0,))
surfaces.apply(enumerator.scan.return_value)
window = surfaces._surfaces["DP-0"].window
window.reset_mock()
surfaces.set_visible(visible=False)
window.withdraw.assert_called_once_with()
window.reset_mock()
surfaces.set_visible(visible=True)
names = [c[0] for c in window.method_calls]
assert names.index("overrideredirect") < names.index("deiconify")
assert names[-1] == "lift"
assert call.overrideredirect(boolean=True) in window.method_calls
def test_soft_mode_is_not_forced_override_redirect(
self, mock_root: MagicMock
) -> None:
"""A managed lock must stay managed."""
_, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="soft"))
enumerator.scan.return_value = MagicMock(live=(DP0,))
surfaces.apply(enumerator.scan.return_value)
window = surfaces._surfaces["DP-0"].window
window.reset_mock()
surfaces.set_visible(visible=True)
window.overrideredirect.assert_not_called()

View File

@ -1,165 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Re-arming after a step-back, and the failure paths around it.
Split from ``test_stepback.py`` (250-line cap); the ``armed`` fixture is in
``conftest.py``.
"""
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from gatelock import _stepback
from gatelock._arbiter import RANK_SCREEN_LOCKER
from gatelock._config import GrabPolicy, LockConfig
from gatelock._window import LockWindow
from gatelock.tests.conftest import make_arbiter
if TYPE_CHECKING:
from pathlib import Path
import pytest
from gatelock._arbiter import Arbiter
RANK_MID = 150
def _tick(window: LockWindow) -> None:
window._stepback._tick()
class TestRearm:
"""It comes back, fully, once nothing qualifying is live."""
def _suspended(
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
) -> tuple[LockWindow, Arbiter, Arbiter]:
window, arbiter = armed
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
_tick(window)
assert window._stepback.suspended
return window, arbiter, strong
def test_stays_hidden_while_the_stronger_is_live(
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
) -> None:
"""Windowless until the stronger one is gone."""
window, _, strong = self._suspended(armed, tmp_path)
_tick(window)
assert window._stepback.suspended
strong.release()
def test_rearms_fully_when_it_is_gone(
self,
armed: tuple[LockWindow, Arbiter],
mock_root: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""VT, backdrop, surfaces, grab, holder and the loops all come back."""
window, arbiter, strong = self._suspended(armed, tmp_path)
strong.release()
mock_root.grab_set_global.reset_mock()
with (
patch("gatelock._stepback.disable_vt_switching") as vt,
caplog.at_level(logging.WARNING),
):
_tick(window)
assert not window._stepback.suspended
assert "re-arms" in caplog.text
vt.assert_called_once_with()
mock_root.deiconify.assert_called_once_with()
mock_root.grab_set_global.assert_called_once_with()
assert arbiter.holds_screen
assert window.recovery.running
for surface in window.surfaces._surfaces.values():
surface.window.deiconify.assert_called()
def test_without_vt_policy_it_leaves_vt_alone(
self, mock_root: MagicMock, tmp_path: Path
) -> None:
"""A lock that never disabled VT does not start now."""
arbiter = make_arbiter(tmp_path, "leetcode_guard", RANK_MID, vt=False)
grab = GrabPolicy(disable_vt=False)
config = LockConfig(mode="hard", grab=grab, rank=RANK_MID)
window = LockWindow(mock_root, config, MagicMock(), arbiter=arbiter)
window.setup()
window.grab_input()
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER, vt=False)
_tick(window)
strong.release()
with patch("gatelock._stepback.disable_vt_switching") as vt:
_tick(window)
assert not window._stepback.suspended
vt.assert_not_called()
window.close()
def test_gives_up_waiting_after_the_deadline(
self,
armed: tuple[LockWindow, Arbiter],
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A stuck stronger claim cannot keep the machine unlocked forever."""
window, _, strong = self._suspended(armed, tmp_path)
step = window._stepback
step._since = step._clock() - _stepback.QUEUE_DEADLINE_SECONDS - 1
with caplog.at_level(logging.ERROR):
_tick(window)
assert not step.suspended
assert "gave up" in caplog.text
strong.release()
class TestRobustness:
"""A failing step must not leave the lock half-down."""
def test_tk_errors_are_logged_and_the_rest_still_happens(
self,
armed: tuple[LockWindow, Arbiter],
mock_root: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failed grab release is logged; hiding and the claim still follow."""
window, arbiter = armed
mock_root.grab_release.side_effect = tk.TclError("no grab")
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
with caplog.at_level(logging.WARNING):
_tick(window)
assert "grab release failed" in caplog.text
assert window._stepback.suspended
assert not arbiter.holds_screen
strong.release()
def test_a_raise_does_not_end_the_checking(
self, armed: tuple[LockWindow, Arbiter], caplog: pytest.LogCaptureFixture
) -> None:
"""The reschedule is in ``finally``; one bad check is just logged."""
window, _ = armed
with (
patch("gatelock._stepback.outranking", side_effect=ValueError("boom")),
caplog.at_level(logging.ERROR),
):
_tick(window)
assert "step-back check raised" in caplog.text
assert window._stepback._job is not None
def test_close_stops_the_checking(self, armed: tuple[LockWindow, Arbiter]) -> None:
"""No timer outlives the window."""
window, _ = armed
window.close()
assert window._stepback._job is None
def test_stop_survives_a_dead_root(
self, armed: tuple[LockWindow, Arbiter], mock_root: MagicMock
) -> None:
"""Cancelling on a destroyed root is logged, not raised."""
window, _ = armed
mock_root.after_cancel.side_effect = tk.TclError("gone")
window._stepback.stop()
assert window._stepback._job is None

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gatelock" name = "gatelock"
version = "0.13.0" version = "0.12.0"
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps" description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [] # pure stdlib — tkinter is bundled with Python dependencies = [] # pure stdlib — tkinter is bundled with Python

View File

@ -1,101 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki
# pylint: disable=missing-function-docstring
"""One stand-in locker for ``verify_all_lockers.py``: the real startup sequence.
Mirrors what leetcode_guard / screen_locker / diet_guard / book_guard /
focus_guard do: ``Arbiter`` -> ``publish`` -> ``wait_for_turn`` (windowless) ->
``acquire_holder`` -> ``LockWindow`` -> ``setup`` -> ``grab_input`` -> mainloop,
with the real app name and rank. Its only UI is one button; clicking it ends
the lock, like a solve does. Everything is reported on stdout, one line each:
``ARMED``, ``BTN x y`` (button centre, root coordinates), ``GRAB 0|1``,
``CLICKED``.
Usage: ``locker_child.py <app> <rank> <queue_poll_s>``
"""
from __future__ import annotations
import sys
import tkinter as tk
from gatelock import (
Arbiter,
GateRoot,
LockConfig,
LockWindow,
wait_for_turn,
)
REPORT_MS = 250
class _Child:
"""The hooks object, as each real locker is."""
def __init__(self, app: str, rank: int, poll: float) -> None:
self.app = app
self.button: tk.Button | None = None
config = LockConfig(mode="hard", app_name=app, rank=rank)
arbiter = Arbiter(
app,
rank,
grab=config.resolved_grab(),
disable_vt=False,
)
arbiter.publish()
wait_for_turn(arbiter, poll=poll)
arbiter.acquire_holder()
self.root = GateRoot(className=app)
self.lock = LockWindow(self.root, config, self, arbiter=arbiter)
self.lock.setup()
self.say("ARMED")
self.lock.grab_input()
self.root.after(REPORT_MS, self.report)
def say(self, text: str) -> None:
sys.stdout.write(f"{text}\n")
sys.stdout.flush()
def report(self) -> None:
self.say(f"GRAB {int(self.lock.recovery.holds_grab())}")
button = self.button
if button is not None and button.winfo_ismapped():
x = button.winfo_rootx() + button.winfo_width() // 2
y = button.winfo_rooty() + button.winfo_height() // 2
self.say(f"BTN {x} {y}")
self.root.after(REPORT_MS, self.report)
def clicked(self) -> None:
self.say("CLICKED")
self.lock.close()
# -- LockWindowHooks ---------------------------------------------------
def build_surface(self, parent: tk.Misc, surface: object) -> None:
del surface
self.button = tk.Button(parent, text=self.app, command=self.clicked)
self.button.place(relx=0.5, rely=0.5, width=240, height=90, anchor="center")
def teardown_surface(self, surface: object) -> None:
del surface
self.button = None
def on_focus_ready(self, surface: object) -> None:
del surface
def on_callback_error(self) -> None:
self.say("ERROR callback")
def on_close(self) -> None:
return
def main(argv: list[str]) -> int:
"""Arm one locker and run its mainloop."""
child = _Child(argv[0], int(argv[1]), float(argv[2]))
child.lock.run()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -1,73 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki
# pylint: disable=import-error
"""A second X client that holds the keyboard+pointer grab, like a credential prompt.
Started by ``verify_prompt_yield.py`` on a private display. Maps one window with
the given ``WM_CLASS`` (optionally inside an override-redirect frame, the way a
window manager reparents clients), takes ``XGrabKeyboard`` + ``XGrabPointer``,
prints ``READY``, then waits on stdin: ``release`` ungrabs and unmaps (prints
``RELEASED``), EOF exits.
Usage: ``prompt_grab_helper.py <wm_class> [--framed]``
"""
from __future__ import annotations
import sys
import time
from Xlib import X, display
OWNER_EVENTS = True
VIEWABLE_TRIES = 50
def main(argv: list[str]) -> int:
"""Grab, announce, wait for ``release``; returns a process exit status."""
wm_class = argv[0]
xdisplay = display.Display()
root = xdisplay.screen().root
depth = xdisplay.screen().root_depth
top = window = root.create_window(
10, 10, 400, 200, 0, depth, X.InputOutput, X.CopyFromParent
)
if "--framed" in argv:
top = root.create_window(
10, 10, 400, 200, 0, depth, X.InputOutput, X.CopyFromParent,
override_redirect=1,
) # fmt: skip
window.reparent(top, 0, 0)
window.set_wm_class(wm_class, wm_class.capitalize())
window.map()
top.map()
xdisplay.sync()
# A window manager maps asynchronously; grabbing a not-yet-viewable window
# fails with GrabNotViewable.
for _ in range(VIEWABLE_TRIES):
if window.get_attributes().map_state == X.IsViewable:
break
time.sleep(0.1)
modes = (X.GrabModeAsync, X.GrabModeAsync)
keyboard = window.grab_keyboard(OWNER_EVENTS, *modes, X.CurrentTime)
pointer = window.grab_pointer(
OWNER_EVENTS, X.ButtonPressMask, *modes, X.NONE, X.NONE, X.CurrentTime
)
if keyboard or pointer:
sys.stdout.write(f"grab failed keyboard={keyboard} pointer={pointer}\n")
return 2
sys.stdout.write("READY\n")
sys.stdout.flush()
for line in sys.stdin:
if line.strip() == "release":
xdisplay.ungrab_keyboard(X.CurrentTime)
xdisplay.ungrab_pointer(X.CurrentTime)
top.unmap()
xdisplay.sync()
sys.stdout.write("RELEASED\n")
sys.stdout.flush()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -1,249 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki
# pylint: disable=missing-function-docstring,consider-using-with,import-error
r"""All lockers at once, on a real X server: one lock at a time, each clickable.
Starts the five lockers that fire together at login (real app names and ranks,
the real ``Arbiter`` + ``wait_for_turn`` startup, one process each, a private
``GATELOCK_RUNTIME_DIR``) with a ``gcr-prompter`` grab helper started first, as
on 2026-11-11. Asserts: never two locks on screen; once the prompt lets go the
lone lock holds the grab; an XTEST click on its button fires its callback; when
it closes the next one comes up the same way. The queue poll is 0.5 s (real:
2 s) to fit the 60 s cap. Argument: launch order -- mixed, strong-first,
weak-first, or late-strong (screen_locker starts 3 s after the rest, once a
weaker lock holds the grab). Xvfb only (XTEST); refuses ``:0``::
PYTHONPATH=. xvfb-run -a -s "-screen 0 1600x1200x24" \
python3 scripts/verify_all_lockers.py
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import select
import subprocess
import sys
import tempfile
import time
from typing import TYPE_CHECKING, Final
from Xlib import X, display, error
from Xlib.ext import xtest
if TYPE_CHECKING:
from collections.abc import Callable
HERE: Final = Path(__file__).parent
LOCKERS: Final = { # app -> rank, as in each locker's own constants
"screen_locker": 200,
"leetcode_guard": 150,
"book_guard": 125,
"focus_guard": 120,
"diet_guard": 100,
}
_RANKED: Final = tuple(sorted(LOCKERS, key=lambda a: -LOCKERS[a]))
ORDERS: Final = { # launch order: how the five processes race to publish
"mixed": tuple(_RANKED[i] for i in (4, 1, 3, 0, 2)),
"strong-first": _RANKED,
"weak-first": _RANKED[::-1],
"late-strong": tuple(sorted(set(LOCKERS) - {"screen_locker"})),
}
LATE: Final = {"late-strong": ("screen_locker", 3.0, 2.0)} # app, start s, hold s
POLL_S, STEP_S, PROMPT_HOLD_S, SHOW_S, GRAB_S, CLICK_S = 0.5, 0.1, 6.0, 12.0, 8.0, 5.0
SETTLE_S = 1.0
def _say(message: str) -> None:
sys.stdout.write(f"{message}\n")
class _Fleet:
"""The child processes and what they have reported so far."""
def __init__(self, env: dict[str, str], launch: tuple[str, ...]) -> None:
self.env = env
self.procs: dict[str, subprocess.Popen[bytes]] = {}
self.buffers = dict.fromkeys(LOCKERS, b"")
self.grab = dict.fromkeys(LOCKERS, 0)
self.button: dict[str, tuple[int, int]] = {}
self.clicked: set[str] = set()
self.armed: set[str] = set()
for app in launch:
self.launch(app)
def launch(self, app: str) -> None:
args = [sys.executable, str(HERE / "locker_child.py"), app]
args += [str(LOCKERS[app]), str(POLL_S)]
self.procs[app] = subprocess.Popen(args, stdout=subprocess.PIPE, env=self.env)
def drain(self) -> None:
fds = {p.stdout.fileno(): a for a, p in self.procs.items() if p.stdout}
for fd in select.select(list(fds), [], [], 0)[0]:
app = fds[fd]
self.buffers[app] += os.read(fd, 65536)
*lines, self.buffers[app] = self.buffers[app].split(b"\n")
for raw in lines:
self.note(app, raw.decode())
def note(self, app: str, line: str) -> None:
if line == "ARMED":
self.armed.add(app)
elif line == "CLICKED":
self.clicked.add(app)
elif m := re.fullmatch(r"GRAB (\d)", line):
self.grab[app] = int(m[1])
elif m := re.fullmatch(r"BTN (\d+) (\d+)", line):
self.button[app] = (int(m[1]), int(m[2]))
elif line.startswith("ERROR"):
_say(f"FAIL {app} reported {line}")
def stop(self) -> None:
for proc in self.procs.values():
proc.terminate()
for proc in self.procs.values():
proc.wait(timeout=10)
class _Screen:
"""Which locks are on screen, and a synthetic mouse."""
def __init__(self) -> None:
self.display = display.Display()
self.root = self.display.screen().root
def shown(self) -> set[str]:
apps: set[str] = set()
try:
for child in self.root.query_tree().children:
if child.get_attributes().map_state != X.IsViewable:
continue
for win in (child, *child.query_tree().children):
found = win.get_wm_class()
if found and found[1].lower() in LOCKERS:
apps.add(found[1].lower())
except error.XError as exc: # a lock closed mid-walk: next sample
_say(f"note: window vanished mid-sample ({exc})")
return apps
def click(self, x: int, y: int) -> None:
# Approach from off-target: Tk learns which widget is under the pointer
# from Enter/Motion events, and the previous lock's button sat at this
# very spot, so a motion to the same coordinates would produce none.
for px, py in ((x - 30, y - 30), (x, y)):
xtest.fake_input(self.display, X.MotionNotify, x=px, y=py)
self.display.sync()
time.sleep(0.15)
xtest.fake_input(self.display, X.ButtonPress, 1)
xtest.fake_input(self.display, X.ButtonRelease, 1)
self.display.sync()
class _Run:
"""Pumps the fleet and samples the screen; remembers any double lock."""
def __init__(self, fleet: _Fleet, screen: _Screen) -> None:
self.fleet = fleet
self.screen = screen
self.doubles: list[list[str]] = []
def pump(self, seconds: float, until: Callable[[], bool] | None = None) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
self.fleet.drain()
shown = self.screen.shown()
if len(shown) > 1:
self.doubles.append(sorted(shown))
if until is not None and until():
return True
time.sleep(STEP_S)
return until() if until is not None else False
def lone(self, seen: list[str]) -> str | None:
"""Wait for exactly one new lock on screen; return its app."""
got = self.pump(
SHOW_S,
lambda: len(shown := self.screen.shown()) == 1 and not shown & set(seen),
)
return next(iter(self.screen.shown())) if got else None
def _drive(run: _Run, check: Callable[[object, str], None]) -> list[str]:
"""Click each lock that appears, in turn; returns the order seen."""
order: list[str] = []
fleet = run.fleet
for _ in LOCKERS:
app = run.lone(order)
check(app, f"lock #{len(order) + 1} came up alone ({app})")
if not app:
break
order.append(app)
ready = run.pump(GRAB_S, lambda a=app: fleet.grab[a] == 1 and a in fleet.button)
check(ready, f"{app}: holds the grab and shows its button")
if ready:
run.pump(SETTLE_S)
run.screen.click(*fleet.button[app])
check(
run.pump(CLICK_S, lambda a=app: a in fleet.clicked),
f"{app}: XTEST click fired the callback",
)
return order
def main(mode: str) -> int:
"""Run the scenario on a private display; 0 when every check held."""
if os.environ.get("DISPLAY", "").split(".")[0] in {"", ":0"}:
_say("refusing to run: DISPLAY must be a private X server, never :0")
return 2
if mode not in ORDERS:
_say(f"usage: verify_all_lockers.py [{'|'.join(ORDERS)}]")
return 2
env = {**os.environ, "GATELOCK_RUNTIME_DIR": tempfile.mkdtemp(prefix="gl-lockers-")}
failures: list[str] = []
def check(ok: object, label: str) -> None:
_say(f"{'PASS' if ok else 'FAIL'} {label}")
if not ok:
failures.append(label)
helper = subprocess.Popen(
[sys.executable, str(HERE / "prompt_grab_helper.py"), "gcr-prompter"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
check(
helper.stdout is not None and helper.stdout.readline().strip() == "READY",
"prompt holds the grab",
)
fleet = _Fleet(env, ORDERS[mode])
run = _Run(fleet, _Screen())
try:
run.pump(LATE[mode][2] if mode in LATE else PROMPT_HOLD_S)
check(not run.doubles, "while the prompt held the grab: never two locks")
if helper.stdin:
helper.stdin.write("release\n")
helper.stdin.flush()
if mode in LATE:
late, at, hold = LATE[mode]
run.pump(at - hold)
check("leetcode_guard" in fleet.armed, "a weaker lock is armed first")
fleet.launch(late)
gone = run.pump(SHOW_S, lambda: "leetcode_guard" not in run.screen.shown())
check(gone, "the weaker lock stepped back")
order = _drive(run, check)
check(not run.doubles, f"never two locks on screen (saw {run.doubles[:3]})")
_say(f"order seen: {order}")
_say("rank order" if order == list(_RANKED) else "NOT rank order")
finally:
fleet.stop()
if helper.stdin:
helper.stdin.close()
helper.wait(timeout=10)
_say("OK" if not failures else f"{len(failures)} FAILED")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "mixed"))

View File

@ -1,113 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki
# pylint: disable=consider-using-with,import-error
r"""Prove the lock's Tk loop stays responsive while a foreign client holds the grab.
Tk's ``grab -global`` blocks the whole event loop for ~1.0 s on every FAILED
attempt, and the lock retries every 200 ms plus once a tick, so under a held
grab (2026-11-11: the keyring prompt) the loop was starved: no repaint, no
clicks, rows and buttons without text.
``prompt_grab_helper.py`` holds the grab (class ``steam_game``: not a prompt,
so no yielding muddies the measurement). A real ``mainloop`` runs for 5 s with
a 50 ms ``after`` heartbeat; the worst gap between beats must stay under
300 ms. After the helper lets go the lock must still take the grab.
PYTHONPATH=. xvfb-run -a -s "-screen 0 1600x1200x24" \
python3 scripts/verify_grab_responsive.py
"""
from __future__ import annotations
import os
from pathlib import Path
import select
import subprocess
import sys
import time
from unittest.mock import MagicMock
from gatelock import GateRoot, LockConfig, LockWindow
from gatelock._config import GrabPolicy
HELPER = Path(__file__).with_name("prompt_grab_helper.py")
BEAT_MS, RUN_S, MAX_GAP_S, WAIT_S = 50, 5.0, 0.3, 8.0
def _say(message: str) -> None:
sys.stdout.write(f"{message}\n")
def _line(proc: subprocess.Popen[str]) -> str:
out = proc.stdout
ready = select.select([out], [], [], WAIT_S)[0] if out else []
return out.readline().strip() if ready and out else ""
def _worst_gap(root: GateRoot) -> float:
"""Run the real mainloop for RUN_S; return the largest heartbeat gap."""
last = [time.monotonic()]
worst = [0.0]
def beat() -> None:
now = time.monotonic()
worst[0] = max(worst[0], now - last[0])
last[0] = now
root.after(BEAT_MS, beat)
root.after(BEAT_MS, beat)
root.after(int(RUN_S * 1000), root.quit)
root.mainloop()
return worst[0]
def main() -> int:
"""Hold a foreign grab, measure the loop, then release; 0 when all held."""
if os.environ.get("DISPLAY", "").split(".")[0] in {"", ":0"}:
_say("refusing to run: DISPLAY must be a private X server, never :0")
return 2
helper = subprocess.Popen(
[sys.executable, str(HELPER), "steam_game"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
failed = 0
if _line(helper) != "READY":
_say("FAIL helper did not take the grab")
return 1
root = GateRoot()
config = LockConfig(mode="hard", grab=GrabPolicy(disable_vt=False, retry_ms=200))
lock = LockWindow(root, config, MagicMock())
try:
lock.setup()
lock.grab_input()
gap = _worst_gap(root)
ok = gap < MAX_GAP_S
_say(f"{'PASS' if ok else 'FAIL'} worst loop gap {gap:.3f}s (< {MAX_GAP_S}s)")
failed += not ok
grab_held = lock.recovery.holds_grab()
_say(f"{'FAIL' if grab_held else 'PASS'} helper still holds the grab")
failed += grab_held
if helper.stdin:
helper.stdin.write("release\n")
helper.stdin.flush()
_line(helper)
deadline = time.monotonic() + WAIT_S
while time.monotonic() < deadline and not lock.recovery.holds_grab():
root.update()
time.sleep(0.05)
got = lock.recovery.holds_grab()
_say(f"{'PASS' if got else 'FAIL'} lock took the grab once it was free")
failed += not got
finally:
lock.close()
if helper.stdin:
helper.stdin.close()
helper.wait(timeout=WAIT_S)
_say("OK" if not failed else f"{failed} FAILED")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,250 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki
# pylint: disable=missing-function-docstring,consider-using-with,import-error
r"""Prove, on a real X server, that the lock yields to a system credential prompt.
No unit test can: in the suite the Tk root is a MagicMock, so the grab never
fails and stacking is never real. The 2026-11-11 incident was exactly that.
``prompt_grab_helper.py`` (a second X client, so Tk really answers "another
application has grab") maps a window with a chosen ``WM_CLASS`` and takes the
keyboard+pointer grab; a ``LockWindow`` then arms over it. Cases: ``prompt``
(gcr-prompter must end up above backdrop and surfaces, stay there, and the lock
must retake the grab and the top once the helper lets go), ``framed`` (client
inside an override-redirect frame, like i3: the top-level ancestor is raised),
``game`` (steam_game is not allowlisted: lock stays on top), ``i3`` (like
``prompt`` under a real i3 with a private runtime dir).
Run one case on a throwaway display (never ``:0``)::
PYTHONPATH=. xvfb-run -a -s "-screen 0 1600x1200x24" \
python3 scripts/verify_prompt_yield.py prompt
"""
from __future__ import annotations
import _tkinter
import os
from pathlib import Path
import select
import subprocess
import sys
import tempfile
import time
import tkinter as tk
from typing import TYPE_CHECKING, Final
from unittest.mock import MagicMock
from Xlib import X, display
from gatelock import GateRoot, LockConfig, LockWindow
from gatelock._config import GrabPolicy
if TYPE_CHECKING:
from collections.abc import Callable
HELPER: Final = Path(__file__).with_name("prompt_grab_helper.py")
CASES: Final = { # case -> (WM_CLASS instance, framed)
"prompt": ("gcr-prompter", False),
"framed": ("gcr-prompter", True),
"game": ("steam_game", False),
"i3": ("gcr-prompter", False),
}
STEP_S, WAIT_S = 0.05, 8.0
def _say(message: str) -> None:
sys.stdout.write(f"{message}\n")
class _Helper:
"""The grab-holding subprocess, as seen from the checking side."""
def __init__(self, wm_class: str, *, framed: bool) -> None:
args = [sys.executable, str(HELPER), wm_class, *(["--framed"] * framed)]
self.proc = subprocess.Popen(
args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
)
self._expect("READY")
def _expect(self, word: str) -> None:
out = self.proc.stdout
ready = select.select([out], [], [], WAIT_S)[0] if out else []
line = out.readline().strip() if ready and out else ""
if line != word:
msg = f"helper said {line!r}, wanted {word!r}"
raise RuntimeError(msg)
def release(self) -> None:
if self.proc.stdin:
self.proc.stdin.write("release\n")
self.proc.stdin.flush()
self._expect("RELEASED")
def stop(self) -> None:
if self.proc.stdin:
self.proc.stdin.close()
self.proc.wait(timeout=WAIT_S)
class _Stack:
"""Real stacking order, read over the harness's own X connection."""
def __init__(self, lock_tops: list[int]) -> None:
self.display = display.Display()
self.root = self.display.screen().root
self.lock_ids = [self.top_of(i) for i in lock_tops]
self.prompt = 0
def top_of(self, window_id: int) -> int:
win = self.display.create_resource_object("window", window_id)
while (parent := win.query_tree().parent).id != self.root.id:
win = parent
return int(win.id)
def order(self) -> list[int]:
return [w.id for w in self.root.query_tree().children]
def viewable(self, window_id: int) -> bool:
win = self.display.create_resource_object("window", window_id)
return bool(win.get_attributes().map_state == X.IsViewable)
def find(self, wm_class: str) -> int | None:
"""The root child that is, or contains, the helper's window."""
for child in self.root.query_tree().children:
for win in (child, *child.query_tree().children):
found = win.get_wm_class()
if found and found[0] == wm_class:
self.prompt = int(child.id)
return self.prompt
return None
def covered(self) -> bool:
order = self.order()
return all(order.index(i) > order.index(self.prompt) for i in self.lock_ids)
def on_top(self) -> bool:
order = self.order()
return all(order.index(self.prompt) > order.index(i) for i in self.lock_ids)
def lock_topmost(self) -> bool:
"""The topmost *viewable* root child belongs to the lock."""
top = next(i for i in reversed(self.order()) if self.viewable(i))
return top in self.lock_ids
def _pump(root: tk.Tk, seconds: float, until: Callable[[], bool] | None = None) -> bool:
"""Run Tk's event loop for ``seconds`` or until ``until`` holds.
One event at a time: ``update()`` never returns while the lock's grab
retries (each blocks Tk for ~1 s) keep coming due.
"""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
root.tk.dooneevent(_tkinter.DONT_WAIT)
if until is not None and until():
return True
time.sleep(STEP_S)
return until() if until is not None else False
def _start_i3() -> subprocess.Popen[bytes]:
"""Start i3 on the private display, with a private runtime dir."""
home = Path(tempfile.mkdtemp(prefix="gl-i3-"))
(home / "config").write_text("font pango:monospace 8\n", encoding="utf-8")
# No inherited I3SOCK: this i3 must never touch the live session's socket.
env = {k: v for k, v in os.environ.items() if k != "I3SOCK"}
env["XDG_RUNTIME_DIR"] = str(home)
proc = subprocess.Popen(["/usr/bin/i3", "-c", str(home / "config")], env=env)
probe = display.Display()
atom = probe.intern_atom("I3_SOCKET_PATH")
deadline = time.monotonic() + WAIT_S
while time.monotonic() < deadline:
if probe.screen().root.get_full_property(atom, X.AnyPropertyType):
return proc
time.sleep(0.1)
proc.kill()
msg = "i3 never came up"
raise RuntimeError(msg)
def _checks(case: str, root: tk.Tk, lock: LockWindow, helper: _Helper) -> list[str]:
"""Drive the armed lock through one scenario; returns failed labels."""
wm_class = CASES[case][0]
failures: list[str] = []
def check(ok: object, label: str) -> None:
_say(f"{'PASS' if ok else 'FAIL'} {label}")
if not ok:
failures.append(label)
_pump(root, 0.3)
tops: list[tk.Wm] = [root]
tops += [w for w in root.winfo_children() if isinstance(w, tk.Toplevel)]
seen = _Stack([int(t.wm_frame(), 16) for t in tops])
prompt = seen.find(wm_class)
check(prompt is not None, "helper window found on the root")
if prompt is None:
return failures
_pump(root, 0.7)
check(seen.covered(), "t<1s: lock starts above the prompt (incident)")
check(not lock.recovery.holds_grab(), "t<1s: the helper holds the grab")
yielded = _pump(root, WAIT_S, seen.on_top)
if case == "game":
check(not yielded, "steam_game: lock stays on top (no yield)")
_pump(root, 2.5)
check(seen.covered(), "steam_game: still covered 2.5s later")
else:
check(yielded, "prompt raised above backdrop and every surface")
_pump(root, 2.5) # several enforce ticks
check(seen.on_top(), "prompt stays on top across ticks")
check(not lock.recovery.holds_grab(), "lock does not hold the grab yet")
check(all(seen.viewable(i) for i in seen.lock_ids), "lock still mapped")
helper.release()
got = _pump(root, WAIT_S, lock.recovery.holds_grab)
check(got, "after the helper let go, the lock took the grab")
_pump(root, 1.5)
check(seen.lock_topmost(), "lock is back on top of everything viewable")
return failures
def _run_case(case: str) -> list[str]:
"""Arm a lock over the helper's grab, run the scenario, tear it all down."""
wm_class, framed = CASES[case]
wm = _start_i3() if case == "i3" else None
helper = lock = None
try:
helper = _Helper(wm_class, framed=framed)
root = GateRoot()
config = LockConfig(
mode="hard", grab=GrabPolicy(disable_vt=False, retry_ms=200)
)
lock = LockWindow(root, config, MagicMock())
lock.setup()
lock.grab_input()
return _checks(case, root, lock, helper)
finally:
if lock is not None:
lock.close()
if helper is not None:
helper.stop()
if wm is not None:
wm.terminate()
def main(argv: list[str]) -> int:
"""Run the named case on a private display; 0 when every check held."""
xdisplay = os.environ.get("DISPLAY", "")
if not xdisplay or xdisplay.split(".")[0] == ":0":
_say("refusing to run: DISPLAY must be a private X server, never :0")
return 2
if len(argv) != 1 or argv[0] not in CASES:
_say(f"usage: verify_prompt_yield.py {{{'|'.join(CASES)}}}")
return 2
failed = _run_case(argv[0])
_say("OK" if not failed else f"{len(failed)} FAILED")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -64,57 +64,47 @@ class _DeviceCodeDialogState extends State<DeviceCodeDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Glyphs in place of app-written words (kuhy's textgate rule); each old
// sentence is the spoken label. The user code is GitHub's data, typed
// by the user, so it stays visible.
return AlertDialog( return AlertDialog(
title: const Icon(Icons.key, semanticLabel: 'Authorize on GitHub'), title: const Text('Authorize on GitHub'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( const Text('Enter this code on GitHub:'),
children: [ const SizedBox(height: 8),
const Icon( SelectableText(
Icons.keyboard, widget.device.userCode,
semanticLabel: 'Enter this code on GitHub:', style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(width: 12),
SelectableText(
widget.device.userCode,
style: Theme.of(context).textTheme.headlineSmall,
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_error case final error?) if (_error == null)
Icon( const Row(
Icons.error, children: [
color: Theme.of(context).colorScheme.error, SizedBox(
semanticLabel: error, width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Expanded(child: Text('Waiting for authorization…')),
],
) )
else else
Semantics( Text(
label: 'Waiting for authorization…', _error!,
child: const SizedBox( style: TextStyle(color: Theme.of(context).colorScheme.error),
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
), ),
], ],
), ),
actions: [ actions: [
IconButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton.icon(
onPressed: _openPage, onPressed: _openPage,
child: const Icon( icon: const Icon(Icons.open_in_new),
Icons.open_in_new, label: const Text('Open GitHub & copy code'),
semanticLabel: 'Open GitHub & copy code',
),
), ),
], ],
); );

View File

@ -4,7 +4,7 @@ description: >-
modal that drives it. Extracted from four apps that each carried a copy, modal that drives it. Extracted from four apps that each carried a copy,
two of them byte-identical. two of them byte-identical.
publish_to: 'none' publish_to: 'none'
version: 0.4.0 version: 0.3.2
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -67,9 +67,6 @@ Future<Object? Function()> _showDialog(
); );
await tester.tap(find.text('open')); await tester.tap(find.text('open'));
await tester.pump(); await tester.pump();
// Let the dialog fade in: a transparent route has no semantics yet, and
// the dialog's words are spoken labels now.
await tester.pump(const Duration(milliseconds: 300));
return () => popped; return () => popped;
} }
@ -77,9 +74,9 @@ void main() {
testWidgets('shows the user code and a waiting state', (tester) async { testWidgets('shows the user code and a waiting state', (tester) async {
await _showDialog(tester, _pendingAuth()); await _showDialog(tester, _pendingAuth());
expect(find.bySemanticsLabel('Authorize on GitHub'), findsOneWidget); expect(find.text('Authorize on GitHub'), findsOneWidget);
expect(find.text('ABCD-1234'), findsOneWidget); expect(find.text('ABCD-1234'), findsOneWidget);
expect(find.bySemanticsLabel('Waiting for authorization…'), findsOneWidget); expect(find.text('Waiting for authorization…'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget);
}); });
@ -114,7 +111,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.bySemanticsLabel(RegExp('access_denied')), findsOneWidget); expect(find.textContaining('access_denied'), findsOneWidget);
}); });
testWidgets('surfaces an Error, not just an Exception', (tester) async { testWidgets('surfaces an Error, not just an Exception', (tester) async {
@ -129,12 +126,12 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.bySemanticsLabel(RegExp('No host specified')), findsOneWidget); expect(find.textContaining('No host specified'), findsOneWidget);
}); });
testWidgets('cancel pops without a token', (tester) async { testWidgets('cancel pops without a token', (tester) async {
final popped = await _showDialog(tester, _pendingAuth()); final popped = await _showDialog(tester, _pendingAuth());
await tester.tap(find.bySemanticsLabel('Cancel')); await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing); expect(find.byType(AlertDialog), findsNothing);
@ -164,7 +161,7 @@ void main() {
); );
await _showDialog(tester, auth); await _showDialog(tester, auth);
await tester.tap(find.bySemanticsLabel('Open GitHub & copy code')); await tester.tap(find.text('Open GitHub & copy code'));
await tester.pump(); await tester.pump();
expect(copied, 'ABCD-1234'); expect(copied, 'ABCD-1234');

View File

@ -93,14 +93,6 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
degrades to a pass, and the gate defers to that workflow when run inside degrades to a pass, and the gate defers to that workflow when run inside
another GitHub Actions job. another GitHub Actions job.
## Flutter apps: no Google non-free sign-in
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
in a built APK with `--apk`; apps use `google_account_token` instead.
## Flutter apps: the launcher rules ## Flutter apps: the launcher rules
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked

View File

@ -25,7 +25,6 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
from repo_contract import bootstrap from repo_contract import bootstrap
from repo_contract.check import violations from repo_contract.check import violations
from repo_contract.infer import propose from repo_contract.infer import propose
from repo_contract.install_gms import install_gms, plan_gms
from repo_contract.install_web import install_web, plan_web from repo_contract.install_web import install_web, plan_web
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
from repo_contract.stacks import detect_stacks from repo_contract.stacks import detect_stacks
@ -65,7 +64,6 @@ def plan(repo: Path) -> list[str]:
A website (detected or declared in repo-contract.toml) also gets the A website (detected or declared in repo-contract.toml) also gets the
AgentReady gate: config, shim, workflow and hook (see install_web.py). AgentReady gate: config, shim, workflow and hook (see install_web.py).
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
""" """
todo = [] todo = []
if _needs_shim(repo): if _needs_shim(repo):
@ -74,7 +72,7 @@ def plan(repo: Path) -> list[str]:
todo.append(str(WORKFLOW)) todo.append(str(WORKFLOW))
if needs_hook(repo, HOOK_ID): if needs_hook(repo, HOOK_ID):
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return todo + plan_web(repo) + plan_gms(repo) return todo + plan_web(repo)
def install(repo: Path) -> list[str]: def install(repo: Path) -> list[str]:
@ -94,7 +92,7 @@ def install(repo: Path) -> list[str]:
if needs_hook(repo, HOOK_ID): if needs_hook(repo, HOOK_ID):
write_hook(repo, _template("pre-commit-snippet.yaml")) write_hook(repo, _template("pre-commit-snippet.yaml"))
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return done + install_web(repo) + install_gms(repo) return done + install_web(repo)
def bootstrap_commands(repo: Path) -> list[str]: def bootstrap_commands(repo: Path) -> list[str]:

View File

@ -1,62 +0,0 @@
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
Why it rides on install_repo_contract_gate.sh: that installer is the one step
every new repo gets after `git init`, so a new Flutter app cannot come out of
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
utils/google_account_token in November 2026). Pieces: the
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
level down, e.g. tutor/app) get it; utils itself holds the real gate.
"""
from __future__ import annotations
from pathlib import Path
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
TEMPLATES = Path(__file__).resolve().parent / "templates"
SHIM = Path("scripts/check_no_gms_signin.sh")
HOOK_ID = "no-gms-signin"
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
def _template(name: str) -> str:
return (TEMPLATES / name).read_text(encoding="utf-8")
def is_flutter_android(repo: Path) -> bool:
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
if (repo / "repo_contract" / "check.py").is_file():
return False # utils: SHIM is the real gate, never a delegate
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
def plan_gms(repo: Path) -> list[str]:
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
if not is_flutter_android(repo):
return []
todo = []
shim = repo / SHIM
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
SHIM_TEMPLATE
):
todo.append(str(SHIM))
if needs_hook(repo, HOOK_ID):
todo.append(HOOK_PIECE)
return todo
def install_gms(repo: Path) -> list[str]:
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
todo = plan_gms(repo)
if str(SHIM) in todo:
target = repo / SHIM
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
target.chmod(0o755)
if HOOK_PIECE in todo:
write_hook(repo, _template("no-gms-signin-hook.yaml"))
return todo

View File

@ -1,27 +0,0 @@
#!/bin/bash
# ============================================================================
# Fail if this app links Google's non-free sign-in (google_sign_in,
# play-services-auth, googleid) instead of google_account_token.
#
# Thin delegate to the shared gate in ~/src/utils, which owns the package
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
# in a repo; never copy the shared gate itself.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -1,12 +0,0 @@
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
# No Google non-free sign-in: google_sign_in links play-services-auth /
# googleid into the APK; apps use utils/google_account_token instead.
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
# pull or merge, and a staged-only trigger would never look again.
- id: no-gms-signin
name: no google_sign_in / GMS auth (use google_account_token)
entry: scripts/check_no_gms_signin.sh .
language: system
pass_filenames: false
always_run: true

View File

@ -1,77 +0,0 @@
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
from __future__ import annotations
import stat
import subprocess
from pathlib import Path
from repo_contract import install as inst
from repo_contract import install_gms as gms
from .conftest import write
def flutter_android(root: Path, sub: str = "") -> Path:
"""A Flutter project at `root/sub` with an android/ dir."""
base = f"{sub}/" if sub else ""
write(root, f"{base}pubspec.yaml", "name: x\n")
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
return root
def test_no_pubspec_means_no_gate(repo: Path) -> None:
assert gms.plan_gms(repo) == []
assert gms.install_gms(repo) == []
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
write(repo, "pubspec.yaml", "name: x\n")
assert gms.plan_gms(repo) == []
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
flutter_android(repo)
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
shim = repo / gms.SHIM
assert shim.stat().st_mode & stat.S_IXUSR
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
assert f"id: {gms.HOOK_ID}" in config
assert "always_run: true" in config
assert gms.plan_gms(repo) == []
assert gms.install_gms(repo) == []
def test_nested_app_dir_counts(repo: Path) -> None:
flutter_android(repo, "app")
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
flutter_android(repo)
gms.install_gms(repo)
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
assert gms.install_gms(repo) == [str(gms.SHIM)]
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
flutter_android(repo, "google_account_token")
write(repo, "repo_contract/check.py")
assert gms.plan_gms(repo) == []
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
flutter_android(repo)
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
gms.install_gms(repo)
utils_root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
["bash", str(repo / gms.SHIM), str(repo)],
capture_output=True,
text=True,
check=False,
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
)
assert proc.returncode == 1
assert "google_account_token" in proc.stderr

View File

@ -1,193 +0,0 @@
#!/bin/bash
# ============================================================================
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
#
# Every app signs in through kuhy's FOSS plugin google_account_token
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
# google_sign_in_android, which links play-services-auth / googleid into the
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
# 2026-11-10; nothing else stops a later session from re-adding it.
#
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
# files under each lock's sibling android/ dir. Web-only pieces
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
# google_identity_services_web) are allowed while google_sign_in_android is
# absent: kartka's web build uses them for the GIS button and they never
# reach an APK. APK mode looks for the GMS auth library marker files in the
# built APK, the ground truth when a dependency arrives some other way.
#
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
# check_no_gms_signin.sh --apk app.apk
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
# With no arguments, checks ./pubspec.lock (passes if there is none).
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
google_identity_services_web)
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
# Anchored so com.google.gms:google-services (Firebase) and
# play-services-auth-api-phone do not trip it.
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
# APK mode: library marker files AGP copies to the APK root (or META-INF),
# one per linked artifact. Dex strings are not used: play-services-base and
# -basement reference GoogleSignInAccount without linking play-services-auth.
# play-services-fido (passkeys) is deliberately allowed.
readonly APK_MARKERS=(
googleid.properties
play-services-auth.properties
play-services-auth-base.properties
play-services-auth-api-phone.properties
play-services-auth-blockstore.properties
play-services-identity-credentials.properties
META-INF/androidx.credentials_credentials-play-services-auth.version
)
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
-o -name node_modules)
usage() {
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
echo " $SCRIPT_NAME --apk app.apk"
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
exit 0
}
fix_hint() {
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
echo " google_account_token:" >&2
echo " git:" >&2
echo " url: https://github.com/kuhyx/utils" >&2
echo " ref: google_account_token-v0.1.0" >&2
echo " path: google_account_token" >&2
echo " See utils/google_account_token/README.md." >&2
}
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
lock_packages() {
awk '
/^packages:[[:space:]]*$/ { in_packages = 1; next }
/^[^[:space:]]/ { in_packages = 0; next }
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
sub(/^ /, ""); sub(/:.*/, ""); print
}
' "$1"
}
# Prints the offending packages of one lock, one per line.
lock_offenders() {
local -A present=()
local name
while IFS= read -r name; do
present["$name"]=1
done < <(lock_packages "$1")
for name in "${BANNED[@]}"; do
[[ -n "${present[$name]:-}" ]] && echo "$name"
done
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
for name in "${WEB_ONLY[@]}"; do
[[ -n "${present[$name]:-}" ]] && echo "$name"
done
fi
return 0
}
# Prints file:line:text for every non-comment GMS sign-in coordinate under
# the lock's sibling android/ dir.
gradle_offenders() {
local android_dir="$1/android" file
[[ -d "$android_dir" ]] || return 0
while IFS= read -r -d '' file; do
grep -nE "$GRADLE_PATTERN" "$file" \
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
| sed "s|^|$file:|" || true
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
}
indent() {
local line
while IFS= read -r line; do
echo " $line"
done
}
check_lock() {
local lock="$1" offenders gradle
offenders="$(lock_offenders "$lock")"
gradle="$(gradle_offenders "$(dirname "$lock")")"
[[ -z "$offenders" && -z "$gradle" ]] && return 0
if [[ -n "$offenders" ]]; then
echo "Error: $lock links Google's non-free sign-in:" >&2
indent <<<"$offenders" >&2
fi
if [[ -n "$gradle" ]]; then
echo "Error: gradle names the GMS sign-in stack:" >&2
indent <<<"$gradle" >&2
fi
return 1
}
check_apk() {
local apk="$1" entries found=()
local marker
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
entries="$(unzip -Z1 "$apk")"
for marker in "${APK_MARKERS[@]}"; do
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
done
[[ "${#found[@]}" -eq 0 ]] && return 0
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
printf ' %s\n' "${found[@]}" >&2
fix_hint
return 1
}
# Expands the arguments into pubspec.lock paths, NUL-separated.
collect_locks() {
local arg
for arg in "$@"; do
if [[ -d "$arg" ]]; then
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
-name pubspec.lock -print0
else
printf '%s\0' "$arg"
fi
done
}
main() {
case "${1:-}" in
-h|--help) usage ;;
--apk)
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
check_apk "$2"
return
;;
esac
if [[ $# -eq 0 ]]; then
[[ -f pubspec.lock ]] || return 0
set -- pubspec.lock
fi
local -a locks=()
local arg lock failed=0
# Validated up front: collect_locks runs in a process substitution, whose
# exit status would be lost, and a typo'd path must not pass silently.
for arg in "$@"; do
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
done
mapfile -d '' locks < <(collect_locks "$@")
for lock in "${locks[@]}"; do
check_lock "$lock" || failed=1
done
[[ "$failed" -eq 0 ]] || fix_hint
return "$failed"
}
main "$@"

View File

@ -1,109 +0,0 @@
#!/bin/bash
# ============================================================================
# Deploy the parts of utils that run on this machine, then prove they are live.
#
# utils is mostly libraries that apps pin by git tag, so "deploy" means:
# 1. every released library's tag for its current version is on origin
# (sickdays, sync_settings_ui: the apps' pins resolve to the code on main);
# 2. the sickdays CLI + launcher run the checkout's code, via
# sickdays/install.sh (its venv installs sickdays editable from here).
#
# Run it from the live checkout (~/src/utils), never from a worktree: install.sh
# installs editable from its own directory, and a worktree path would point the
# live venv at a directory that gets deleted.
#
# Usage: scripts/deploy.sh [--verify-only]
# Exits non-zero unless every health check passes. Idempotent.
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly SCRIPT_NAME UTILS_DIR
readonly VENV_DIR="${HOME}/.local/share/sickdays/venv"
readonly CLI_LINK="${HOME}/.local/bin/sickdays"
VERIFY_ONLY=0
FAILED=0
log() { printf 'deploy: %s\n' "$1" >&2; }
bad() { printf 'deploy: FAIL -- %s\n' "$1" >&2; FAILED=1; }
usage() {
echo "Usage: $SCRIPT_NAME [--verify-only]"
echo " --verify-only run the health checks without installing anything"
exit 0
}
# Prints the version field of a pyproject.toml or pubspec.yaml.
manifest_version() {
sed -nE 's/^version( =|:) *"?([^"]+)"?$/\2/p' "$1" | head -n 1
}
# The tag <prefix>-v<version> for the version on disk must be on origin and
# hold exactly this checkout's <dir>: code past its tag never reaches the apps
# that pin it.
check_tag() {
local prefix="$1" dir="$2" manifest version tag
manifest="$UTILS_DIR/$dir/pyproject.toml"
[[ -f "$manifest" ]] || manifest="$UTILS_DIR/$dir/pubspec.yaml"
version="$(manifest_version "$manifest")"
tag="${prefix}-v${version}"
if ! git -C "$UTILS_DIR" fetch -q origin "+refs/tags/${tag}:refs/tags/${tag}" 2>/dev/null; then
bad "${tag} is not on origin: tag and push it so the app pins resolve"
elif [[ "$(git -C "$UTILS_DIR" rev-parse "${tag}:${dir}")" \
!= "$(git -C "$UTILS_DIR" rev-parse "HEAD:${dir}")" ]]; then
bad "${dir} changed after ${tag}: bump its version and release it"
else
log "ok: ${tag} is on origin and matches HEAD"
fi
}
# The bare `sickdays` (what the launcher runs) must be the venv's CLI, and the
# venv must import the text-free sickdays from this checkout.
check_sickdays() {
local target module
target="$(readlink -f "$CLI_LINK" || true)"
if [[ "$target" != "$(readlink -f "$VENV_DIR/bin/sickdays")" ]]; then
bad "$CLI_LINK runs ${target:-nothing}, not the venv's sickdays"
fi
module="$("$VENV_DIR/bin/python" -I -c 'import sickdays; print(sickdays.__file__)' 2>/dev/null || true)"
if [[ "$module" != "$UTILS_DIR/sickdays/"* ]]; then
bad "the sickdays venv imports ${module:-nothing}, not $UTILS_DIR/sickdays"
elif [[ -e "$(dirname "$module")/gui/_text.py" ]]; then
bad "the live sickdays still has gui/_text.py (the pre-text-free GUI)"
else
log "ok: sickdays runs $module"
fi
}
verify() {
check_tag sickdays sickdays
check_tag sync_settings_ui sync_settings_ui
check_sickdays
if [[ "$FAILED" -ne 0 ]]; then
log "health check FAILED"
exit 1
fi
log "healthy"
}
main() {
if [[ "$VERIFY_ONLY" -eq 0 ]]; then
log "installing sickdays from $UTILS_DIR/sickdays"
"$UTILS_DIR/sickdays/install.sh"
fi
verify
}
while [[ $# -gt 0 ]]; do
case $1 in
--verify-only) VERIFY_ONLY=1; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
main

View File

@ -9,7 +9,6 @@
# when the repo has .dep-freshness-no-workflow), Commands section. A website # when the repo has .dep-freshness-no-workflow), Commands section. A website
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also # (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow. # gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
# #
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap] # Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo # Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo

View File

@ -1,195 +0,0 @@
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
android/ gradle files) or a fake APK built with zipfile, runs the real
script, and asserts on the exit code and the message.
"""
from __future__ import annotations
import subprocess
import zipfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
# The package keys of kuhy's real kartka lock (web-only GIS button).
KARTKA_KEYS = (
"google_account_token",
"google_identity_services_web",
"google_sign_in_platform_interface",
"google_sign_in_web",
)
def _lock(root: Path, *packages: str) -> Path:
"""Write a pubspec.lock under `root` with the given package keys."""
root.mkdir(parents=True, exist_ok=True)
body = ["# Generated by pub", "packages:"]
for name in ("collection", *packages):
body += [
f" {name}:",
' dependency: "direct main"',
" description:",
f" name: {name}",
" source: hosted",
' version: "1.0.0"',
]
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
lock = root / "pubspec.lock"
lock.write_text("\n".join(body), encoding="utf-8")
return lock
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(SCRIPT), *map(str, args)],
capture_output=True,
text=True,
check=False,
)
def test_clean_lock_passes(tmp_path: Path) -> None:
lock = _lock(tmp_path, "google_account_token", "http")
assert _run(lock).returncode == 0
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
lock = _lock(tmp_path, "google_sign_in_android")
proc = _run(lock)
assert proc.returncode == 1
assert str(lock) in proc.stderr
assert "google_sign_in_android" in proc.stderr
assert "google_account_token-v0.1.0" in proc.stderr
assert "google_account_token/README.md" in proc.stderr
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
proc = _run(_lock(tmp_path, "google_sign_in"))
assert proc.returncode == 1
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
assert proc.returncode == 1
assert "google_sign_in_web" in proc.stderr
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
lock = _lock(tmp_path)
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
gradle.parent.mkdir(parents=True)
gradle.write_text(
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
encoding="utf-8",
)
proc = _run(lock)
assert proc.returncode == 1
assert str(gradle) in proc.stderr
assert "play-services-auth" in proc.stderr
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
lock = _lock(tmp_path)
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
toml.parent.mkdir(parents=True)
toml.write_text(
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
encoding="utf-8",
)
assert _run(lock).returncode == 1
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
lock = _lock(tmp_path)
gradle = tmp_path / "android" / "build.gradle"
gradle.parent.mkdir(parents=True)
gradle.write_text(
"// play-services-auth was removed 2026-11-02\n"
"classpath 'com.google.gms:google-services:4.4.2'\n",
encoding="utf-8",
)
assert _run(lock).returncode == 0
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
_lock(tmp_path / "app", "google_account_token")
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
# Agent worktrees are other checkouts, judged in their own repo.
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
assert _run(tmp_path).returncode == 0
_lock(tmp_path / "other", "google_sign_in_ios")
proc = _run(tmp_path)
assert proc.returncode == 1
assert "google_sign_in_ios" in proc.stderr
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
assert _run(tmp_path / "nope.lock").returncode == 2
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
"""Fake APK: dex strings plus library marker files AGP copies in."""
with zipfile.ZipFile(path, "w") as apk:
apk.writestr("AndroidManifest.xml", b"\x03\x00")
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
for name in markers:
apk.writestr(name, b"version=1")
return path
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
"""play-services-base references GoogleSignInAccount without linking auth."""
markers = (
"play-services-base.properties",
"play-services-basement.properties",
"play-services-tasks.properties",
"play-services-fido.properties",
)
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
assert proc.returncode == 0, proc.stderr
def test_apk_without_markers_passes(tmp_path: Path) -> None:
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
banned = (
"play-services-auth.properties",
"googleid.properties",
"play-services-auth-base.properties",
"play-services-auth-api-phone.properties",
"play-services-auth-blockstore.properties",
"play-services-identity-credentials.properties",
CREDENTIALS_MARKER,
)
for marker in banned:
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
proc = _run("--apk", apk)
assert proc.returncode == 1, marker
assert marker in proc.stderr
assert "google_account_token" in proc.stderr
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
assert _run("--apk", apk).returncode == 0
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
assert _run("--apk", tmp_path / "nope.apk").returncode == 2

View File

@ -70,35 +70,22 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
## The picker window (0.3.0) ## The picker window (0.3.0)
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on `sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
Tk -- calendar, a six-switch panel per day, the days ahead. Past days are Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
inert; a free day is refused; each write starts `sync_quietly()` on a inert; a free day is refused; each write starts `sync_quietly()` on a
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
just the panel (`import sickdays` never imports tkinter): just the panel (`import sickdays` never imports tkinter):
No words inside the window (textgate): dates are ISO numerals (`2026-11`,
`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `</>` =
LeetCode, open book, apple = diet, alarm clock, target = focus), switches are
knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save),
red bin (clear) and cross (cancel). A refusal is an icon in the status slot:
padlock (past), no-entry (free day), warning (cannot check, or a refused
write; the detail goes to the log), hourglass (a sync is still running).
Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts,
where non-ASCII glyphs render as nothing. The title bar keeps "Sick days"
(an app's name, textgate settled decision 4). The guard is
`tests/test_gui_no_words.py`: no letter on any widget, in any state.
```python ```python
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack() KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
``` ```
Normal day: six switches, all off (off = stands down), `actions.mark`; sick Normal day: five switches, all off (off = stands down), "Mark sick"; sick
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
days show their icon and offer no write; a refusal (or a click mid-sync) why and offer no write; a refusal (or a click mid-sync) shows in the panel.
shows in the panel's `status` slot, named by `panel.reason`.
`on_saved(SickDay | None)` fires after the write and sync start: the entry on `on_saved(SickDay | None)` fires after the write and sync start: the entry on
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None` Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
on an unchanged save (nothing written). The panel never destroys itself. on an unchanged Save (nothing written). The panel never destroys itself.
## API ## API

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sickdays" name = "sickdays"
version = "0.5.0" version = "0.4.0"
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it" description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
requires-python = ">=3.14" requires-python = ">=3.14"
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in # Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in
@ -120,9 +120,6 @@ disable = []
min-public-methods = 0 min-public-methods = 0
max-module-lines = 1000 max-module-lines = 1000
max-attributes = 10 max-attributes = 10
# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction:
# Canvas mixes in XView and YView on top of Widget's chain.
max-parents = 9
[tool.pylint.typecheck] [tool.pylint.typecheck]
generated-members = [ generated-members = [

View File

@ -1,33 +1,29 @@
# Copyright (c) 2026 Krzysztof Rudnicki # Copyright (c) 2026 Krzysztof Rudnicki
"""The month grid: Monday-first, one button per day, prev/next month chevrons. """The month grid: Monday-first, one button per day, prev/next month arrows.
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
days keep their mark but faded and inert. Today stays clickable -- a sick days keep their mark but faded and inert. Today stays clickable -- a sick
day can be marked or cleared for today. Deciding what a click *means* stays day can be marked or cleared for today. Deciding what a click *means* stays
with the window; this only reports the date. No words: the month reads with the window; this only reports the date.
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
""" """
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING
from sickdays._day import to_iso from sickdays._day import to_iso
from sickdays.gui import _dates from sickdays.gui import _text
from sickdays.gui import _tokens as tok from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui from sickdays.gui import _widgets as ui
from sickdays.gui._controls import IconButton, IconSlot
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from datetime import date from datetime import date
#: The weekday marks over the grid, in px. _PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
_MARK_PX: Final = 10 _NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
#: The keep-a-locker dot inside a sick day's cell, in px.
_DOT_PX: Final = 8
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]: def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
@ -60,7 +56,7 @@ class MonthCalendar(tk.Frame):
self._is_sick = is_sick self._is_sick = is_sick
self._has_dot = has_dot self._has_dot = has_dot
self._on_day = on_day self._on_day = on_day
self._first, self._last = _dates.month_bounds(today) self._first, self._last = _text.month_bounds(today)
self.month = today.replace(day=1) self.month = today.replace(day=1)
self._header = tk.Frame(self, name="header", bg=self.cget("bg")) self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
self._header.pack(fill="x") self._header.pack(fill="x")
@ -70,7 +66,7 @@ class MonthCalendar(tk.Frame):
def step(self, months: int) -> None: def step(self, months: int) -> None:
"""Move ``months`` months (within the phone's range) and redraw.""" """Move ``months`` months (within the phone's range) and redraw."""
target = _dates.shift_month(self.month, months) target = _text.shift_month(self.month, months)
if self._first <= target <= self._last: if self._first <= target <= self._last:
self.month = target self.month = target
self.redraw() self.redraw()
@ -81,12 +77,11 @@ class MonthCalendar(tk.Frame):
for child in frame.winfo_children(): for child in frame.winfo_children():
child.destroy() child.destroy()
self._draw_header() self._draw_header()
for column in range(7): for column, initial in enumerate(_text.WEEKDAY_INITIALS):
mark = "ring" if _dates.is_weekend_column(column) else "dot" ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid( row=0, column=column
row=0, column=column, pady=(0, tok.XS)
) )
for row, week in enumerate(_dates.month_weeks(self.month), start=1): for row, week in enumerate(_text.month_weeks(self.month), start=1):
for column, day in enumerate(week): for column, day in enumerate(week):
if day is not None: if day is not None:
self._cell(day).grid( self._cell(day).grid(
@ -94,20 +89,20 @@ class MonthCalendar(tk.Frame):
) )
def _draw_header(self) -> None: def _draw_header(self) -> None:
back = IconButton(self._header, "prev", lambda: self.step(-1), name="prev") back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
forward = IconButton(self._header, "next", lambda: self.step(1), name="next") forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
back.set_enabled(self.month > self._first) back.configure(state="normal" if self.month > self._first else "disabled")
forward.set_enabled(self.month < self._last) forward.configure(state="normal" if self.month < self._last else "disabled")
back.pack(side="left") back.pack(side="left")
forward.pack(side="right") forward.pack(side="right")
ui.label(self._header, _dates.month_title(self.month), "subtitle").pack( ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
side="top", pady=tok.XS side="top", pady=tok.XS
) )
def _cell(self, day: date) -> tk.Button: def _cell(self, day: date) -> tk.Button:
sick = self._is_sick(day) sick = self._is_sick(day)
past = day < self._today past = day < self._today
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day)) fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
cell = tk.Button( cell = tk.Button(
self._grid, self._grid,
name=f"day-{to_iso(day)}", name=f"day-{to_iso(day)}",
@ -129,6 +124,7 @@ class MonthCalendar(tk.Frame):
cursor="arrow" if past else "hand2", cursor="arrow" if past else "hand2",
) )
if sick and self._has_dot(day): if sick and self._has_dot(day):
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text) dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
dot.place(relx=1.0, x=-2, y=2, anchor="ne") dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
return cell return cell

View File

@ -1,171 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Word-free controls: an icon button, an on/off switch and an icon slot.
Canvases, because a Tk Button or Checkbutton can only show its state as a
word or a font glyph (see :mod:`sickdays.gui._icons`). Each keeps Tk's own
``state`` option and offers ``invoke()``, so callers and tests drive them
like the widgets they replace. Every state has two channels: shape (icon,
knob position) plus colour.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING, Final, Literal
from sickdays.gui import _tokens as tok
from sickdays.gui._icons import TAG, draw
if TYPE_CHECKING:
from collections.abc import Callable
ButtonKind = Literal["primary", "danger", "plain"]
#: (fill, icon colour) per button kind; danger keeps its red on a dark fill.
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
"primary": (tok.ACCENT, tok.ON_FILL),
"danger": (tok.INK_RAISED_2, tok.DANGER),
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
}
ICON_PX: Final = 20
_TRACK_W: Final = 44
_TRACK_H: Final = 24
def _enabled(widget: tk.Canvas) -> bool:
return str(widget.cget("state")) != "disabled"
class IconButton(tk.Canvas):
"""A flat button showing ``icon``; ``invoke()`` runs ``command`` unless disabled."""
def __init__(
self,
parent: tk.Misc,
icon: str,
command: Callable[[], object],
*,
name: str,
kind: ButtonKind = "plain",
) -> None:
"""Draw the button; a click anywhere on it invokes it."""
fill, self._colour = _BUTTON_COLOURS[kind]
super().__init__(
parent,
name=name,
width=ICON_PX + 2 * tok.MD,
height=ICON_PX + 2 * tok.SM,
bg=fill,
highlightthickness=0,
borderwidth=0,
cursor="hand2",
)
#: Which pictogram this button shows.
self.icon = icon
self._command = command
self.bind("<Button-1>", lambda _event: self.invoke())
self._paint()
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
"""Grey the icon out and ignore clicks while disabled."""
self.configure(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
)
self._paint()
def invoke(self) -> None:
"""Run the command, as a click would."""
if _enabled(self):
self._command()
def _paint(self) -> None:
self.delete(TAG)
colour = self._colour if _enabled(self) else tok.MUTED_ON_DARK
draw(self, self.icon, size=ICON_PX, colour=colour, at=(tok.MD, tok.SM))
class Toggle(tk.Canvas):
"""An on/off switch for ``variable``: knob right on accent, left on dark."""
def __init__(self, parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> None:
"""Draw the switch in ``variable``'s current state."""
super().__init__(
parent,
name=name,
width=_TRACK_W,
height=_TRACK_H,
bg=parent.cget("bg"),
highlightthickness=0,
borderwidth=0,
cursor="hand2",
)
self._variable = variable
self.bind("<Button-1>", lambda _event: self.invoke())
self.paint()
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
"""Fade the switch and ignore clicks while disabled."""
self.configure(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
)
self.paint()
def invoke(self) -> None:
"""Flip the switch, as a click would."""
if _enabled(self):
self._variable.set(not self._variable.get())
self.paint()
def is_on(self) -> bool:
"""Whether the locker this switch stands for still applies."""
return bool(self._variable.get())
def paint(self) -> None:
"""Redraw the track and knob from the variable."""
self.delete(TAG)
on = self.is_on()
track, knob = (
(tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.MUTED_ON_DARK)
)
if not _enabled(self):
track = tok.blend(track, tok.INK, tok.PAST_ALPHA)
knob = tok.blend(knob, tok.INK, tok.PAST_ALPHA)
h, w = _TRACK_H, _TRACK_W
self.create_oval(0, 0, h, h, fill=track, outline=track, tags=TAG)
self.create_oval(w - h, 0, w, h, fill=track, outline=track, tags=TAG)
self.create_rectangle(
h // 2, 0, w - h // 2, h, fill=track, outline=track, tags=TAG
)
left = w - h + 3 if on else 3
self.create_oval(
left, 3, left + h - 6, h - 3, fill=knob, outline=knob, tags=TAG
)
class IconSlot(tk.Canvas):
"""One icon, or nothing: a status mark, a locker's pictogram, a marker."""
def __init__(self, parent: tk.Misc, *, name: str = "", size: int = ICON_PX) -> None:
"""An empty slot ``size`` px square on ``parent``'s background."""
super().__init__(
parent,
name=name,
width=size,
height=size,
bg=parent.cget("bg"),
highlightthickness=0,
borderwidth=0,
)
self._size = size
#: The pictogram shown, or ``None`` when the slot is empty.
self.icon: str | None = None
def show(self, icon: str | None, colour: str = tok.TEXT_ON_DARK) -> IconSlot:
"""Show ``icon`` in ``colour`` (``None`` clears the slot)."""
self.delete(TAG)
self.icon = icon
if icon is not None:
draw(self, icon, size=self._size, colour=colour)
return self

View File

@ -1,86 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Calendar arithmetic and the free-day check for the picker -- no Tk in here.
The picker shows no words (kuhy's textgate rule): dates are ISO numerals and
a refusal is a reason *code* the widgets turn into an icon. The library's
own message for an unavailable check goes to the log, not the screen.
"""
from __future__ import annotations
import calendar
from datetime import date
import logging
from typing import TYPE_CHECKING, Final, Literal
from sickdays._conflict import free_day_predicate
from sickdays._constants import LOCKERS
from sickdays._errors import ConflictCheckUnavailableError
if TYPE_CHECKING:
from collections.abc import Iterable
_LOG = logging.getLogger(__name__)
#: Why a day cannot be marked sick: it is a free day, or that cannot be checked.
Block = Literal["free", "unavailable"]
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
_WORKDAYS: Final = frozenset({1, 2, 3})
#: Sat/Sun, marked with a ring instead of a dot over the grid.
_WEEKEND: Final = frozenset({5, 6})
def kept_in_order(kept: Iterable[str]) -> tuple[str, ...]:
"""The kept lockers in ``sickdays.LOCKERS`` order, not the set's."""
chosen = set(kept)
return tuple(locker for locker in LOCKERS if locker in chosen)
def free_day_block(day: date) -> Block | None:
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
"Could not check" refuses too: it must never count as "not a free day".
"""
try:
is_free = free_day_predicate()
except ConflictCheckUnavailableError as exc:
_LOG.warning("free-day check unavailable: %s", exc)
return "unavailable"
return "free" if is_free(day) else None
def is_workday(day: date) -> bool:
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
return day.weekday() in _WORKDAYS
def is_weekend_column(column: int) -> bool:
"""Whether grid ``column`` (0 = Monday) is Saturday or Sunday."""
return column in _WEEKEND
def month_title(month: date) -> str:
"""``"2026-10"``: the month as numerals, never a month name."""
return f"{month.year}-{month.month:02d}"
def month_weeks(month: date) -> list[list[date | None]]:
"""The month as Monday-first weeks, ``None`` padding either end."""
return [
[None if not cell else date(month.year, month.month, cell) for cell in week]
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
month.year, month.month
)
]
def shift_month(month: date, step: int) -> date:
"""The first of the month ``step`` months after ``month``."""
index = month.year * 12 + month.month - 1 + step
return date(index // 12, index % 12 + 1, 1)
def month_bounds(reference: date) -> tuple[date, date]:
"""January this year to December next year, the phone's range."""
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)

View File

@ -1,235 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pictograms drawn as Canvas geometry, so the picker needs no words.
Drawn rather than typed: the venv's Tk falls back to core X fonts, where
every non-ASCII glyph (angle quotes, check marks) renders as nothing; the
old month arrows were blank buttons. Each icon is plotted on a unit square,
scaled to ``size`` pixels, and tagged ``icon`` so a repaint can drop it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from sickdays._constants import (
LOCKER_ALARM,
LOCKER_BOOK,
LOCKER_DIET,
LOCKER_FOCUS,
LOCKER_LEETCODE,
LOCKER_WORKOUT,
)
if TYPE_CHECKING:
from collections.abc import Callable
import tkinter as tk
TAG: Final = "icon"
class Pen:
"""Draws unit-square coordinates onto ``canvas`` at ``(x, y)``, ``size`` wide."""
def __init__(
self, canvas: tk.Canvas, *, size: int, colour: str, at: tuple[int, int]
) -> None:
"""Remember where and how large to draw."""
self._canvas = canvas
self._size = size
self._colour = colour
self._x, self._y = at
self.width = max(2, round(size / 10))
def _xy(self, points: tuple[float, ...]) -> list[float]:
return [
(self._y if i % 2 else self._x) + value * self._size
for i, value in enumerate(points)
]
def line(self, *points: float) -> None:
"""A stroke through ``points`` (x0, y0, x1, y1, ...)."""
self._canvas.create_line(
*self._xy(points),
fill=self._colour,
width=self.width,
capstyle="round",
joinstyle="round",
tags=TAG,
)
def oval(self, *box: float, filled: bool = False) -> None:
"""An ellipse in ``box`` (x0, y0, x1, y1), outlined or filled."""
self._canvas.create_oval(
*self._xy(box),
outline=self._colour,
fill=self._colour if filled else "",
width=self.width,
tags=TAG,
)
def shape(self, *points: float, filled: bool = False) -> None:
"""A closed polygon through ``points``, outlined or filled."""
self._canvas.create_polygon(
*self._xy(points),
outline=self._colour,
fill=self._colour if filled else "",
width=self.width,
joinstyle="round",
tags=TAG,
)
def arch(self, *box: float) -> None:
"""The top half of the ellipse in ``box`` (a padlock's shackle)."""
self._canvas.create_arc(
*self._xy(box),
start=0,
extent=180,
style="arc",
outline=self._colour,
width=self.width,
tags=TAG,
)
def _check(pen: Pen) -> None:
pen.line(0.2, 0.55, 0.42, 0.76, 0.82, 0.28)
def _cross(pen: Pen) -> None:
pen.line(0.25, 0.25, 0.75, 0.75)
pen.line(0.75, 0.25, 0.25, 0.75)
def _trash(pen: Pen) -> None:
pen.line(0.18, 0.26, 0.82, 0.26)
pen.line(0.4, 0.26, 0.42, 0.14, 0.58, 0.14, 0.6, 0.26)
pen.shape(0.27, 0.34, 0.73, 0.34, 0.68, 0.88, 0.32, 0.88)
def _prev(pen: Pen) -> None:
pen.line(0.62, 0.22, 0.36, 0.5, 0.62, 0.78)
def _next(pen: Pen) -> None:
pen.line(0.38, 0.22, 0.64, 0.5, 0.38, 0.78)
def _workout(pen: Pen) -> None:
pen.line(0.2, 0.5, 0.8, 0.5)
pen.shape(0.17, 0.28, 0.29, 0.28, 0.29, 0.72, 0.17, 0.72, filled=True)
pen.shape(0.71, 0.28, 0.83, 0.28, 0.83, 0.72, 0.71, 0.72, filled=True)
pen.line(0.08, 0.5, 0.17, 0.5)
pen.line(0.83, 0.5, 0.92, 0.5)
def _leetcode(pen: Pen) -> None:
pen.line(0.36, 0.28, 0.12, 0.5, 0.36, 0.72)
pen.line(0.64, 0.28, 0.88, 0.5, 0.64, 0.72)
pen.line(0.57, 0.2, 0.43, 0.8)
def _book(pen: Pen) -> None:
pen.shape(0.5, 0.3, 0.1, 0.2, 0.1, 0.76, 0.5, 0.86)
pen.shape(0.5, 0.3, 0.9, 0.2, 0.9, 0.76, 0.5, 0.86)
def _diet(pen: Pen) -> None:
pen.oval(0.2, 0.34, 0.8, 0.9, filled=True)
pen.line(0.5, 0.34, 0.54, 0.14)
pen.oval(0.56, 0.12, 0.78, 0.26, filled=True)
def _alarm(pen: Pen) -> None:
pen.oval(0.18, 0.24, 0.82, 0.88)
pen.line(0.5, 0.38, 0.5, 0.56, 0.63, 0.64)
pen.oval(0.08, 0.1, 0.3, 0.3, filled=True)
pen.oval(0.7, 0.1, 0.92, 0.3, filled=True)
def _focus(pen: Pen) -> None:
pen.oval(0.1, 0.1, 0.9, 0.9)
pen.oval(0.3, 0.3, 0.7, 0.7)
pen.oval(0.44, 0.44, 0.56, 0.56, filled=True)
def _padlock(pen: Pen) -> None:
pen.arch(0.3, 0.12, 0.7, 0.62)
pen.shape(0.22, 0.44, 0.78, 0.44, 0.78, 0.88, 0.22, 0.88, filled=True)
def _no_entry(pen: Pen) -> None:
pen.oval(0.14, 0.14, 0.86, 0.86)
pen.line(0.28, 0.28, 0.72, 0.72)
def _warning(pen: Pen) -> None:
pen.shape(0.5, 0.12, 0.9, 0.86, 0.1, 0.86)
pen.line(0.5, 0.38, 0.5, 0.6)
pen.oval(0.47, 0.71, 0.53, 0.77, filled=True)
def _hourglass(pen: Pen) -> None:
pen.line(0.22, 0.12, 0.78, 0.12)
pen.line(0.22, 0.88, 0.78, 0.88)
pen.shape(0.28, 0.12, 0.72, 0.12, 0.28, 0.88, 0.72, 0.88)
def _sick(pen: Pen) -> None:
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85, filled=True)
def _normal(pen: Pen) -> None:
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85)
def _dot(pen: Pen) -> None:
pen.oval(0.3, 0.3, 0.7, 0.7, filled=True)
def _ring(pen: Pen) -> None:
pen.oval(0.25, 0.25, 0.75, 0.75)
ICONS: Final[dict[str, Callable[[Pen], None]]] = {
"check": _check,
"cross": _cross,
"trash": _trash,
"prev": _prev,
"next": _next,
"workout": _workout,
"leetcode": _leetcode,
"book": _book,
"diet": _diet,
"alarm": _alarm,
"focus": _focus,
"padlock": _padlock,
"no_entry": _no_entry,
"warning": _warning,
"hourglass": _hourglass,
"sick": _sick,
"normal": _normal,
"dot": _dot,
"ring": _ring,
}
#: The pictogram each locker is shown as, in place of its name.
LOCKER_ICONS: Final = {
LOCKER_WORKOUT: "workout",
LOCKER_LEETCODE: "leetcode",
LOCKER_BOOK: "book",
LOCKER_DIET: "diet",
LOCKER_ALARM: "alarm",
LOCKER_FOCUS: "focus",
}
def draw(
canvas: tk.Canvas,
icon: str,
*,
size: int,
colour: str,
at: tuple[int, int] = (0, 0),
) -> None:
"""Draw ``icon`` on ``canvas`` in a ``size`` px square whose corner is ``at``."""
ICONS[icon](Pen(canvas, size=size, colour=colour, at=at))

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki # Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: a switch per locker for one day, and the buttons that write them. """KeepPanel: the five switches for one day, and the buttons that write them.
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call, mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
@ -7,34 +7,29 @@ so screen-locker can drop it onto its fullscreen surface in-process, under
its own X grab (a separate window could not get input there). its own X grab (a separate window could not get input there).
On is "this locker still applies", off is "stands down", all off by default On is "this locker still applies", off is "stands down", all off by default
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a -- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot day, or a free-day pool that cannot be checked shows why and offers no
be checked (warning) shows its icon and offers no write. A refused write is write. A refused write is shown in the panel, never raised: an exception in a
shown in the panel as an icon, never raised: an exception in a Tk callback Tk callback only reaches stderr, which nobody sees under a lock.
only reaches stderr, which nobody sees under a lock. The panel shows no
words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import threading import threading
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
from sickdays._constants import LOCKERS from sickdays._constants import LOCKERS
from sickdays._day import to_iso, today from sickdays._day import today
from sickdays._errors import SickDayError from sickdays._errors import SickDayError
from sickdays._keep_api import kept_lockers, set_kept_lockers from sickdays._keep_api import kept_lockers, set_kept_lockers
from sickdays._paths import resolve_paths from sickdays._paths import resolve_paths
from sickdays._sync import sync_quietly from sickdays._sync import sync_quietly
from sickdays.gui import _dates from sickdays.gui import _text
from sickdays.gui import _tokens as tok from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui from sickdays.gui import _widgets as ui
from sickdays.gui._controls import IconButton, IconSlot, Toggle
from sickdays.gui._icons import LOCKER_ICONS
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -44,17 +39,6 @@ if TYPE_CHECKING:
from sickdays._paths import Paths from sickdays._paths import Paths
_LOG = logging.getLogger(__name__)
#: (icon, colour) for each reason the panel shows, keyed by that reason.
STATUS_ICONS: Final = {
"past": ("padlock", tok.MUTED_ON_DARK),
"free": ("no_entry", tok.DANGER),
"unavailable": ("warning", tok.DANGER),
"refused": ("warning", tok.DANGER),
"busy": ("hourglass", tok.DANGER),
}
#: Held by every background sync, and tried by every write. ``sync()`` reads #: Held by every background sync, and tried by every write. ``sync()`` reads
#: the pool, waits on the network, then writes its merged copy back, so a #: the pool, waits on the network, then writes its merged copy back, so a
#: write landing in between would be silently overwritten. A write that finds #: write landing in between would be silently overwritten. A write that finds
@ -89,17 +73,16 @@ class KeepPanel(tk.Frame):
``on_saved`` is called once per successful click, after the write and ``on_saved`` is called once per successful click, after the write and
after the background sync has started: after the background sync has started:
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``); * "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``); * "Save" with a changed set -> the stored entry (``is_sick=True``);
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync; * "Save" with nothing changed -> ``None``: nothing written, no sync;
* ``actions.clear`` -> the cleared entry (``is_sick=False``). * "Clear sick day" -> the cleared entry (``is_sick=False``).
A refused write (:class:`~sickdays.SickDayError`, or a click while the A refused write (:class:`~sickdays.SickDayError`, or a click while the
previous write's sync is still running) is shown in the panel's previous write's sync is still running) is shown in the panel and
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it ``on_saved`` is not called. ``on_cancel``, when given, adds a
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an "Cancel" button that calls it. The panel never destroys itself; the
``actions.cancel`` cross that calls it. The panel never destroys itself; callbacks decide what happens to it.
the callbacks decide what happens to it.
""" """
def __init__( def __init__(
@ -124,15 +107,12 @@ class KeepPanel(tk.Frame):
} }
#: The last background sync, for callers (and tests) that wait on it. #: The last background sync, for callers (and tests) that wait on it.
self.sync_thread: threading.Thread | None = None self.sync_thread: threading.Thread | None = None
#: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``.
self.reason: str | None = None
blocked = self._blocked_reason() blocked = self._blocked_reason()
self._draw_switches(editable=blocked is None) self._draw_switches(editable=blocked is None)
self._status = IconSlot(self, name="status") self._status = ui.label(
self._status.grid( self, blocked or "", "label", fg=tok.DANGER, name="status"
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
) )
self._show(blocked) self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
self._draw_actions(writable=blocked is None, on_cancel=on_cancel) self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
def kept(self) -> frozenset[str]: def kept(self) -> frozenset[str]:
@ -141,54 +121,49 @@ class KeepPanel(tk.Frame):
def _blocked_reason(self) -> str | None: def _blocked_reason(self) -> str | None:
if self._day < today(): if self._day < today():
return "past" return _text.PAST_NOTE
return None if self._sick else _dates.free_day_block(self._day) return None if self._sick else _text.free_day_refusal(self._day)
def _show(self, reason: str | None) -> None:
self.reason = reason
if reason is None:
self._status.show(None)
return
icon, colour = STATUS_ICONS[reason]
self._status.show(icon, colour)
def _draw_switches(self, *, editable: bool) -> None: def _draw_switches(self, *, editable: bool) -> None:
head = tk.Frame(self, name="head", bg=self.cget("bg")) title = _text.panel_title(self._day, sick=self._sick)
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM)) ui.label(self, title, "subtitle", name="title").grid(
IconSlot(head, name="state").show( row=0, column=0, columnspan=2, sticky="w"
"sick" if self._sick else "normal", tok.SICK )
).pack(side="left", padx=(0, tok.SM)) ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left") row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
for row, locker in enumerate(LOCKERS, start=1): )
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid( for row, locker in enumerate(LOCKERS, start=2):
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
row=row, column=0, sticky="w", pady=tok.XS row=row, column=0, sticky="w", pady=tok.XS
) )
switch = Toggle(self, self._vars[locker], name=f"keep-{locker}") switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS) switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
if not editable: if not editable:
switch.set_enabled(False) switch.configure(state="disabled")
def _draw_actions( def _draw_actions(
self, *, writable: bool, on_cancel: Callable[[], None] | None self, *, writable: bool, on_cancel: Callable[[], None] | None
) -> None: ) -> None:
actions = tk.Frame(self, name="actions", bg=self.cget("bg")) actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
actions.grid( actions.grid(
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0) row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
) )
buttons: list[IconButton] = [] buttons: list[tk.Button] = []
if writable and self._sick: if writable and self._sick:
buttons.append( buttons.append(
IconButton(actions, "check", self._save, kind="primary", name="save") ui.button(actions, "Save", self._save, kind="primary", name="save")
) )
buttons.append( buttons.append(
IconButton(actions, "trash", self._clear, kind="danger", name="clear") ui.button(
actions, "Clear sick day", self._clear, kind="danger", name="clear"
)
) )
elif writable: elif writable:
buttons.append( buttons.append(
IconButton(actions, "check", self._mark, kind="primary", name="mark") ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
) )
if on_cancel is not None: if on_cancel is not None:
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel")) buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
for button in buttons: for button in buttons:
button.pack(side="left", padx=(0, tok.SM)) button.pack(side="left", padx=(0, tok.SM))
@ -212,13 +187,12 @@ class KeepPanel(tk.Frame):
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for # Non-blocking on purpose (no ``with``): waiting would freeze Tk for
# the whole network round trip. # the whole network round trip.
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
self._show("busy") self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
return return
try: try:
entry = action() entry = action()
except SickDayError as exc: except SickDayError as exc:
_LOG.warning("sick-day write refused: %s", exc) self._status.configure(text=str(exc), fg=tok.DANGER)
self._show("refused")
return return
finally: finally:
_SYNC_LOCK.release() _SYNC_LOCK.release()

View File

@ -0,0 +1,125 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Wording and calendar arithmetic for the picker -- no Tk in here.
Every string is the phone's (``wake-alarm/phone_app/lib/screens/
sick_day_sheet.dart`` and ``sick_days_body.dart``), so the two pickers read
the same. Kept here, apart from the widgets, so it is testable without a
display.
"""
from __future__ import annotations
import calendar
from datetime import date
from typing import TYPE_CHECKING, Final
from sickdays._conflict import free_day_predicate
from sickdays._constants import (
LOCKER_ALARM,
LOCKER_BOOK,
LOCKER_DIET,
LOCKER_FOCUS,
LOCKER_LEETCODE,
LOCKER_WORKOUT,
)
from sickdays._day import to_iso
from sickdays._errors import ConflictCheckUnavailableError
if TYPE_CHECKING:
from collections.abc import Iterable
#: What each locker is called on screen, in ``sickdays.LOCKERS`` order.
LOCKER_LABELS: Final = {
LOCKER_WORKOUT: "Workout lock",
LOCKER_LEETCODE: "LeetCode",
LOCKER_BOOK: "Book",
LOCKER_DIET: "Diet",
LOCKER_ALARM: "Wake alarm",
LOCKER_FOCUS: "Focus",
}
SHEET_HELP: Final = (
"Off = stands down for the day. Switch on any locker that should still "
"apply; it then behaves exactly as on a normal day."
)
INTRO: Final = (
"Click a day to mark it sick and choose which lockers still apply; click "
"a sick day to change them or clear it. Today or later only. No limit and "
"no reason needed."
)
INTRO_DETAIL: Final = (
"On a sick day every locker left off stands down: no alarm, no lock, and "
"work left undone is dropped rather than carried forward. A locker "
"switched on behaves as on a normal day; a dot marks a sick day that "
"keeps one."
)
PAST_NOTE: Final = "This day has passed; it can no longer be changed."
NONE_AHEAD: Final = "None ahead."
BUSY_NOTE: Final = "Syncing \u2014 try again in a moment."
WEEKDAY_INITIALS: Final = ("M", "T", "W", "T", "F", "S", "S")
_WEEKDAYS: Final = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
_WORKDAYS: Final = frozenset({1, 2, 3})
def day_label(day: date) -> str:
"""``"Tue 2026-10-20"``."""
return f"{_WEEKDAYS[day.weekday()]} {to_iso(day)}"
def kept_label(kept: Iterable[str]) -> str:
"""The kept lockers as one line, in :data:`LOCKER_LABELS` order."""
chosen = set(kept)
return ", ".join(label for key, label in LOCKER_LABELS.items() if key in chosen)
def panel_title(day: date, *, sick: bool) -> str:
"""``"Mark sick: Tue 2026-10-20"``, or ``"Sick day: ..."`` when editing."""
return f"{'Sick day' if sick else 'Mark sick'}: {day_label(day)}"
def free_day_refusal(day: date) -> str | None:
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
"Could not check" refuses too, with the library's own message: it must
never count as "not a free day".
"""
try:
is_free = free_day_predicate()
except ConflictCheckUnavailableError as exc:
return str(exc)
if not is_free(day):
return None
return f"{to_iso(day)} is a free day — release it in Free days first."
def is_workday(day: date) -> bool:
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
return day.weekday() in _WORKDAYS
def month_title(month: date) -> str:
"""``"October 2026"``."""
return f"{calendar.month_name[month.month]} {month.year}"
def month_weeks(month: date) -> list[list[date | None]]:
"""The month as Monday-first weeks, ``None`` padding either end."""
return [
[None if not cell else date(month.year, month.month, cell) for cell in week]
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
month.year, month.month
)
]
def shift_month(month: date, step: int) -> date:
"""The first of the month ``step`` months after ``month``."""
index = month.year * 12 + month.month - 1 + step
return date(index // 12, index % 12 + 1, 1)
def month_bounds(reference: date) -> tuple[date, date]:
"""January this year to December next year, the phone's range."""
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)

View File

@ -1,20 +1,33 @@
# Copyright (c) 2026 Krzysztof Rudnicki # Copyright (c) 2026 Krzysztof Rudnicki
"""Token-styled Tk labels for the numerals the picker shows (dates, months). """Token-styled Tk building blocks shared by the panel and the window.
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
icons are word-free Canvases in :mod:`sickdays.gui._controls`. filled control is picked from its fill (``on-fill`` on the accent, never the
near-white body text), the bug the design system's README warns about.
""" """
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import font as tkfont from tkinter import font as tkfont
from typing import Final from typing import TYPE_CHECKING, Final, Literal
from sickdays.gui import _tokens as tok from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
from collections.abc import Callable
ButtonKind = Literal["primary", "danger", "plain"]
_BOLD_ROLES: Final = frozenset({"title", "subtitle"}) _BOLD_ROLES: Final = frozenset({"title", "subtitle"})
#: (background, foreground) per button kind. Danger is a text button, as on
#: the phone, so its red sits on the dark surface rather than under ink.
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
"primary": (tok.ACCENT, tok.ON_FILL),
"danger": (tok.INK_RAISED_2, tok.DANGER),
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
}
def family(widget: tk.Misc) -> str: def family(widget: tk.Misc) -> str:
"""The family Tk's default font actually resolves to on this display.""" """The family Tk's default font actually resolves to on this display."""
@ -29,7 +42,10 @@ def label(
fg: str = tok.TEXT_ON_DARK, fg: str = tok.TEXT_ON_DARK,
name: str = "", name: str = "",
) -> tk.Label: ) -> tk.Label:
"""A label on ``parent``'s own background; titles and subtitles are bold.""" """A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
Titles and subtitles are bold, everything else regular.
"""
return tk.Label( return tk.Label(
parent, parent,
name=name, name=name,
@ -39,4 +55,71 @@ def label(
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES), font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
justify="left", justify="left",
anchor="w", anchor="w",
wraplength=tok.WRAP,
) )
def button(
parent: tk.Misc,
text: str,
command: Callable[[], object],
*,
kind: ButtonKind = "plain",
name: str = "",
) -> tk.Button:
"""A flat button whose text colour follows its fill."""
bg, fg = _BUTTON_COLOURS[kind]
return tk.Button(
parent,
name=name,
text=text,
command=command,
bg=bg,
fg=fg,
activebackground=bg,
activeforeground=fg,
font=tok.font(family(parent), "body", bold=kind == "primary"),
relief="flat",
borderwidth=0,
highlightthickness=0,
padx=tok.MD,
pady=tok.SM,
cursor="hand2",
)
def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None:
"""Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off"."""
bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK)
switch.configure(
text="On" if on else "Off",
bg=bg,
selectcolor=bg,
activebackground=bg,
fg=fg,
activeforeground=fg,
)
def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton:
"""A Checkbutton drawn as an On/Off switch, repainted on every change."""
switch = tk.Checkbutton(
parent,
name=name,
variable=variable,
onvalue=True,
offvalue=False,
indicatoron=False,
width=4,
font=tok.font(family(parent), "label", bold=True),
relief="flat",
borderwidth=0,
highlightthickness=0,
padx=tok.SM,
pady=tok.XS,
disabledforeground=tok.MUTED_ON_DARK,
cursor="hand2",
)
switch.configure(command=lambda: paint_toggle(switch, on=variable.get()))
paint_toggle(switch, on=variable.get())
return switch

View File

@ -2,33 +2,26 @@
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk. """``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
the clicked day, the sick days still ahead underneath (each an ISO date and the clicked day, the sick days still ahead underneath. The window class is
the pictograms of the lockers it keeps). The window class is ``sickdays`` ``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window`` ``for_window`` rule can float it; ``Escape`` closes it.
rule can float it; ``Escape`` closes it.
No words inside the window (kuhy's textgate rule). The title bar keeps the
app's name: an OS-level app name is a proper noun (textgate settled
decision 4), the same as ``sickdays.desktop``'s ``Name=``.
""" """
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING
from sickdays._api import is_sick_day, load from sickdays._api import is_sick_day, load
from sickdays._day import to_iso, today from sickdays._day import today
from sickdays._keep_api import kept_lockers from sickdays._keep_api import kept_lockers
from sickdays._model import sick_entries from sickdays._model import sick_entries
from sickdays._paths import resolve_paths from sickdays._paths import resolve_paths
from sickdays.gui import _dates from sickdays.gui import _text
from sickdays.gui import _tokens as tok from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui from sickdays.gui import _widgets as ui
from sickdays.gui._calendar import MonthCalendar from sickdays.gui._calendar import MonthCalendar
from sickdays.gui._controls import IconSlot from sickdays.gui._panel import KeepPanel
from sickdays.gui._icons import LOCKER_ICONS
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import date from datetime import date
@ -37,18 +30,19 @@ if TYPE_CHECKING:
from sickdays._paths import Paths from sickdays._paths import Paths
WINDOW_CLASS = "sickdays" WINDOW_CLASS = "sickdays"
WINDOW_TITLE = "Sick days"
#: A kept locker's pictogram in the list of days ahead, in px.
_KEPT_PX: Final = 16
def saved_icon(entry: SickDay | None) -> str | None: def saved_message(entry: SickDay | None, day: date) -> str:
"""The status mark after the panel closed with ``entry``. """The status line after the panel closed with ``entry``."""
label = _text.day_label(day)
A check for any write (the grid and the list show what it was); nothing if entry is None:
when the panel was saved unchanged. return f"{label}: nothing changed"
""" if not entry.is_sick:
return None if entry is None else "check" return f"{label} is a normal day again"
kept = _text.kept_label(entry.keep)
return f"{label} is a sick day — " + (
f"kept: {kept}" if kept else "every locker stands down"
)
class SickDaysApp: class SickDaysApp:
@ -58,7 +52,7 @@ class SickDaysApp:
"""Style ``root`` and draw the current pool.""" """Style ``root`` and draw the current pool."""
self._root = root self._root = root
self._paths = resolve_paths(paths) self._paths = resolve_paths(paths)
root.title(WINDOW_TITLE) root.title("Sick days")
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG) root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
root.bind("<Escape>", self.close) root.bind("<Escape>", self.close)
left = tk.Frame(root, bg=tok.INK) left = tk.Frame(root, bg=tok.INK)
@ -66,9 +60,13 @@ class SickDaysApp:
#: Where the clicked day's panel goes. #: Where the clicked day's panel goes.
self.host = tk.Frame(root, name="host", bg=tok.INK) self.host = tk.Frame(root, name="host", bg=tok.INK)
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0)) self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
#: The last outcome's mark: a check, or the panel's refusal icons. ui.label(left, "Sick days", "title").pack(anchor="w")
self.status = IconSlot(left, name="status") ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
self.status.pack(anchor="w") ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
anchor="w", pady=(tok.SM, 0)
)
self.status = ui.label(left, "", "label", name="status")
self.status.pack(anchor="w", pady=(tok.SM, 0))
log = self._paths.log log = self._paths.log
self.calendar = MonthCalendar( self.calendar = MonthCalendar(
left, left,
@ -77,8 +75,8 @@ class SickDaysApp:
has_dot=lambda day: bool(kept_lockers(day, log_path=log)), has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
on_day=self.select, on_day=self.select,
) )
self.calendar.pack(anchor="w", pady=(tok.SM, 0)) self.calendar.pack(anchor="w", pady=(tok.MD, 0))
IconSlot(left, name="ahead").show("sick", tok.SICK).pack( ui.label(left, "Marked sick", "subtitle").pack(
anchor="w", pady=(tok.LG, tok.SM) anchor="w", pady=(tok.LG, tok.SM)
) )
self._list = tk.Frame(left, name="upcoming", bg=tok.INK) self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
@ -90,25 +88,25 @@ class SickDaysApp:
self._root.destroy() self._root.destroy()
def select(self, day: date) -> None: def select(self, day: date) -> None:
"""Open the panel for ``day``, or refuse a free day in the status mark.""" """Open the panel for ``day``, or refuse a free day in the status line."""
self._close_panel() self._close_panel()
if not is_sick_day(day, log_path=self._paths.log): if not is_sick_day(day, log_path=self._paths.log):
block = _dates.free_day_block(day) refusal = _text.free_day_refusal(day)
if block is not None: if refusal is not None:
self.status.show(*STATUS_ICONS[block]) self._set_status(refusal, failed=True)
return return
self.status.show(None) self._set_status("", failed=False)
KeepPanel( KeepPanel(
self.host, self.host,
day, day,
on_saved=self._saved, on_saved=lambda entry: self._saved(entry, day),
on_cancel=self._close_panel, on_cancel=self._close_panel,
paths=self._paths, paths=self._paths,
).pack(anchor="n") ).pack(anchor="n")
def _saved(self, entry: SickDay | None) -> None: def _saved(self, entry: SickDay | None, day: date) -> None:
self._close_panel() self._close_panel()
self.status.show(saved_icon(entry), tok.SUCCESS) self._set_status(saved_message(entry, day), failed=False)
self.calendar.redraw() self.calendar.redraw()
self._draw_upcoming() self._draw_upcoming()
@ -116,20 +114,29 @@ class SickDaysApp:
for child in self.host.winfo_children(): for child in self.host.winfo_children():
child.destroy() child.destroy()
def _set_status(self, text: str, *, failed: bool) -> None:
self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS)
def _draw_upcoming(self) -> None: def _draw_upcoming(self) -> None:
for child in self._list.winfo_children(): for child in self._list.winfo_children():
child.destroy() child.destroy()
reference = today() reference = today()
for entry in sick_entries(load(self._paths.log)): upcoming = [
if entry.day < reference: entry
continue for entry in sick_entries(load(self._paths.log))
row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK) if entry.day >= reference
row.pack(anchor="w", pady=(0, tok.XS)) ]
ui.label(row, to_iso(entry.day), "body").pack(side="left") if not upcoming:
for locker in _dates.kept_in_order(entry.keep): ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show( anchor="w"
LOCKER_ICONS[locker], tok.MUTED_ON_DARK )
).pack(side="left", padx=(tok.SM, 0)) for entry in upcoming:
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
kept = _text.kept_label(entry.keep)
if kept:
ui.label(
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
).pack(anchor="w", pady=(0, tok.XS))
def run(*, paths: Paths | None = None) -> int: def run(*, paths: Paths | None = None) -> int:

View File

@ -61,7 +61,7 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
for module in _MODULES: for module in _MODULES:
monkeypatch.setattr(f"{module}.resolve_paths", _resolve) monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free) monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", lambda: _never_free) monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free)
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync) monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)

View File

@ -1,70 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The word-free controls and the drawn icons, against a real Tk."""
from __future__ import annotations
import tkinter as tk
import pytest
from sickdays._constants import LOCKERS
from sickdays.gui import _tokens as tok
from sickdays.gui._controls import IconButton, IconSlot, Toggle
from sickdays.gui._icons import ICONS, LOCKER_ICONS, TAG
@pytest.fixture
def shown(tk_root: tk.Tk) -> tk.Tk:
"""The root mapped, so real clicks reach the canvases."""
tk_root.deiconify()
tk_root.update()
return tk_root
def _click(widget: tk.Canvas) -> None:
widget.pack()
widget.update()
widget.event_generate("<Button-1>", x=2, y=2)
widget.update()
def test_every_icon_draws_something(tk_root: tk.Tk) -> None:
for icon in ICONS:
slot = IconSlot(tk_root, size=32).show(icon, tok.DANGER)
assert slot.icon == icon
assert slot.find_withtag(TAG), icon
assert slot.show(None).icon is None
assert slot.find_withtag(TAG) == ()
def test_every_locker_has_an_icon() -> None:
assert set(LOCKER_ICONS) == set(LOCKERS)
assert set(LOCKER_ICONS.values()) <= set(ICONS)
def test_a_click_invokes_the_button_unless_disabled(shown: tk.Tk) -> None:
calls: list[int] = []
button = IconButton(shown, "check", lambda: calls.append(1), name="ok")
button.set_enabled(False)
assert str(button.cget("cursor")) == "arrow"
_click(button)
button.invoke()
assert calls == []
button.set_enabled(True)
_click(button)
assert calls == [1]
def test_a_click_flips_the_switch_unless_disabled(shown: tk.Tk) -> None:
on = tk.BooleanVar(shown, value=False)
switch = Toggle(shown, on, name="switch")
_click(switch)
assert switch.is_on()
assert on.get()
switch.set_enabled(False)
_click(switch)
switch.invoke()
assert switch.is_on()
switch.set_enabled(True)
switch.invoke()
assert not switch.is_on()

View File

@ -1,87 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The picker's calendar arithmetic, free-day check and tokens -- no display."""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING
from sickdays._errors import ConflictCheckUnavailableError
from sickdays.gui import _dates
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
import pytest
DAY = date(2026, 10, 20)
def test_kept_lockers_follow_locker_order_not_the_alphabet() -> None:
assert _dates.kept_in_order({"alarm", "book", "workout"}) == (
"workout",
"book",
"alarm",
)
assert _dates.kept_in_order(frozenset()) == ()
def test_free_day_block(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
assert _dates.free_day_block(DAY) is None
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
)
assert _dates.free_day_block(DAY) == "free"
def unavailable() -> None:
msg = "freedays is not installed"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
with caplog.at_level(logging.WARNING):
assert _dates.free_day_block(DAY) == "unavailable"
assert "freedays is not installed" in caplog.text
def test_month_grid_is_monday_first() -> None:
weeks = _dates.month_weeks(date(2026, 10, 1))
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
assert weeks[-1][-1] is None
assert _dates.month_title(date(2026, 10, 1)) == "2026-10"
assert _dates.month_title(date(2027, 3, 1)) == "2027-03"
assert [_dates.is_weekend_column(c) for c in range(7)] == [
False,
False,
False,
False,
False,
True,
True,
]
def test_month_arithmetic_and_range() -> None:
assert _dates.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
assert _dates.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
assert _dates.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
def test_workdays_are_tuesday_to_thursday() -> None:
assert [_dates.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
False,
True,
True,
True,
False,
False,
False,
]
def test_tokens() -> None:
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK

View File

@ -1,113 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The textgate guard: no letter on any widget of the picker, in any state.
Everything this window shows is chrome except numerals and ISO dates (day
numbers, ``2026-11``, ``2026-11-13``), so the guard bans every letter, not
just words. The window title is the app's proper noun (textgate settled
decision 4) and is checked on its own.
"""
from __future__ import annotations
from datetime import timedelta
import re
import tkinter as tk
from typing import TYPE_CHECKING
from sickdays._api import add_sick_day
from sickdays._day import today
from sickdays._errors import ConflictCheckUnavailableError
from sickdays.gui import KeepPanel, SickDaysApp, _panel
if TYPE_CHECKING:
import pytest
FUTURE = today() + timedelta(days=3)
LETTER = re.compile(r"[^\W\d_]")
def _shown(widget: tk.Misc) -> list[str]:
"""Every string ``widget`` and its descendants paint."""
found: list[str] = []
for child in widget.winfo_children():
if isinstance(child, tk.Canvas):
# Raw Tcl: typeshed types Canvas.type()/itemcget() too loosely.
path = str(child)
found.extend(
str(child.tk.call(path, "itemcget", item, "-text"))
for item in child.find_all()
if str(child.tk.call(path, "type", item)) == "text"
)
elif isinstance(child, (tk.Label, tk.Button, tk.Checkbutton)):
found.append(str(child.cget("text")))
found.extend(_shown(child))
return found
def _assert_no_letters(widget: tk.Misc) -> None:
shown = _shown(widget)
assert shown, "the guard walked no widget"
worded = [text for text in shown if LETTER.search(text)]
assert worded == []
def test_the_window_title_is_the_app_name(tk_root: tk.Tk) -> None:
SickDaysApp(tk_root)
assert tk_root.title() == "Sick days"
def test_the_window_shows_no_letters(tk_root: tk.Tk) -> None:
add_sick_day(FUTURE, keep=["alarm", "workout"])
app = SickDaysApp(tk_root)
_assert_no_letters(tk_root)
app.select(FUTURE + timedelta(days=1))
_assert_no_letters(tk_root)
def test_the_window_after_a_refusal_shows_no_letters(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
)
SickDaysApp(tk_root).select(FUTURE)
_assert_no_letters(tk_root)
def test_the_window_after_a_save_shows_no_letters(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
app.select(FUTURE)
(panel,) = app.host.winfo_children()
panel.nametowidget("actions.mark").invoke()
assert isinstance(panel, KeepPanel)
assert panel.sync_thread is not None
panel.sync_thread.join(timeout=5)
_assert_no_letters(tk_root)
def test_every_panel_state_shows_no_letters(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
add_sick_day(FUTURE, keep=["book"])
for day in (FUTURE, FUTURE + timedelta(days=1), today() - timedelta(days=1)):
_assert_no_letters(
KeepPanel(tk_root, day, on_saved=lambda _e: None, on_cancel=lambda: None)
)
def unavailable() -> None:
msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
_assert_no_letters(KeepPanel(tk_root, FUTURE + timedelta(days=2), on_saved=print))
def test_a_refused_or_busy_write_shows_no_letters(tk_root: tk.Tk) -> None:
refused = KeepPanel(tk_root, FUTURE + timedelta(days=4), on_saved=print)
add_sick_day(FUTURE + timedelta(days=4))
refused.nametowidget("actions.mark").invoke()
_assert_no_letters(refused)
busy = KeepPanel(tk_root, FUTURE + timedelta(days=5), on_saved=print)
with _panel._SYNC_LOCK:
busy.nametowidget("actions.mark").invoke()
_assert_no_letters(busy)

View File

@ -1,27 +1,26 @@
# Copyright (c) 2026 Krzysztof Rudnicki # Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk.""" """KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging
import threading import threading
import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from sickdays._api import add_sick_day, is_sick_day, load from sickdays._api import add_sick_day, is_sick_day, load
from sickdays._day import to_iso, today from sickdays._day import today
from sickdays._errors import ConflictCheckUnavailableError from sickdays._errors import ConflictCheckUnavailableError
from sickdays._keep_api import kept_lockers from sickdays._keep_api import kept_lockers
from sickdays._paths import Paths from sickdays._paths import Paths
from sickdays._store import write_pool from sickdays._store import write_pool
from sickdays.gui import KeepPanel from sickdays.gui import KeepPanel, _text
from sickdays.gui._controls import IconButton, IconSlot, Toggle from sickdays.gui import _tokens as tok
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
import tkinter as tk
from sickdays._model import SickDay from sickdays._model import SickDay
@ -41,22 +40,20 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
return calls return calls
def _button(panel: KeepPanel, name: str) -> IconButton: def _button(panel: KeepPanel, name: str) -> tk.Button:
widget = panel.nametowidget(f"actions.{name}") widget = panel.nametowidget(f"actions.{name}")
assert isinstance(widget, IconButton) assert isinstance(widget, tk.Button)
return widget return widget
def _toggle(panel: KeepPanel, locker: str) -> Toggle: def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
widget = panel.nametowidget(f"keep-{locker}") widget = panel.nametowidget(f"keep-{locker}")
assert isinstance(widget, Toggle) assert isinstance(widget, tk.Checkbutton)
return widget return widget
def _status(panel: KeepPanel) -> str | None: def _status(panel: KeepPanel) -> str:
slot = panel.nametowidget("status") return str(panel.nametowidget("status").cget("text"))
assert isinstance(slot, IconSlot)
return slot.icon
def _actions(panel: KeepPanel) -> set[str]: def _actions(panel: KeepPanel) -> set[str]:
@ -75,11 +72,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None])
assert panel.kept() == frozenset() assert panel.kept() == frozenset()
assert _actions(panel) == {"mark"} assert _actions(panel) == {"mark"}
toggle = _toggle(panel, "leetcode") toggle = _toggle(panel, "leetcode")
assert not toggle.is_on() assert toggle.cget("text") == "Off"
toggle.invoke() toggle.invoke()
assert toggle.is_on() assert toggle.cget("text") == "On"
assert _button(panel, "mark").icon == "check" assert str(toggle.cget("bg")).upper() == tok.ACCENT
assert _status(panel) is None assert str(toggle.cget("fg")).upper() == tok.ON_FILL
_button(panel, "mark").invoke() _button(panel, "mark").invoke()
_join(panel) _join(panel)
assert is_sick_day(FUTURE) assert is_sick_day(FUTURE)
@ -96,14 +93,8 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None) panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
assert panel.kept() == frozenset({"workout"}) assert panel.kept() == frozenset({"workout"})
assert _actions(panel) == {"save", "clear"} assert _actions(panel) == {"save", "clear"}
assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE) title = panel.nametowidget("title").cget("text")
state = panel.nametowidget("head.state") assert title == f"Sick day: {_text.day_label(FUTURE)}"
assert isinstance(state, IconSlot)
assert state.icon == "sick"
assert _button(panel, "clear").icon == "trash"
icon = panel.nametowidget("icon-workout")
assert isinstance(icon, IconSlot)
assert icon.icon == "workout"
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None: def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
@ -148,27 +139,20 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None])
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None: def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None) panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
assert panel.reason == "past" assert _status(panel) == _text.PAST_NOTE
assert _status(panel) == "padlock"
assert _actions(panel) == set() assert _actions(panel) == set()
diet = _toggle(panel, "diet") assert str(_toggle(panel, "diet").cget("state")) == "disabled"
assert str(diet.cget("state")) == "disabled"
diet.invoke()
assert not diet.is_on()
def test_a_free_day_is_refused_with_the_phones_message( def test_a_free_day_is_refused_with_the_phones_message(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
) )
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None) panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
assert panel.reason == "free" expected = "is a free day — release it in Free days first."
assert _status(panel) == "no_entry" assert _status(panel).endswith(expected)
state = panel.nametowidget("head.state")
assert isinstance(state, IconSlot)
assert state.icon == "normal"
assert _actions(panel) == set() assert _actions(panel) == set()
@ -179,24 +163,20 @@ def test_an_uncheckable_free_pool_refuses(
msg = "cannot check the free-day pool" msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg) raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable) monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None) panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
assert panel.reason == "unavailable" assert _status(panel) == "cannot check the free-day pool"
assert _status(panel) == "warning"
assert _actions(panel) == set() assert _actions(panel) == set()
def test_a_refused_write_is_shown_not_raised( def test_a_refused_write_is_shown_not_raised(
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture tk_root: tk.Tk, synced: list[Paths | None]
) -> None: ) -> None:
saved: list[SickDay | None] = [] saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append) panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
add_sick_day(FUTURE) # marked elsewhere while the panel was open add_sick_day(FUTURE) # marked elsewhere while the panel was open
with caplog.at_level(logging.WARNING): _button(panel, "mark").invoke()
_button(panel, "mark").invoke() assert "already a sick day" in _status(panel)
assert panel.reason == "refused"
assert _status(panel) == "warning"
assert "already a sick day" in caplog.text
assert saved == [] assert saved == []
assert synced == [] assert synced == []
@ -209,9 +189,7 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
on_saved=lambda _e: None, on_saved=lambda _e: None,
on_cancel=lambda: cancelled.append(True), on_cancel=lambda: cancelled.append(True),
) )
cancel = _button(panel, "cancel") _button(panel, "cancel").invoke()
assert cancel.icon == "cross"
cancel.invoke()
assert cancelled == [True] assert cancelled == [True]
@ -264,5 +242,4 @@ def test_a_write_during_a_sync_is_refused_not_lost(
for entry in saved: for entry in saved:
assert entry is not None assert entry is not None
assert is_sick_day(entry.day), f"{entry.day} was written, then lost" assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
assert second.reason == "busy" assert _status(second) == _text.BUSY_NOTE
assert _status(second) == "hourglass"

View File

@ -0,0 +1,86 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The picker's wording, calendar arithmetic and tokens -- no display needed."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
from sickdays._errors import ConflictCheckUnavailableError
from sickdays.gui import _text
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
import pytest
DAY = date(2026, 10, 20)
def test_labels_match_the_phone() -> None:
assert _text.day_label(DAY) == "Tue 2026-10-20"
assert _text.panel_title(DAY, sick=False) == "Mark sick: Tue 2026-10-20"
assert _text.panel_title(DAY, sick=True) == "Sick day: Tue 2026-10-20"
assert list(_text.LOCKER_LABELS.values()) == [
"Workout lock",
"LeetCode",
"Book",
"Diet",
"Wake alarm",
"Focus",
]
def test_kept_label_follows_locker_order_not_the_alphabet() -> None:
assert _text.kept_label({"alarm", "book", "workout"}) == (
"Workout lock, Book, Wake alarm"
)
assert _text.kept_label(frozenset()) == ""
def test_free_day_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
assert _text.free_day_refusal(DAY) is None
monkeypatch.setattr(
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
assert _text.free_day_refusal(DAY) == (
"2026-10-20 is a free day — release it in Free days first."
)
def unavailable() -> None:
msg = "freedays is not installed"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
assert _text.free_day_refusal(DAY) == "freedays is not installed"
def test_month_grid_is_monday_first() -> None:
weeks = _text.month_weeks(date(2026, 10, 1))
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
assert weeks[-1][-1] is None
assert _text.month_title(date(2026, 10, 1)) == "October 2026"
def test_month_arithmetic_and_range() -> None:
assert _text.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
assert _text.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
assert _text.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
def test_workdays_are_tuesday_to_thursday() -> None:
assert [_text.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
False,
True,
True,
True,
False,
False,
False,
]
def test_tokens() -> None:
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK

View File

@ -10,9 +10,8 @@ from typing import TYPE_CHECKING
from sickdays._api import add_sick_day from sickdays._api import add_sick_day
from sickdays._day import to_iso, today from sickdays._day import to_iso, today
from sickdays._model import SickDay from sickdays._model import SickDay
from sickdays.gui import KeepPanel, SickDaysApp, _dates, _window from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
from sickdays.gui import _tokens as tok from sickdays.gui import _tokens as tok
from sickdays.gui._controls import IconButton, IconSlot
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest import pytest
@ -20,19 +19,13 @@ if TYPE_CHECKING:
FUTURE = today() + timedelta(days=3) FUTURE = today() + timedelta(days=3)
def _upcoming(app: SickDaysApp) -> dict[str, list[str]]: def _texts(widget: tk.Misc) -> list[str]:
"""Each listed day's ISO date -> the locker icons it keeps.""" found: list[str] = []
rows = app.calendar.master.nametowidget("upcoming").winfo_children() for child in widget.winfo_children():
listed: dict[str, list[str]] = {} if isinstance(child, tk.Label):
for row in rows: found.append(str(child.cget("text")))
icons = [ found.extend(_texts(child))
str(slot.icon) return found
for slot in row.winfo_children()
if isinstance(slot, IconSlot)
]
(date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)]
listed[str(date_label.cget("text"))] = icons
return listed
def _cell(app: SickDaysApp, day: date) -> tk.Button: def _cell(app: SickDaysApp, day: date) -> tk.Button:
@ -47,17 +40,14 @@ def _panel(app: SickDaysApp) -> KeepPanel:
return panel return panel
def test_an_empty_pool_lists_nothing_ahead(tk_root: tk.Tk) -> None: def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root) app = SickDaysApp(tk_root)
assert tk_root.title() == "Sick days" assert tk_root.title() == "Sick days"
assert _upcoming(app) == {} assert _text.NONE_AHEAD in _texts(tk_root)
assert app.status.icon is None
assert app.host.winfo_children() == [] assert app.host.winfo_children() == []
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None: def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
later = FUTURE + timedelta(days=1)
add_sick_day(later)
app = SickDaysApp(tk_root) app = SickDaysApp(tk_root)
app.calendar.month = FUTURE.replace(day=1) app.calendar.month = FUTURE.replace(day=1)
app.calendar.redraw() app.calendar.redraw()
@ -68,8 +58,10 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
assert panel.sync_thread is not None assert panel.sync_thread is not None
panel.sync_thread.join(timeout=5) panel.sync_thread.join(timeout=5)
assert app.host.winfo_children() == [] assert app.host.winfo_children() == []
assert app.status.icon == "check" assert app.status.cget("text") == _window.saved_message(
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []} SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
)
assert "Kept: LeetCode" in _texts(tk_root)
cell = _cell(app, FUTURE) cell = _cell(app, FUTURE)
assert str(cell.cget("bg")).upper() == tok.SICK assert str(cell.cget("bg")).upper() == tok.SICK
assert cell.nametowidget("dot").winfo_exists() assert cell.nametowidget("dot").winfo_exists()
@ -86,12 +78,13 @@ def test_a_free_day_is_refused_before_the_panel_opens(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
) )
app = SickDaysApp(tk_root) app = SickDaysApp(tk_root)
app.select(FUTURE) app.select(FUTURE)
assert app.host.winfo_children() == [] assert app.host.winfo_children() == []
assert app.status.icon == "no_entry" assert str(app.status.cget("text")).endswith("release it in Free days first.")
assert str(app.status.cget("fg")).upper() == tok.DANGER
def test_a_sick_day_on_a_free_day_still_opens( def test_a_sick_day_on_a_free_day_still_opens(
@ -99,7 +92,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
) -> None: ) -> None:
add_sick_day(FUTURE) add_sick_day(FUTURE)
monkeypatch.setattr( monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True "sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
) )
app = SickDaysApp(tk_root) app = SickDaysApp(tk_root)
app.select(FUTURE) app.select(FUTURE)
@ -109,31 +102,19 @@ def test_a_sick_day_on_a_free_day_still_opens(
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None: def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
add_sick_day(FUTURE, keep=["alarm", "workout"]) add_sick_day(FUTURE, keep=["alarm", "workout"])
add_sick_day(FUTURE + timedelta(days=1)) add_sick_day(FUTURE + timedelta(days=1))
past = today() - timedelta(days=1) texts = _texts(SickDaysApp(tk_root).calendar.master)
add_sick_day(past, now=past) assert _text.day_label(FUTURE) in texts
assert _upcoming(SickDaysApp(tk_root)) == { assert "Kept: Workout lock, Wake alarm" in texts
to_iso(FUTURE): ["workout", "alarm"], assert _text.NONE_AHEAD not in texts
to_iso(FUTURE + timedelta(days=1)): [],
}
def test_saved_icon() -> None: def test_saved_message_wording() -> None:
assert _window.saved_icon(None) is None sick = SickDay(FUTURE, is_sick=True)
assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check" label = _text.day_label(FUTURE)
assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check" assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
cleared = SickDay(FUTURE, is_sick=False)
def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None: assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
app = SickDaysApp(tk_root)
title = app.calendar.nametowidget("header").winfo_children()[-1]
assert isinstance(title, tk.Label)
assert title.cget("text") == _dates.month_title(today().replace(day=1))
marks = [
child.icon
for child in app.calendar.nametowidget("days").winfo_children()
if isinstance(child, IconSlot)
]
assert marks == ["dot"] * 5 + ["ring"] * 2
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None: def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
@ -147,7 +128,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None: def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root) app = SickDaysApp(tk_root)
first, last = _dates.month_bounds(today()) first, last = _text.month_bounds(today())
for _ in range(30): for _ in range(30):
app.calendar.step(1) app.calendar.step(1)
assert app.calendar.month == last assert app.calendar.month == last
@ -155,8 +136,6 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
app.calendar.step(-1) app.calendar.step(-1)
assert app.calendar.month == first assert app.calendar.month == first
back = app.calendar.nametowidget("header.prev") back = app.calendar.nametowidget("header.prev")
assert isinstance(back, IconButton)
assert back.icon == "prev"
assert str(back.cget("state")) == "disabled" assert str(back.cget("state")) == "disabled"

View File

@ -1,56 +0,0 @@
part of 'sync_settings_screen.dart';
/// The screen's glyph vocabulary, in place of written headings and status
/// sentences (kuhy's textgate rule). Every old sentence survives as the
/// spoken label, so screen readers and widget tests still find it.
/// A result: a glyph, the old sentence (spoken only), and whether it is a
/// failure. Colour is the second channel: danger hue for failures.
class _Cue {
const _Cue(this.icon, this.spoken) : error = false;
const _Cue.error(this.icon, this.spoken) : error = true;
final IconData icon;
final String spoken;
final bool error;
}
/// Paints a [_Cue] as a large glyph.
class _CueView extends StatelessWidget {
const _CueView(this.cue);
final _Cue cue;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: Alignment.centerLeft,
child: Icon(
cue.icon,
size: 32,
color: cue.error ? scheme.error : scheme.primary,
semanticLabel: cue.spoken,
),
);
}
}
/// A section heading as a glyph, spoken as [spoken].
class _Heading extends StatelessWidget {
const _Heading(this.icon, this.spoken);
final IconData icon;
final String spoken;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
semanticLabel: spoken,
),
);
}

Some files were not shown because too many files have changed in this diff Show More