mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 16:34:02 +01:00
Compare commits
11 Commits
focus-proj
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0fad2b2e5 | |||
| c83d41a594 | |||
| 2bbfbd9439 | |||
| 2d341dd41e | |||
| 3084080d74 | |||
| 9d6b17fd28 | |||
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a | |||
| afcc1a47c7 |
@ -16,3 +16,4 @@ 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)
|
||||||
|
|||||||
@ -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.1
|
version: 0.2.2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
file_picker: 13.3.2
|
file_picker: 13.3.3
|
||||||
file_selector: 1.1.0
|
file_selector: 1.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day =
|
|||||||
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||||
|
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||||
|
|
||||||
## Adding a gate
|
## Adding a gate
|
||||||
|
|
||||||
|
|||||||
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# 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
|
||||||
143
feedback_inbox/.pre-commit-config.yaml
Normal file
143
feedback_inbox/.pre-commit-config.yaml
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# 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
|
||||||
70
feedback_inbox/README.md
Normal file
70
feedback_inbox/README.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# 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.
|
||||||
2
feedback_inbox/feedback_inbox/__init__.py
Normal file
2
feedback_inbox/feedback_inbox/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Turns every game dev-feedback bundle into one todo-app task."""
|
||||||
15
feedback_inbox/feedback_inbox/__main__.py
Normal file
15
feedback_inbox/feedback_inbox/__main__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 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())
|
||||||
57
feedback_inbox/feedback_inbox/_bundle.py
Normal file
57
feedback_inbox/feedback_inbox/_bundle.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# 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)
|
||||||
80
feedback_inbox/feedback_inbox/_cli.py
Normal file
80
feedback_inbox/feedback_inbox/_cli.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# 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)
|
||||||
74
feedback_inbox/feedback_inbox/_entry.py
Normal file
74
feedback_inbox/feedback_inbox/_entry.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# 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 ("", "")
|
||||||
235
feedback_inbox/feedback_inbox/_inbox.py
Normal file
235
feedback_inbox/feedback_inbox/_inbox.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# 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)
|
||||||
51
feedback_inbox/feedback_inbox/_paths.py
Normal file
51
feedback_inbox/feedback_inbox/_paths.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# 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,
|
||||||
|
)
|
||||||
165
feedback_inbox/feedback_inbox/_todo.py
Normal file
165
feedback_inbox/feedback_inbox/_todo.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# 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
|
||||||
67
feedback_inbox/feedback_inbox/_units.py
Normal file
67
feedback_inbox/feedback_inbox/_units.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# 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
|
||||||
2
feedback_inbox/feedback_inbox/tests/__init__.py
Normal file
2
feedback_inbox/feedback_inbox/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Tests for feedback_inbox."""
|
||||||
31
feedback_inbox/feedback_inbox/tests/conftest.py
Normal file
31
feedback_inbox/feedback_inbox/tests/conftest.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
42
feedback_inbox/feedback_inbox/tests/test_bundle.py
Normal file
42
feedback_inbox/feedback_inbox/tests/test_bundle.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 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)
|
||||||
103
feedback_inbox/feedback_inbox/tests/test_cli.py
Normal file
103
feedback_inbox/feedback_inbox/tests/test_cli.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
# 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
|
||||||
68
feedback_inbox/feedback_inbox/tests/test_entry.py
Normal file
68
feedback_inbox/feedback_inbox/tests/test_entry.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# 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") == ("", "")
|
||||||
194
feedback_inbox/feedback_inbox/tests/test_inbox.py
Normal file
194
feedback_inbox/feedback_inbox/tests/test_inbox.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# 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)
|
||||||
145
feedback_inbox/feedback_inbox/tests/test_inbox_repair.py
Normal file
145
feedback_inbox/feedback_inbox/tests/test_inbox_repair.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# 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
|
||||||
95
feedback_inbox/feedback_inbox/tests/test_support.py
Normal file
95
feedback_inbox/feedback_inbox/tests/test_support.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# 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}"
|
||||||
200
feedback_inbox/feedback_inbox/tests/test_todo.py
Normal file
200
feedback_inbox/feedback_inbox/tests/test_todo.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
# 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")
|
||||||
97
feedback_inbox/feedback_inbox/tests/test_units.py
Normal file
97
feedback_inbox/feedback_inbox/tests/test_units.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# 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"]]
|
||||||
87
feedback_inbox/install.sh
Executable file
87
feedback_inbox/install.sh
Executable file
@ -0,0 +1,87 @@
|
|||||||
|
#!/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 "$@"
|
||||||
175
feedback_inbox/pyproject.toml
Normal file
175
feedback_inbox/pyproject.toml
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
[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"]
|
||||||
10
feedback_inbox/requirements.txt
Normal file
10
feedback_inbox/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# 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
|
||||||
12
feedback_inbox/systemd/feedback-inbox-sync.path
Normal file
12
feedback_inbox/systemd/feedback-inbox-sync.path
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[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
|
||||||
12
feedback_inbox/systemd/feedback-inbox-sync.service
Normal file
12
feedback_inbox/systemd/feedback-inbox-sync.service
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[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
|
||||||
12
feedback_inbox/systemd/feedback-inbox-sync.timer
Normal file
12
feedback_inbox/systemd/feedback-inbox-sync.timer
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[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
|
||||||
12
feedback_inbox/systemd/feedback-inbox.path
Normal file
12
feedback_inbox/systemd/feedback-inbox.path
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[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
|
||||||
15
feedback_inbox/systemd/feedback-inbox.service
Normal file
15
feedback_inbox/systemd/feedback-inbox.service
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[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
|
||||||
@ -36,6 +36,10 @@ uncovers today. A NEW project starting today may replace gates and set any targe
|
|||||||
new obligation, not a reduction (but see the floor).
|
new obligation, not a reduction (but see the floor).
|
||||||
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
||||||
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
||||||
|
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
|
||||||
|
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
|
||||||
|
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
|
||||||
|
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
|
||||||
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
||||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||||
|
|
||||||
|
|||||||
@ -31,10 +31,12 @@ from focus_project._update import Update, apply_update, update_project
|
|||||||
from focus_project.activity import record_activity
|
from focus_project.activity import record_activity
|
||||||
from focus_project.checks import register_source, run_checks
|
from focus_project.checks import register_source, run_checks
|
||||||
from focus_project.evidence import Evidence
|
from focus_project.evidence import Evidence
|
||||||
from focus_project.minutes import minutes_for
|
from focus_project.minutes import minutes_for, unbacked_minutes_for
|
||||||
|
from focus_project.sources.activity import BACKING_FROM
|
||||||
from focus_project.status import status
|
from focus_project.status import status
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BACKING_FROM",
|
||||||
"Evidence",
|
"Evidence",
|
||||||
"Limits",
|
"Limits",
|
||||||
"Milestone",
|
"Milestone",
|
||||||
@ -59,5 +61,6 @@ __all__ = [
|
|||||||
"run_checks",
|
"run_checks",
|
||||||
"stands_down",
|
"stands_down",
|
||||||
"status",
|
"status",
|
||||||
|
"unbacked_minutes_for",
|
||||||
"update_project",
|
"update_project",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is
|
|||||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
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
|
||||||
|
(: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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -15,9 +19,10 @@ from __future__ import annotations
|
|||||||
from datetime import date # noqa: TC003
|
from datetime import date # noqa: TC003
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from focus_project import _api, _clock, carry
|
from focus_project import _api, _clock, carry, sources
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
from focus_project._own import union_seconds
|
from focus_project._own import union_seconds
|
||||||
|
from focus_project.sources import activity
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int:
|
|||||||
return carry.balance(day, moment).counted(project.daily_on(day))
|
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["minutes_for", "union_seconds"]
|
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
|
||||||
|
"""Whole minutes of timer time on ``day`` that the gate did not count.
|
||||||
|
|
||||||
|
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
|
||||||
|
or with an unreadable registry (logged).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project = _api.active(day)
|
||||||
|
if project is None:
|
||||||
|
return 0
|
||||||
|
lo, hi = sources.day_bounds(day)
|
||||||
|
bounds = (lo, min(hi, _clock.now() if now is None else now))
|
||||||
|
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
|
||||||
|
counted = sources.clip(sources.collect(day, project), bounds)
|
||||||
|
except (RegistryError, OSError) as exc:
|
||||||
|
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
|
||||||
|
return 0
|
||||||
|
# |unbacked minus counted| = |unbacked union counted| - |counted|.
|
||||||
|
gap = union_seconds(unbacked + counted) - union_seconds(counted)
|
||||||
|
return int(gap // 60)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]
|
||||||
|
|||||||
@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for
|
|||||||
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
||||||
beat it extends the segment; later it ends the segment at that last beat. It
|
beat it extends the segment; later it ends the segment at that last beat. It
|
||||||
never opens a segment. Other heartbeat sources never chain the timer.
|
never opens a segment. Other heartbeat sources never chain the timer.
|
||||||
|
|
||||||
|
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
|
||||||
|
if real activity of the same project backs it, whole span or nothing. A
|
||||||
|
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
|
||||||
|
accepted LeetCode submission (leetcode projects only); ``browser`` never
|
||||||
|
backs. A span ``(a, b)`` is backed if a backing event lies in
|
||||||
|
``[a - 300, b]``, or between two consecutive backing events at most
|
||||||
|
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
|
||||||
|
rule (every span counts), so paid days never change.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import importlib
|
||||||
|
from itertools import pairwise
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
from focus_project._paths import activity_path
|
from focus_project._paths import activity_path
|
||||||
from focus_project.sources import Interval, heartbeat_interval
|
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from focus_project._model import Project
|
from focus_project._model import Project
|
||||||
|
|
||||||
MAX_GAP_S = 600.0
|
MAX_GAP_S = 600.0
|
||||||
|
BACKING_FROM: Final = date(2026, 11, 8)
|
||||||
|
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
|
||||||
|
BRIDGE_S: Final = 1200.0
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -102,12 +115,50 @@ def sessions(project_id: str) -> list[Interval]:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
|
||||||
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
|
"""Sorted times of the events that may back a timer span.
|
||||||
|
|
||||||
|
The LeetCode ledger is read in its own guard: if it fails, only the
|
||||||
|
submissions are lost, never the heartbeats (fail closed, narrow).
|
||||||
|
"""
|
||||||
|
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
|
||||||
|
try:
|
||||||
|
# Imported lazily: it pulls in earned_time, and the timer must keep
|
||||||
|
# working (heartbeat backing only) where that is not installed.
|
||||||
|
leetcode = importlib.import_module("focus_project.sources.leetcode")
|
||||||
|
times += leetcode.submission_times(project)
|
||||||
|
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
|
||||||
|
_LOG.exception("leetcode backing unavailable, heartbeats only")
|
||||||
|
return sorted(times)
|
||||||
|
|
||||||
|
|
||||||
|
def is_backed(span: Interval, times: list[float]) -> bool:
|
||||||
|
"""Whether a backing event lies in or just before ``span``, or bridges it."""
|
||||||
|
lo, hi = span
|
||||||
|
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
|
||||||
|
return True
|
||||||
|
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
|
||||||
|
|
||||||
|
|
||||||
|
def split_timer(
|
||||||
|
day: date, project: Project, events: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[list[Interval], list[Interval]]:
|
||||||
|
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
|
||||||
|
found = read_events(project.id) if events is None else events
|
||||||
|
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
|
||||||
|
spans = timer_intervals(chain)
|
||||||
|
if day < BACKING_FROM:
|
||||||
|
return spans, []
|
||||||
|
times = backing_times(found, project)
|
||||||
|
backed = [s for s in spans if is_backed(s, times)]
|
||||||
|
return backed, [s for s in spans if s not in backed]
|
||||||
|
|
||||||
|
|
||||||
|
def intervals(day: date, project: Project) -> list[Interval]:
|
||||||
|
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||||
events = read_events(project.id)
|
events = read_events(project.id)
|
||||||
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
|
|
||||||
beats = [e for e in events if e.get("source") != "timer"]
|
beats = [e for e in events if e.get("source") != "timer"]
|
||||||
return [
|
return [
|
||||||
*timer_intervals(timer),
|
*split_timer(day, project, events)[0],
|
||||||
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
def submission_times(project: Project) -> list[float]:
|
||||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
"""Times of verified accepted submissions; [] for a non-leetcode project."""
|
||||||
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
||||||
return []
|
return []
|
||||||
key = ledger.read_key()
|
key = ledger.read_key()
|
||||||
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
|||||||
_LOG.warning("leetcode source: no signing key, contributes 0")
|
_LOG.warning("leetcode source: no signing key, contributes 0")
|
||||||
return []
|
return []
|
||||||
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
||||||
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
|
return [ts for ts in stamps if ts is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||||
|
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||||
|
return [heartbeat_interval(ts) for ts in submission_times(project)]
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from focus_project import _api, _milestones, carry, evidence
|
from focus_project import _api, _milestones, carry, evidence, minutes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -36,7 +36,8 @@ def status(day: date) -> dict[str, Any]:
|
|||||||
|
|
||||||
``minutes`` is what was really worked plus what the day took from the
|
``minutes`` is what was really worked plus what the day took from the
|
||||||
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
||||||
is the capped figure that pays.
|
is the capped figure that pays. ``unbacked_minutes`` is timer time the
|
||||||
|
anti-cheat gate refused (shown as "not counted", never paid).
|
||||||
"""
|
"""
|
||||||
project = _api.active(day)
|
project = _api.active(day)
|
||||||
if project is None:
|
if project is None:
|
||||||
@ -52,6 +53,7 @@ def status(day: date) -> dict[str, Any]:
|
|||||||
"unlock_minutes": None,
|
"unlock_minutes": None,
|
||||||
"ends": None,
|
"ends": None,
|
||||||
"replaces": [],
|
"replaces": [],
|
||||||
|
"unbacked_minutes": 0,
|
||||||
"milestones": [],
|
"milestones": [],
|
||||||
"missed": False,
|
"missed": False,
|
||||||
}
|
}
|
||||||
@ -72,6 +74,7 @@ def status(day: date) -> dict[str, Any]:
|
|||||||
"unlock_minutes": project.unlock_on(day),
|
"unlock_minutes": project.unlock_on(day),
|
||||||
"ends": project.last_day.isoformat(),
|
"ends": project.last_day.isoformat(),
|
||||||
"replaces": list(project.replaces_on(day)),
|
"replaces": list(project.replaces_on(day)),
|
||||||
|
"unbacked_minutes": minutes.unbacked_minutes_for(day),
|
||||||
# Interview projects: [{id, due ISO, state}] and whether any is red.
|
# Interview projects: [{id, due ISO, state}] and whether any is red.
|
||||||
"milestones": rows,
|
"milestones": rows,
|
||||||
"missed": missed,
|
"missed": missed,
|
||||||
|
|||||||
@ -137,6 +137,7 @@ def test_status_active_reports_the_worked_minutes() -> None:
|
|||||||
"unlock_minutes": 30,
|
"unlock_minutes": 30,
|
||||||
"ends": "2026-11-05",
|
"ends": "2026-11-05",
|
||||||
"replaces": ["leetcode"],
|
"replaces": ["leetcode"],
|
||||||
|
"unbacked_minutes": 0,
|
||||||
"milestones": [],
|
"milestones": [],
|
||||||
"missed": False,
|
"missed": False,
|
||||||
}
|
}
|
||||||
@ -160,6 +161,7 @@ def test_status_inactive() -> None:
|
|||||||
"unlock_minutes",
|
"unlock_minutes",
|
||||||
"ends",
|
"ends",
|
||||||
"replaces",
|
"replaces",
|
||||||
|
"unbacked_minutes",
|
||||||
"milestones",
|
"milestones",
|
||||||
"missed",
|
"missed",
|
||||||
}
|
}
|
||||||
|
|||||||
181
focus_project/focus_project/tests/test_backing.py
Normal file
181
focus_project/focus_project/tests/test_backing.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""0.10.0: a timer span earns only if a same-project beat backs it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _api, minutes
|
||||||
|
from focus_project.sources import activity
|
||||||
|
from focus_project.tests import test_minutes as tm
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 8) # BACKING_FROM
|
||||||
|
|
||||||
|
|
||||||
|
def at(hour: int, minute: int = 0) -> float:
|
||||||
|
return tm.at(hour, minute, day=DAY)
|
||||||
|
|
||||||
|
|
||||||
|
def proj() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="HR prep",
|
||||||
|
start=DAY,
|
||||||
|
end=date(2026, 11, 12),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=["leetcode.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(*items: tuple[str, str, float]) -> None:
|
||||||
|
tm.events(*items)
|
||||||
|
|
||||||
|
|
||||||
|
def timer_only() -> None:
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "checkin", at(9, 5)),
|
||||||
|
("timer", "stop", at(9, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
assert minutes.minutes_for(DAY) == 0
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
assert fp.status(DAY)["unbacked_minutes"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_in_the_span_backs_all_of_it() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 5)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_backs_only_the_spans_it_touches() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 7)))
|
||||||
|
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 10)),
|
||||||
|
("sql_drills", "heartbeat", at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_just_before_the_span_backs_it() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 5)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_late_beat_does_not_back_it() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 40)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("tutor", "heartbeat", at(8, 50)),
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "checkin", at(9, 5)),
|
||||||
|
("timer", "stop", at(9, 8)),
|
||||||
|
("tutor", "heartbeat", at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("tutor", "heartbeat", at(8, 30)),
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 8)),
|
||||||
|
("tutor", "heartbeat", at(9, 40)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_never_backs() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("browser", "heartbeat", at(9, 7)))
|
||||||
|
# The beat pays its own 9:02-9:07 but backs no timer span.
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 5
|
||||||
|
assert minutes.minutes_for(DAY) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_before_backing_from_keep_the_old_rule() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="Old",
|
||||||
|
start=date(2026, 11, 2),
|
||||||
|
end=date(2026, 11, 5),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=["leetcode.com"],
|
||||||
|
)
|
||||||
|
old = date(2026, 11, 2)
|
||||||
|
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
|
||||||
|
assert minutes.minutes_for(old) == 10
|
||||||
|
assert minutes.unbacked_minutes_for(old) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_project_is_zero_unbacked() -> None:
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert fp.status(DAY)["unbacked_minutes"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbacked_is_clipped_at_now() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_timer_returns_backed_and_unbacked() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
project = _api.active(DAY)
|
||||||
|
assert project is not None
|
||||||
|
backed, unbacked = activity.split_timer(DAY, project)
|
||||||
|
assert backed == []
|
||||||
|
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backing_from_is_exported() -> None:
|
||||||
|
assert date(2026, 11, 8) == fp.BACKING_FROM
|
||||||
|
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def boom(_day: date) -> None:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
monkeypatch.setattr("focus_project._api.active", boom)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# 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
|
||||||
@ -14,6 +14,8 @@ import pytest
|
|||||||
import focus_project as fp
|
import focus_project as fp
|
||||||
from focus_project import minutes, sources
|
from focus_project import minutes, sources
|
||||||
from focus_project.sources import leetcode
|
from focus_project.sources import leetcode
|
||||||
|
from focus_project.tests.test_backing import DAY, proj, rows
|
||||||
|
from focus_project.tests.test_backing import at as day_at
|
||||||
from focus_project.tests.test_minutes import D1, at, project
|
from focus_project.tests.test_minutes import D1, at, project
|
||||||
|
|
||||||
KEY = b"k" * 32
|
KEY = b"k" * 32
|
||||||
@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||||||
assert got
|
assert got
|
||||||
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
|
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
|
||||||
assert REAL.stat().st_mtime_ns == before
|
assert REAL.stat().st_mtime_ns == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
|
||||||
|
"""0.10.0: an accepted submission backs timer time on a backed day."""
|
||||||
|
proj()
|
||||||
|
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
write(tmp_path, ac_row(day_at(9, 10)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_ledger_leaves_heartbeat_backing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def boom(_project: object) -> list[float]:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
monkeypatch.setattr(leetcode, "submission_times", boom)
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("timer", "start", day_at(9)),
|
||||||
|
("timer", "stop", day_at(9, 10)),
|
||||||
|
("tutor", "heartbeat", day_at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "focus_project"
|
name = "focus_project"
|
||||||
version = "0.9.1"
|
version = "0.10.0"
|
||||||
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
# Stdlib only on purpose: guards import it from the system python.
|
# Stdlib only on purpose: guards import it from the system python.
|
||||||
|
|||||||
@ -97,6 +97,25 @@ 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
|
||||||
|
|||||||
@ -222,14 +222,19 @@ 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 exit path. ``flock`` covers a killed process for
|
Must run on every clean exit path (``flock`` covers a killed process):
|
||||||
free, but a *cleanly* exiting app has to release explicitly -- the
|
the morning routine runs the alarm and then the workout locker as
|
||||||
morning routine runs the alarm and then the workout locker as
|
sequential subprocesses, and a lingering claim would make the second
|
||||||
sequential subprocesses, and a lingering claim would make the workout
|
stand down against an app that had already finished.
|
||||||
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:
|
||||||
|
|||||||
@ -22,11 +22,12 @@ 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
|
from dataclasses import dataclass, field
|
||||||
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
|
||||||
|
|
||||||
@ -70,6 +71,8 @@ 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
|
||||||
@ -80,6 +83,7 @@ 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:
|
||||||
@ -167,14 +171,16 @@ 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:
|
except tk.TclError as exc:
|
||||||
if retry_ms == 0:
|
if retry_ms == 0:
|
||||||
_logger.warning("Global grab failed, falling back to local grab")
|
_logger.warning("Global grab failed (%s), falling back to local grab", exc)
|
||||||
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)
|
||||||
@ -183,6 +189,8 @@ 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()
|
||||||
|
|||||||
132
gatelock/gatelock/_grabprobe.py
Normal file
132
gatelock/gatelock/_grabprobe.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# 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
|
||||||
@ -6,8 +6,9 @@ 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 here only ever *adds* visibility. Nothing in this module can hide
|
Everything the recovery loop reaches here only ever *adds* visibility; that
|
||||||
or unmap a surface; that asymmetry is what makes the recovery loop monotonic.
|
asymmetry is what makes it monotonic. The one hider, :func:`set_visible`, is
|
||||||
|
reached only by :mod:`gatelock._stepback`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -55,7 +56,9 @@ def geometry_matches(surface: _Surface) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def enforce_one(surface: _Surface, *, overrideredirect: bool) -> bool:
|
def enforce_one(
|
||||||
|
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:
|
||||||
@ -63,6 +66,9 @@ def enforce_one(surface: _Surface, *, overrideredirect: bool) -> bool:
|
|||||||
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.
|
||||||
@ -77,10 +83,32 @@ def enforce_one(surface: _Surface, *, overrideredirect: bool) -> bool:
|
|||||||
changed = True
|
changed = True
|
||||||
if overrideredirect:
|
if overrideredirect:
|
||||||
window.overrideredirect(boolean=True)
|
window.overrideredirect(boolean=True)
|
||||||
window.lift()
|
if 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, ...]:
|
||||||
@ -105,13 +133,14 @@ def blind_outputs(
|
|||||||
|
|
||||||
|
|
||||||
def enforce_all(
|
def enforce_all(
|
||||||
surfaces: Mapping[str, _Surface], *, overrideredirect: bool
|
surfaces: Mapping[str, _Surface], *, overrideredirect: bool, lift: bool = True
|
||||||
) -> 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.
|
||||||
@ -119,7 +148,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)
|
if enforce_one(surface, overrideredirect=overrideredirect, lift=lift)
|
||||||
]
|
]
|
||||||
return tuple(sorted(corrected))
|
return tuple(sorted(corrected))
|
||||||
|
|
||||||
|
|||||||
@ -22,22 +22,39 @@ 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.
|
direction for the length of the outage. On 2026-11-11 the lock blamed
|
||||||
|
*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
|
||||||
@ -46,24 +63,36 @@ 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.
|
||||||
"""
|
"""
|
||||||
holder = arbiter.describe_holder() if arbiter else None
|
reason = prompt_yield.last_reason if prompt_yield else ""
|
||||||
|
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; no gatelock app "
|
"global grab still blocked after %d attempts%s; no other gatelock "
|
||||||
"holds it -- likely another X client (e.g. a fullscreen game)",
|
"app holds it -- a non-gatelock X client does (e.g. a fullscreen "
|
||||||
|
"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; held by gatelock app "
|
"global grab still blocked after %d attempts%s; 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,
|
||||||
|
|||||||
62
gatelock/gatelock/_prompt_names.py
Normal file
62
gatelock/gatelock/_prompt_names.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# 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
|
||||||
|
)
|
||||||
151
gatelock/gatelock/_prompts.py
Normal file
151
gatelock/gatelock/_prompts.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# 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))
|
||||||
176
gatelock/gatelock/_prompts_x.py
Normal file
176
gatelock/gatelock/_prompts_x.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
# 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
|
||||||
@ -30,6 +30,12 @@ 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
|
||||||
@ -206,6 +212,8 @@ 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)
|
||||||
|
|||||||
@ -41,6 +41,7 @@ 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
|
||||||
|
|
||||||
@ -63,6 +64,11 @@ 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."""
|
||||||
@ -146,13 +152,17 @@ 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()
|
corrected = self._parts.surfaces.enforce(lift=not yielding)
|
||||||
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(
|
||||||
@ -188,20 +198,9 @@ 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.
|
||||||
|
|
||||||
Returns:
|
Delegates to :func:`gatelock._recovery_grab.reassert_grab`.
|
||||||
True if the grab had to be re-taken.
|
|
||||||
"""
|
"""
|
||||||
if self._parts.config.resolved_grab() != "global":
|
return reassert_grab(self._root, self._parts, self._grab)
|
||||||
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.
|
||||||
|
|||||||
57
gatelock/gatelock/_recovery_grab.py
Normal file
57
gatelock/gatelock/_recovery_grab.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# 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
|
||||||
@ -8,9 +8,11 @@ against a report without pulling in the machinery that builds one.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
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
|
||||||
@ -60,3 +62,5 @@ 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."""
|
||||||
|
|||||||
186
gatelock/gatelock/_stepback.py
Normal file
186
gatelock/gatelock/_stepback.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
# 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()
|
||||||
@ -27,10 +27,8 @@ 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 from the lock
|
override-redirect, so ``attributes(fullscreen=True)`` is gone: an EWMH
|
||||||
path entirely: it is an EWMH request that an override-redirect window is
|
request an override-redirect window never sees.
|
||||||
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
|
||||||
@ -45,6 +43,7 @@ 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,
|
||||||
@ -54,9 +53,7 @@ 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
|
||||||
|
|
||||||
# Names this module re-exports after the 250-line split. Listed explicitly so
|
# Re-exports after the 250-line split; listed so the autofixer keeps them.
|
||||||
# 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",
|
||||||
@ -161,27 +158,36 @@ 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) -> tuple[str, ...]:
|
def enforce(self, *, lift: bool = True) -> tuple[str, ...]:
|
||||||
"""Re-assert every surface's placement and stacking.
|
"""Re-assert every surface's placement and stacking.
|
||||||
|
|
||||||
Only ever *adds* visibility: remaps what became unmapped, corrects
|
Only adds visibility: remaps, corrects drifted geometry, raises.
|
||||||
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 every surface above whatever else is on screen."""
|
"""Lift the backdrop and 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()
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,9 @@ 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
|
||||||
|
|
||||||
@ -80,6 +82,7 @@ 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)
|
||||||
@ -92,6 +95,7 @@ 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
|
||||||
@ -105,7 +109,9 @@ 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:
|
||||||
@ -140,24 +146,16 @@ class LockWindow:
|
|||||||
# -- window mechanics -----------------------------------------------------
|
# -- window mechanics -----------------------------------------------------
|
||||||
|
|
||||||
def setup(self) -> None:
|
def setup(self) -> None:
|
||||||
"""Configure the backdrop and build a surface on every live output.
|
"""Build the backdrop and a surface per live output (``_arming.setup``)."""
|
||||||
|
|
||||||
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 start watching for output changes.
|
"""Take the configured grab, then watch outputs (``_arming.grab_input``)."""
|
||||||
|
|
||||||
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 per ``grab_retry_ms``.
|
"""Acquire the global grab, retrying (``_arming.acquire_global_grab``)."""
|
||||||
|
|
||||||
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:
|
||||||
@ -171,6 +169,7 @@ 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:
|
||||||
@ -228,6 +227,7 @@ 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()
|
||||||
|
|||||||
@ -31,3 +31,22 @@ 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,
|
||||||
|
)
|
||||||
|
|||||||
@ -17,6 +17,7 @@ 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
|
||||||
@ -78,6 +79,12 @@ 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"),
|
||||||
):
|
):
|
||||||
@ -162,3 +169,39 @@ 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()
|
||||||
|
|||||||
202
gatelock/gatelock/tests/test_grab_blame.py
Normal file
202
gatelock/gatelock/tests/test_grab_blame.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# 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
|
||||||
238
gatelock/gatelock/tests/test_grabprobe.py
Normal file
238
gatelock/gatelock/tests/test_grabprobe.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# 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"
|
||||||
214
gatelock/gatelock/tests/test_prompts.py
Normal file
214
gatelock/gatelock/tests/test_prompts.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# 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
|
||||||
209
gatelock/gatelock/tests/test_prompts_x.py
Normal file
209
gatelock/gatelock/tests/test_prompts_x.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# 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
|
||||||
98
gatelock/gatelock/tests/test_prompts_x_failures.py
Normal file
98
gatelock/gatelock/tests/test_prompts_x_failures.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# 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
|
||||||
@ -64,3 +64,17 @@ 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"}
|
||||||
|
|||||||
43
gatelock/gatelock/tests/test_recovery_restart.py
Normal file
43
gatelock/gatelock/tests/test_recovery_restart.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# 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")
|
||||||
204
gatelock/gatelock/tests/test_recovery_yield.py
Normal file
204
gatelock/gatelock/tests/test_recovery_yield.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# 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()
|
||||||
120
gatelock/gatelock/tests/test_stepback.py
Normal file
120
gatelock/gatelock/tests/test_stepback.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# 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
|
||||||
109
gatelock/gatelock/tests/test_stepback_parts.py
Normal file
109
gatelock/gatelock/tests/test_stepback_parts.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 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()
|
||||||
165
gatelock/gatelock/tests/test_stepback_rearm.py
Normal file
165
gatelock/gatelock/tests/test_stepback_rearm.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# 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
|
||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "gatelock"
|
name = "gatelock"
|
||||||
version = "0.12.0"
|
version = "0.13.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
|
||||||
|
|||||||
101
gatelock/scripts/locker_child.py
Executable file
101
gatelock/scripts/locker_child.py
Executable file
@ -0,0 +1,101 @@
|
|||||||
|
#!/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:]))
|
||||||
73
gatelock/scripts/prompt_grab_helper.py
Executable file
73
gatelock/scripts/prompt_grab_helper.py
Executable file
@ -0,0 +1,73 @@
|
|||||||
|
#!/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:]))
|
||||||
249
gatelock/scripts/verify_all_lockers.py
Executable file
249
gatelock/scripts/verify_all_lockers.py
Executable file
@ -0,0 +1,249 @@
|
|||||||
|
#!/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"))
|
||||||
113
gatelock/scripts/verify_grab_responsive.py
Executable file
113
gatelock/scripts/verify_grab_responsive.py
Executable file
@ -0,0 +1,113 @@
|
|||||||
|
#!/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())
|
||||||
250
gatelock/scripts/verify_prompt_yield.py
Executable file
250
gatelock/scripts/verify_prompt_yield.py
Executable file
@ -0,0 +1,250 @@
|
|||||||
|
#!/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:]))
|
||||||
@ -64,47 +64,57 @@ 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 Text('Authorize on GitHub'),
|
title: const Icon(Icons.key, semanticLabel: 'Authorize on GitHub'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Enter this code on GitHub:'),
|
Row(
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
SelectableText(
|
const Icon(
|
||||||
widget.device.userCode,
|
Icons.keyboard,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
semanticLabel: 'Enter this code on GitHub:',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SelectableText(
|
||||||
|
widget.device.userCode,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_error == null)
|
if (_error case final error?)
|
||||||
const Row(
|
Icon(
|
||||||
children: [
|
Icons.error,
|
||||||
SizedBox(
|
color: Theme.of(context).colorScheme.error,
|
||||||
width: 16,
|
semanticLabel: error,
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Expanded(child: Text('Waiting for authorization…')),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text(
|
Semantics(
|
||||||
_error!,
|
label: 'Waiting for authorization…',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
child: const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton.icon(
|
FilledButton(
|
||||||
onPressed: _openPage,
|
onPressed: _openPage,
|
||||||
icon: const Icon(Icons.open_in_new),
|
child: const Icon(
|
||||||
label: const Text('Open GitHub & copy code'),
|
Icons.open_in_new,
|
||||||
|
semanticLabel: 'Open GitHub & copy code',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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.3.2
|
version: 0.4.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@ -67,6 +67,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,9 +77,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.text('Authorize on GitHub'), findsOneWidget);
|
expect(find.bySemanticsLabel('Authorize on GitHub'), findsOneWidget);
|
||||||
expect(find.text('ABCD-1234'), findsOneWidget);
|
expect(find.text('ABCD-1234'), findsOneWidget);
|
||||||
expect(find.text('Waiting for authorization…'), findsOneWidget);
|
expect(find.bySemanticsLabel('Waiting for authorization…'), findsOneWidget);
|
||||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -111,7 +114,7 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
expect(find.textContaining('access_denied'), findsOneWidget);
|
expect(find.bySemanticsLabel(RegExp('access_denied')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('surfaces an Error, not just an Exception', (tester) async {
|
testWidgets('surfaces an Error, not just an Exception', (tester) async {
|
||||||
@ -126,12 +129,12 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
expect(find.textContaining('No host specified'), findsOneWidget);
|
expect(find.bySemanticsLabel(RegExp('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.text('Cancel'));
|
await tester.tap(find.bySemanticsLabel('Cancel'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(AlertDialog), findsNothing);
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
@ -161,7 +164,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await _showDialog(tester, auth);
|
await _showDialog(tester, auth);
|
||||||
await tester.tap(find.text('Open GitHub & copy code'));
|
await tester.tap(find.bySemanticsLabel('Open GitHub & copy code'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(copied, 'ABCD-1234');
|
expect(copied, 'ABCD-1234');
|
||||||
|
|||||||
@ -93,6 +93,14 @@ 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
|
||||||
|
|||||||
@ -25,6 +25,7 @@ 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
|
||||||
@ -64,6 +65,7 @@ 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):
|
||||||
@ -72,7 +74,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)
|
return todo + plan_web(repo) + plan_gms(repo)
|
||||||
|
|
||||||
|
|
||||||
def install(repo: Path) -> list[str]:
|
def install(repo: Path) -> list[str]:
|
||||||
@ -92,7 +94,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)
|
return done + install_web(repo) + install_gms(repo)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_commands(repo: Path) -> list[str]:
|
def bootstrap_commands(repo: Path) -> list[str]:
|
||||||
|
|||||||
62
repo_contract/install_gms.py
Normal file
62
repo_contract/install_gms.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""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
|
||||||
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/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 "$@"
|
||||||
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
77
repo_contract/tests/test_install_gms.py
Normal file
77
repo_contract/tests/test_install_gms.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""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
|
||||||
193
scripts/check_no_gms_signin.sh
Executable file
193
scripts/check_no_gms_signin.sh
Executable file
@ -0,0 +1,193 @@
|
|||||||
|
#!/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 "$@"
|
||||||
109
scripts/deploy.sh
Executable file
109
scripts/deploy.sh
Executable file
@ -0,0 +1,109 @@
|
|||||||
|
#!/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
|
||||||
@ -9,6 +9,7 @@
|
|||||||
# 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
|
||||||
|
|||||||
195
scripts/tests/test_check_no_gms_signin.py
Normal file
195
scripts/tests/test_check_no_gms_signin.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"""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
|
||||||
@ -70,22 +70,35 @@ 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 five-switch panel per day, the days ahead. Past days are
|
Tk -- calendar, a six-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: five switches, all off (off = stands down), "Mark sick"; sick
|
Normal day: six switches, all off (off = stands down), `actions.mark`; sick
|
||||||
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
|
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
|
||||||
why and offer no write; a refusal (or a click mid-sync) shows in the panel.
|
days show their icon and offer no write; a refusal (or a click mid-sync)
|
||||||
|
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 sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
|
mark / 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
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sickdays"
|
name = "sickdays"
|
||||||
version = "0.4.0"
|
version = "0.5.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,6 +120,9 @@ 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 = [
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""The month grid: Monday-first, one button per day, prev/next month arrows.
|
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
|
||||||
|
|
||||||
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.
|
with the window; this only reports the date. No words: the month reads
|
||||||
|
``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
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._day import to_iso
|
from sickdays._day import to_iso
|
||||||
from sickdays.gui import _text
|
from sickdays.gui import _dates
|
||||||
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
|
||||||
|
|
||||||
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
|
#: The weekday marks over the grid, in px.
|
||||||
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
|
_MARK_PX: Final = 10
|
||||||
|
#: 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]:
|
||||||
@ -56,7 +60,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 = _text.month_bounds(today)
|
self._first, self._last = _dates.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")
|
||||||
@ -66,7 +70,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 = _text.shift_month(self.month, months)
|
target = _dates.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()
|
||||||
@ -77,11 +81,12 @@ 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, initial in enumerate(_text.WEEKDAY_INITIALS):
|
for column in range(7):
|
||||||
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
|
mark = "ring" if _dates.is_weekend_column(column) else "dot"
|
||||||
row=0, column=column
|
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid(
|
||||||
|
row=0, column=column, pady=(0, tok.XS)
|
||||||
)
|
)
|
||||||
for row, week in enumerate(_text.month_weeks(self.month), start=1):
|
for row, week in enumerate(_dates.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(
|
||||||
@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _draw_header(self) -> None:
|
def _draw_header(self) -> None:
|
||||||
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
|
back = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
|
||||||
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
|
forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
|
||||||
back.configure(state="normal" if self.month > self._first else "disabled")
|
back.set_enabled(self.month > self._first)
|
||||||
forward.configure(state="normal" if self.month < self._last else "disabled")
|
forward.set_enabled(self.month < self._last)
|
||||||
back.pack(side="left")
|
back.pack(side="left")
|
||||||
forward.pack(side="right")
|
forward.pack(side="right")
|
||||||
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
|
ui.label(self._header, _dates.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=_text.is_workday(day))
|
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.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)}",
|
||||||
@ -124,7 +129,6 @@ 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 = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
|
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
|
||||||
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
|
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
|
||||||
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
|
|
||||||
return cell
|
return cell
|
||||||
|
|||||||
171
sickdays/sickdays/gui/_controls.py
Normal file
171
sickdays/sickdays/gui/_controls.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
# 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
|
||||||
86
sickdays/sickdays/gui/_dates.py
Normal file
86
sickdays/sickdays/gui/_dates.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# 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)
|
||||||
235
sickdays/sickdays/gui/_icons.py
Normal file
235
sickdays/sickdays/gui/_icons.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# 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))
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: the five switches for one day, and the buttons that write them.
|
"""KeepPanel: a switch per locker 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,29 +7,34 @@ 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"; a sick day
|
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
|
||||||
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
|
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
|
||||||
day, or a free-day pool that cannot be checked shows why and offers no
|
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
|
||||||
write. A refused write is shown in the panel, never raised: an exception in a
|
be checked (warning) shows its icon and offers no write. A refused write is
|
||||||
Tk callback only reaches stderr, which nobody sees under a lock.
|
shown in the panel as an icon, never raised: an exception in a Tk callback
|
||||||
|
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
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
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 today
|
from sickdays._day import to_iso, 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 _text
|
from sickdays.gui import _dates
|
||||||
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
|
||||||
@ -39,6 +44,17 @@ 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
|
||||||
@ -73,16 +89,17 @@ 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:
|
||||||
|
|
||||||
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
||||||
* "Save" with a changed set -> the stored entry (``is_sick=True``);
|
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
|
||||||
* "Save" with nothing changed -> ``None``: nothing written, no sync;
|
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
|
||||||
* "Clear sick day" -> the cleared entry (``is_sick=False``).
|
* ``actions.clear`` -> 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 and
|
previous write's sync is still running) is shown in the panel's
|
||||||
``on_saved`` is not called. ``on_cancel``, when given, adds a
|
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
|
||||||
"Cancel" button that calls it. The panel never destroys itself; the
|
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
|
||||||
callbacks decide what happens to it.
|
``actions.cancel`` cross that calls it. The panel never destroys itself;
|
||||||
|
the callbacks decide what happens to it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -107,12 +124,15 @@ 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 = ui.label(
|
self._status = IconSlot(self, name="status")
|
||||||
self, blocked or "", "label", fg=tok.DANGER, name="status"
|
self._status.grid(
|
||||||
|
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
|
||||||
)
|
)
|
||||||
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
|
self._show(blocked)
|
||||||
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]:
|
||||||
@ -121,49 +141,54 @@ 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 _text.PAST_NOTE
|
return "past"
|
||||||
return None if self._sick else _text.free_day_refusal(self._day)
|
return None if self._sick else _dates.free_day_block(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:
|
||||||
title = _text.panel_title(self._day, sick=self._sick)
|
head = tk.Frame(self, name="head", bg=self.cget("bg"))
|
||||||
ui.label(self, title, "subtitle", name="title").grid(
|
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
|
||||||
row=0, column=0, columnspan=2, sticky="w"
|
IconSlot(head, name="state").show(
|
||||||
)
|
"sick" if self._sick else "normal", tok.SICK
|
||||||
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
|
).pack(side="left", padx=(0, tok.SM))
|
||||||
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
|
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
|
||||||
)
|
for row, locker in enumerate(LOCKERS, start=1):
|
||||||
for row, locker in enumerate(LOCKERS, start=2):
|
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
|
||||||
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 = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
|
switch = 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.configure(state="disabled")
|
switch.set_enabled(False)
|
||||||
|
|
||||||
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) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
||||||
)
|
)
|
||||||
buttons: list[tk.Button] = []
|
buttons: list[IconButton] = []
|
||||||
if writable and self._sick:
|
if writable and self._sick:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(actions, "Save", self._save, kind="primary", name="save")
|
IconButton(actions, "check", self._save, kind="primary", name="save")
|
||||||
)
|
)
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(
|
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
|
||||||
actions, "Clear sick day", self._clear, kind="danger", name="clear"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
elif writable:
|
elif writable:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
|
IconButton(actions, "check", self._mark, kind="primary", name="mark")
|
||||||
)
|
)
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
|
buttons.append(IconButton(actions, "cross", 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))
|
||||||
|
|
||||||
@ -187,12 +212,13 @@ 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._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
|
self._show("busy")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
entry = action()
|
entry = action()
|
||||||
except SickDayError as exc:
|
except SickDayError as exc:
|
||||||
self._status.configure(text=str(exc), fg=tok.DANGER)
|
_LOG.warning("sick-day write refused: %s", exc)
|
||||||
|
self._show("refused")
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
_SYNC_LOCK.release()
|
_SYNC_LOCK.release()
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
# 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)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user