mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 13:07:46 +01:00
Compare commits
7 Commits
focus-proj
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d341dd41e | |||
| 3084080d74 | |||
| 9d6b17fd28 | |||
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a |
@ -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
|
||||||
|
|||||||
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
|
||||||
@ -9,7 +9,7 @@ clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
|||||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||||
(logged): fail closed.
|
(logged): fail closed.
|
||||||
|
|
||||||
Unbacked minutes (0.8.0): timer time the anti-cheat gate refused
|
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
||||||
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||||
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||||
"""
|
"""
|
||||||
|
|||||||
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
|
||||||
@ -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 "$@"
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -1,33 +1,20 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""Token-styled Tk building blocks shared by the panel and the window.
|
"""Token-styled Tk labels for the numerals the picker shows (dates, months).
|
||||||
|
|
||||||
Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
|
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
|
||||||
filled control is picked from its fill (``on-fill`` on the accent, never the
|
icons are word-free Canvases in :mod:`sickdays.gui._controls`.
|
||||||
near-white body text), the bug the design system's README warns about.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import font as tkfont
|
from tkinter import font as tkfont
|
||||||
from typing import TYPE_CHECKING, Final, Literal
|
from typing import Final
|
||||||
|
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
ButtonKind = Literal["primary", "danger", "plain"]
|
|
||||||
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
||||||
|
|
||||||
#: (background, foreground) per button kind. Danger is a text button, as on
|
|
||||||
#: the phone, so its red sits on the dark surface rather than under ink.
|
|
||||||
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
|
|
||||||
"primary": (tok.ACCENT, tok.ON_FILL),
|
|
||||||
"danger": (tok.INK_RAISED_2, tok.DANGER),
|
|
||||||
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def family(widget: tk.Misc) -> str:
|
def family(widget: tk.Misc) -> str:
|
||||||
"""The family Tk's default font actually resolves to on this display."""
|
"""The family Tk's default font actually resolves to on this display."""
|
||||||
@ -42,10 +29,7 @@ def label(
|
|||||||
fg: str = tok.TEXT_ON_DARK,
|
fg: str = tok.TEXT_ON_DARK,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
) -> tk.Label:
|
) -> tk.Label:
|
||||||
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
|
"""A label on ``parent``'s own background; titles and subtitles are bold."""
|
||||||
|
|
||||||
Titles and subtitles are bold, everything else regular.
|
|
||||||
"""
|
|
||||||
return tk.Label(
|
return tk.Label(
|
||||||
parent,
|
parent,
|
||||||
name=name,
|
name=name,
|
||||||
@ -55,71 +39,4 @@ def label(
|
|||||||
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
||||||
justify="left",
|
justify="left",
|
||||||
anchor="w",
|
anchor="w",
|
||||||
wraplength=tok.WRAP,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def button(
|
|
||||||
parent: tk.Misc,
|
|
||||||
text: str,
|
|
||||||
command: Callable[[], object],
|
|
||||||
*,
|
|
||||||
kind: ButtonKind = "plain",
|
|
||||||
name: str = "",
|
|
||||||
) -> tk.Button:
|
|
||||||
"""A flat button whose text colour follows its fill."""
|
|
||||||
bg, fg = _BUTTON_COLOURS[kind]
|
|
||||||
return tk.Button(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
text=text,
|
|
||||||
command=command,
|
|
||||||
bg=bg,
|
|
||||||
fg=fg,
|
|
||||||
activebackground=bg,
|
|
||||||
activeforeground=fg,
|
|
||||||
font=tok.font(family(parent), "body", bold=kind == "primary"),
|
|
||||||
relief="flat",
|
|
||||||
borderwidth=0,
|
|
||||||
highlightthickness=0,
|
|
||||||
padx=tok.MD,
|
|
||||||
pady=tok.SM,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None:
|
|
||||||
"""Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off"."""
|
|
||||||
bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK)
|
|
||||||
switch.configure(
|
|
||||||
text="On" if on else "Off",
|
|
||||||
bg=bg,
|
|
||||||
selectcolor=bg,
|
|
||||||
activebackground=bg,
|
|
||||||
fg=fg,
|
|
||||||
activeforeground=fg,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton:
|
|
||||||
"""A Checkbutton drawn as an On/Off switch, repainted on every change."""
|
|
||||||
switch = tk.Checkbutton(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
variable=variable,
|
|
||||||
onvalue=True,
|
|
||||||
offvalue=False,
|
|
||||||
indicatoron=False,
|
|
||||||
width=4,
|
|
||||||
font=tok.font(family(parent), "label", bold=True),
|
|
||||||
relief="flat",
|
|
||||||
borderwidth=0,
|
|
||||||
highlightthickness=0,
|
|
||||||
padx=tok.SM,
|
|
||||||
pady=tok.XS,
|
|
||||||
disabledforeground=tok.MUTED_ON_DARK,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
switch.configure(command=lambda: paint_toggle(switch, on=variable.get()))
|
|
||||||
paint_toggle(switch, on=variable.get())
|
|
||||||
return switch
|
|
||||||
|
|||||||
@ -2,26 +2,33 @@
|
|||||||
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
||||||
|
|
||||||
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
||||||
the clicked day, the sick days still ahead underneath. The window class is
|
the clicked day, the sick days still ahead underneath (each an ISO date and
|
||||||
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
|
the pictograms of the lockers it keeps). The window class is ``sickdays``
|
||||||
``for_window`` rule can float it; ``Escape`` closes it.
|
(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window``
|
||||||
|
rule can float it; ``Escape`` closes it.
|
||||||
|
|
||||||
|
No words inside the window (kuhy's textgate rule). The title bar keeps the
|
||||||
|
app's name: an OS-level app name is a proper noun (textgate settled
|
||||||
|
decision 4), the same as ``sickdays.desktop``'s ``Name=``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._api import is_sick_day, load
|
from sickdays._api import is_sick_day, load
|
||||||
from sickdays._day import today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._keep_api import kept_lockers
|
from sickdays._keep_api import kept_lockers
|
||||||
from sickdays._model import sick_entries
|
from sickdays._model import sick_entries
|
||||||
from sickdays._paths import resolve_paths
|
from sickdays._paths import resolve_paths
|
||||||
from sickdays.gui import _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._calendar import MonthCalendar
|
from sickdays.gui._calendar import MonthCalendar
|
||||||
from sickdays.gui._panel import KeepPanel
|
from sickdays.gui._controls import IconSlot
|
||||||
|
from sickdays.gui._icons import LOCKER_ICONS
|
||||||
|
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -30,19 +37,18 @@ if TYPE_CHECKING:
|
|||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
WINDOW_CLASS = "sickdays"
|
WINDOW_CLASS = "sickdays"
|
||||||
|
WINDOW_TITLE = "Sick days"
|
||||||
|
#: A kept locker's pictogram in the list of days ahead, in px.
|
||||||
|
_KEPT_PX: Final = 16
|
||||||
|
|
||||||
|
|
||||||
def saved_message(entry: SickDay | None, day: date) -> str:
|
def saved_icon(entry: SickDay | None) -> str | None:
|
||||||
"""The status line after the panel closed with ``entry``."""
|
"""The status mark after the panel closed with ``entry``.
|
||||||
label = _text.day_label(day)
|
|
||||||
if entry is None:
|
A check for any write (the grid and the list show what it was); nothing
|
||||||
return f"{label}: nothing changed"
|
when the panel was saved unchanged.
|
||||||
if not entry.is_sick:
|
"""
|
||||||
return f"{label} is a normal day again"
|
return None if entry is None else "check"
|
||||||
kept = _text.kept_label(entry.keep)
|
|
||||||
return f"{label} is a sick day — " + (
|
|
||||||
f"kept: {kept}" if kept else "every locker stands down"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SickDaysApp:
|
class SickDaysApp:
|
||||||
@ -52,7 +58,7 @@ class SickDaysApp:
|
|||||||
"""Style ``root`` and draw the current pool."""
|
"""Style ``root`` and draw the current pool."""
|
||||||
self._root = root
|
self._root = root
|
||||||
self._paths = resolve_paths(paths)
|
self._paths = resolve_paths(paths)
|
||||||
root.title("Sick days")
|
root.title(WINDOW_TITLE)
|
||||||
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
||||||
root.bind("<Escape>", self.close)
|
root.bind("<Escape>", self.close)
|
||||||
left = tk.Frame(root, bg=tok.INK)
|
left = tk.Frame(root, bg=tok.INK)
|
||||||
@ -60,13 +66,9 @@ class SickDaysApp:
|
|||||||
#: Where the clicked day's panel goes.
|
#: Where the clicked day's panel goes.
|
||||||
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
||||||
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
||||||
ui.label(left, "Sick days", "title").pack(anchor="w")
|
#: The last outcome's mark: a check, or the panel's refusal icons.
|
||||||
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
|
self.status = IconSlot(left, name="status")
|
||||||
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
|
self.status.pack(anchor="w")
|
||||||
anchor="w", pady=(tok.SM, 0)
|
|
||||||
)
|
|
||||||
self.status = ui.label(left, "", "label", name="status")
|
|
||||||
self.status.pack(anchor="w", pady=(tok.SM, 0))
|
|
||||||
log = self._paths.log
|
log = self._paths.log
|
||||||
self.calendar = MonthCalendar(
|
self.calendar = MonthCalendar(
|
||||||
left,
|
left,
|
||||||
@ -75,8 +77,8 @@ class SickDaysApp:
|
|||||||
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
||||||
on_day=self.select,
|
on_day=self.select,
|
||||||
)
|
)
|
||||||
self.calendar.pack(anchor="w", pady=(tok.MD, 0))
|
self.calendar.pack(anchor="w", pady=(tok.SM, 0))
|
||||||
ui.label(left, "Marked sick", "subtitle").pack(
|
IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
|
||||||
anchor="w", pady=(tok.LG, tok.SM)
|
anchor="w", pady=(tok.LG, tok.SM)
|
||||||
)
|
)
|
||||||
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
||||||
@ -88,25 +90,25 @@ class SickDaysApp:
|
|||||||
self._root.destroy()
|
self._root.destroy()
|
||||||
|
|
||||||
def select(self, day: date) -> None:
|
def select(self, day: date) -> None:
|
||||||
"""Open the panel for ``day``, or refuse a free day in the status line."""
|
"""Open the panel for ``day``, or refuse a free day in the status mark."""
|
||||||
self._close_panel()
|
self._close_panel()
|
||||||
if not is_sick_day(day, log_path=self._paths.log):
|
if not is_sick_day(day, log_path=self._paths.log):
|
||||||
refusal = _text.free_day_refusal(day)
|
block = _dates.free_day_block(day)
|
||||||
if refusal is not None:
|
if block is not None:
|
||||||
self._set_status(refusal, failed=True)
|
self.status.show(*STATUS_ICONS[block])
|
||||||
return
|
return
|
||||||
self._set_status("", failed=False)
|
self.status.show(None)
|
||||||
KeepPanel(
|
KeepPanel(
|
||||||
self.host,
|
self.host,
|
||||||
day,
|
day,
|
||||||
on_saved=lambda entry: self._saved(entry, day),
|
on_saved=self._saved,
|
||||||
on_cancel=self._close_panel,
|
on_cancel=self._close_panel,
|
||||||
paths=self._paths,
|
paths=self._paths,
|
||||||
).pack(anchor="n")
|
).pack(anchor="n")
|
||||||
|
|
||||||
def _saved(self, entry: SickDay | None, day: date) -> None:
|
def _saved(self, entry: SickDay | None) -> None:
|
||||||
self._close_panel()
|
self._close_panel()
|
||||||
self._set_status(saved_message(entry, day), failed=False)
|
self.status.show(saved_icon(entry), tok.SUCCESS)
|
||||||
self.calendar.redraw()
|
self.calendar.redraw()
|
||||||
self._draw_upcoming()
|
self._draw_upcoming()
|
||||||
|
|
||||||
@ -114,29 +116,20 @@ class SickDaysApp:
|
|||||||
for child in self.host.winfo_children():
|
for child in self.host.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
|
|
||||||
def _set_status(self, text: str, *, failed: bool) -> None:
|
|
||||||
self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS)
|
|
||||||
|
|
||||||
def _draw_upcoming(self) -> None:
|
def _draw_upcoming(self) -> None:
|
||||||
for child in self._list.winfo_children():
|
for child in self._list.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
reference = today()
|
reference = today()
|
||||||
upcoming = [
|
for entry in sick_entries(load(self._paths.log)):
|
||||||
entry
|
if entry.day < reference:
|
||||||
for entry in sick_entries(load(self._paths.log))
|
continue
|
||||||
if entry.day >= reference
|
row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK)
|
||||||
]
|
row.pack(anchor="w", pady=(0, tok.XS))
|
||||||
if not upcoming:
|
ui.label(row, to_iso(entry.day), "body").pack(side="left")
|
||||||
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
|
for locker in _dates.kept_in_order(entry.keep):
|
||||||
anchor="w"
|
IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show(
|
||||||
)
|
LOCKER_ICONS[locker], tok.MUTED_ON_DARK
|
||||||
for entry in upcoming:
|
).pack(side="left", padx=(tok.SM, 0))
|
||||||
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
|
|
||||||
kept = _text.kept_label(entry.keep)
|
|
||||||
if kept:
|
|
||||||
ui.label(
|
|
||||||
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
|
|
||||||
).pack(anchor="w", pady=(0, tok.XS))
|
|
||||||
|
|
||||||
|
|
||||||
def run(*, paths: Paths | None = None) -> int:
|
def run(*, paths: Paths | None = None) -> int:
|
||||||
|
|||||||
@ -61,7 +61,7 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|||||||
for module in _MODULES:
|
for module in _MODULES:
|
||||||
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
||||||
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
||||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free)
|
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", lambda: _never_free)
|
||||||
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
70
sickdays/sickdays/tests/test_gui_controls.py
Normal file
70
sickdays/sickdays/tests/test_gui_controls.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The word-free controls and the drawn icons, against a real Tk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._constants import LOCKERS
|
||||||
|
from sickdays.gui import _tokens as tok
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||||
|
from sickdays.gui._icons import ICONS, LOCKER_ICONS, TAG
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shown(tk_root: tk.Tk) -> tk.Tk:
|
||||||
|
"""The root mapped, so real clicks reach the canvases."""
|
||||||
|
tk_root.deiconify()
|
||||||
|
tk_root.update()
|
||||||
|
return tk_root
|
||||||
|
|
||||||
|
|
||||||
|
def _click(widget: tk.Canvas) -> None:
|
||||||
|
widget.pack()
|
||||||
|
widget.update()
|
||||||
|
widget.event_generate("<Button-1>", x=2, y=2)
|
||||||
|
widget.update()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_icon_draws_something(tk_root: tk.Tk) -> None:
|
||||||
|
for icon in ICONS:
|
||||||
|
slot = IconSlot(tk_root, size=32).show(icon, tok.DANGER)
|
||||||
|
assert slot.icon == icon
|
||||||
|
assert slot.find_withtag(TAG), icon
|
||||||
|
assert slot.show(None).icon is None
|
||||||
|
assert slot.find_withtag(TAG) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_locker_has_an_icon() -> None:
|
||||||
|
assert set(LOCKER_ICONS) == set(LOCKERS)
|
||||||
|
assert set(LOCKER_ICONS.values()) <= set(ICONS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_click_invokes_the_button_unless_disabled(shown: tk.Tk) -> None:
|
||||||
|
calls: list[int] = []
|
||||||
|
button = IconButton(shown, "check", lambda: calls.append(1), name="ok")
|
||||||
|
button.set_enabled(False)
|
||||||
|
assert str(button.cget("cursor")) == "arrow"
|
||||||
|
_click(button)
|
||||||
|
button.invoke()
|
||||||
|
assert calls == []
|
||||||
|
button.set_enabled(True)
|
||||||
|
_click(button)
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_click_flips_the_switch_unless_disabled(shown: tk.Tk) -> None:
|
||||||
|
on = tk.BooleanVar(shown, value=False)
|
||||||
|
switch = Toggle(shown, on, name="switch")
|
||||||
|
_click(switch)
|
||||||
|
assert switch.is_on()
|
||||||
|
assert on.get()
|
||||||
|
switch.set_enabled(False)
|
||||||
|
_click(switch)
|
||||||
|
switch.invoke()
|
||||||
|
assert switch.is_on()
|
||||||
|
switch.set_enabled(True)
|
||||||
|
switch.invoke()
|
||||||
|
assert not switch.is_on()
|
||||||
87
sickdays/sickdays/tests/test_gui_dates.py
Normal file
87
sickdays/sickdays/tests/test_gui_dates.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The picker's calendar arithmetic, free-day check and tokens -- no display."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
|
from sickdays.gui import _dates
|
||||||
|
from sickdays.gui import _tokens as tok
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DAY = date(2026, 10, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kept_lockers_follow_locker_order_not_the_alphabet() -> None:
|
||||||
|
assert _dates.kept_in_order({"alarm", "book", "workout"}) == (
|
||||||
|
"workout",
|
||||||
|
"book",
|
||||||
|
"alarm",
|
||||||
|
)
|
||||||
|
assert _dates.kept_in_order(frozenset()) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_day_block(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
assert _dates.free_day_block(DAY) is None
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
|
)
|
||||||
|
assert _dates.free_day_block(DAY) == "free"
|
||||||
|
|
||||||
|
def unavailable() -> None:
|
||||||
|
msg = "freedays is not installed"
|
||||||
|
raise ConflictCheckUnavailableError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
assert _dates.free_day_block(DAY) == "unavailable"
|
||||||
|
assert "freedays is not installed" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_grid_is_monday_first() -> None:
|
||||||
|
weeks = _dates.month_weeks(date(2026, 10, 1))
|
||||||
|
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
|
||||||
|
assert weeks[-1][-1] is None
|
||||||
|
assert _dates.month_title(date(2026, 10, 1)) == "2026-10"
|
||||||
|
assert _dates.month_title(date(2027, 3, 1)) == "2027-03"
|
||||||
|
assert [_dates.is_weekend_column(c) for c in range(7)] == [
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_arithmetic_and_range() -> None:
|
||||||
|
assert _dates.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
||||||
|
assert _dates.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
|
||||||
|
assert _dates.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_workdays_are_tuesday_to_thursday() -> None:
|
||||||
|
assert [_dates.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tokens() -> None:
|
||||||
|
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
|
||||||
|
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
|
||||||
|
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
|
||||||
|
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK
|
||||||
113
sickdays/sickdays/tests/test_gui_no_words.py
Normal file
113
sickdays/sickdays/tests/test_gui_no_words.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The textgate guard: no letter on any widget of the picker, in any state.
|
||||||
|
|
||||||
|
Everything this window shows is chrome except numerals and ISO dates (day
|
||||||
|
numbers, ``2026-11``, ``2026-11-13``), so the guard bans every letter, not
|
||||||
|
just words. The window title is the app's proper noun (textgate settled
|
||||||
|
decision 4) and is checked on its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import re
|
||||||
|
import tkinter as tk
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._api import add_sick_day
|
||||||
|
from sickdays._day import today
|
||||||
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
|
from sickdays.gui import KeepPanel, SickDaysApp, _panel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FUTURE = today() + timedelta(days=3)
|
||||||
|
LETTER = re.compile(r"[^\W\d_]")
|
||||||
|
|
||||||
|
|
||||||
|
def _shown(widget: tk.Misc) -> list[str]:
|
||||||
|
"""Every string ``widget`` and its descendants paint."""
|
||||||
|
found: list[str] = []
|
||||||
|
for child in widget.winfo_children():
|
||||||
|
if isinstance(child, tk.Canvas):
|
||||||
|
# Raw Tcl: typeshed types Canvas.type()/itemcget() too loosely.
|
||||||
|
path = str(child)
|
||||||
|
found.extend(
|
||||||
|
str(child.tk.call(path, "itemcget", item, "-text"))
|
||||||
|
for item in child.find_all()
|
||||||
|
if str(child.tk.call(path, "type", item)) == "text"
|
||||||
|
)
|
||||||
|
elif isinstance(child, (tk.Label, tk.Button, tk.Checkbutton)):
|
||||||
|
found.append(str(child.cget("text")))
|
||||||
|
found.extend(_shown(child))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_letters(widget: tk.Misc) -> None:
|
||||||
|
shown = _shown(widget)
|
||||||
|
assert shown, "the guard walked no widget"
|
||||||
|
worded = [text for text in shown if LETTER.search(text)]
|
||||||
|
assert worded == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_title_is_the_app_name(tk_root: tk.Tk) -> None:
|
||||||
|
SickDaysApp(tk_root)
|
||||||
|
assert tk_root.title() == "Sick days"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_shows_no_letters(tk_root: tk.Tk) -> None:
|
||||||
|
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
||||||
|
app = SickDaysApp(tk_root)
|
||||||
|
_assert_no_letters(tk_root)
|
||||||
|
app.select(FUTURE + timedelta(days=1))
|
||||||
|
_assert_no_letters(tk_root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_after_a_refusal_shows_no_letters(
|
||||||
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
|
)
|
||||||
|
SickDaysApp(tk_root).select(FUTURE)
|
||||||
|
_assert_no_letters(tk_root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_after_a_save_shows_no_letters(tk_root: tk.Tk) -> None:
|
||||||
|
app = SickDaysApp(tk_root)
|
||||||
|
app.select(FUTURE)
|
||||||
|
(panel,) = app.host.winfo_children()
|
||||||
|
panel.nametowidget("actions.mark").invoke()
|
||||||
|
assert isinstance(panel, KeepPanel)
|
||||||
|
assert panel.sync_thread is not None
|
||||||
|
panel.sync_thread.join(timeout=5)
|
||||||
|
_assert_no_letters(tk_root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_panel_state_shows_no_letters(
|
||||||
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
add_sick_day(FUTURE, keep=["book"])
|
||||||
|
for day in (FUTURE, FUTURE + timedelta(days=1), today() - timedelta(days=1)):
|
||||||
|
_assert_no_letters(
|
||||||
|
KeepPanel(tk_root, day, on_saved=lambda _e: None, on_cancel=lambda: None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def unavailable() -> None:
|
||||||
|
msg = "cannot check the free-day pool"
|
||||||
|
raise ConflictCheckUnavailableError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||||
|
_assert_no_letters(KeepPanel(tk_root, FUTURE + timedelta(days=2), on_saved=print))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_or_busy_write_shows_no_letters(tk_root: tk.Tk) -> None:
|
||||||
|
refused = KeepPanel(tk_root, FUTURE + timedelta(days=4), on_saved=print)
|
||||||
|
add_sick_day(FUTURE + timedelta(days=4))
|
||||||
|
refused.nametowidget("actions.mark").invoke()
|
||||||
|
_assert_no_letters(refused)
|
||||||
|
busy = KeepPanel(tk_root, FUTURE + timedelta(days=5), on_saved=print)
|
||||||
|
with _panel._SYNC_LOCK:
|
||||||
|
busy.nametowidget("actions.mark").invoke()
|
||||||
|
_assert_no_letters(busy)
|
||||||
@ -1,26 +1,27 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
|
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sickdays._api import add_sick_day, is_sick_day, load
|
from sickdays._api import add_sick_day, is_sick_day, load
|
||||||
from sickdays._day import today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
from sickdays._keep_api import kept_lockers
|
from sickdays._keep_api import kept_lockers
|
||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
from sickdays._store import write_pool
|
from sickdays._store import write_pool
|
||||||
from sickdays.gui import KeepPanel, _text
|
from sickdays.gui import KeepPanel
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
from sickdays._model import SickDay
|
from sickdays._model import SickDay
|
||||||
|
|
||||||
@ -40,20 +41,22 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
|
|||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
|
||||||
def _button(panel: KeepPanel, name: str) -> tk.Button:
|
def _button(panel: KeepPanel, name: str) -> IconButton:
|
||||||
widget = panel.nametowidget(f"actions.{name}")
|
widget = panel.nametowidget(f"actions.{name}")
|
||||||
assert isinstance(widget, tk.Button)
|
assert isinstance(widget, IconButton)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
|
def _toggle(panel: KeepPanel, locker: str) -> Toggle:
|
||||||
widget = panel.nametowidget(f"keep-{locker}")
|
widget = panel.nametowidget(f"keep-{locker}")
|
||||||
assert isinstance(widget, tk.Checkbutton)
|
assert isinstance(widget, Toggle)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _status(panel: KeepPanel) -> str:
|
def _status(panel: KeepPanel) -> str | None:
|
||||||
return str(panel.nametowidget("status").cget("text"))
|
slot = panel.nametowidget("status")
|
||||||
|
assert isinstance(slot, IconSlot)
|
||||||
|
return slot.icon
|
||||||
|
|
||||||
|
|
||||||
def _actions(panel: KeepPanel) -> set[str]:
|
def _actions(panel: KeepPanel) -> set[str]:
|
||||||
@ -72,11 +75,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None])
|
|||||||
assert panel.kept() == frozenset()
|
assert panel.kept() == frozenset()
|
||||||
assert _actions(panel) == {"mark"}
|
assert _actions(panel) == {"mark"}
|
||||||
toggle = _toggle(panel, "leetcode")
|
toggle = _toggle(panel, "leetcode")
|
||||||
assert toggle.cget("text") == "Off"
|
assert not toggle.is_on()
|
||||||
toggle.invoke()
|
toggle.invoke()
|
||||||
assert toggle.cget("text") == "On"
|
assert toggle.is_on()
|
||||||
assert str(toggle.cget("bg")).upper() == tok.ACCENT
|
assert _button(panel, "mark").icon == "check"
|
||||||
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
|
assert _status(panel) is None
|
||||||
_button(panel, "mark").invoke()
|
_button(panel, "mark").invoke()
|
||||||
_join(panel)
|
_join(panel)
|
||||||
assert is_sick_day(FUTURE)
|
assert is_sick_day(FUTURE)
|
||||||
@ -93,8 +96,14 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
|
|||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
||||||
assert panel.kept() == frozenset({"workout"})
|
assert panel.kept() == frozenset({"workout"})
|
||||||
assert _actions(panel) == {"save", "clear"}
|
assert _actions(panel) == {"save", "clear"}
|
||||||
title = panel.nametowidget("title").cget("text")
|
assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE)
|
||||||
assert title == f"Sick day: {_text.day_label(FUTURE)}"
|
state = panel.nametowidget("head.state")
|
||||||
|
assert isinstance(state, IconSlot)
|
||||||
|
assert state.icon == "sick"
|
||||||
|
assert _button(panel, "clear").icon == "trash"
|
||||||
|
icon = panel.nametowidget("icon-workout")
|
||||||
|
assert isinstance(icon, IconSlot)
|
||||||
|
assert icon.icon == "workout"
|
||||||
|
|
||||||
|
|
||||||
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||||
@ -139,20 +148,27 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None])
|
|||||||
|
|
||||||
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
||||||
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
||||||
assert _status(panel) == _text.PAST_NOTE
|
assert panel.reason == "past"
|
||||||
|
assert _status(panel) == "padlock"
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
|
diet = _toggle(panel, "diet")
|
||||||
|
assert str(diet.cget("state")) == "disabled"
|
||||||
|
diet.invoke()
|
||||||
|
assert not diet.is_on()
|
||||||
|
|
||||||
|
|
||||||
def test_a_free_day_is_refused_with_the_phones_message(
|
def test_a_free_day_is_refused_with_the_phones_message(
|
||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||||
expected = "is a free day — release it in Free days first."
|
assert panel.reason == "free"
|
||||||
assert _status(panel).endswith(expected)
|
assert _status(panel) == "no_entry"
|
||||||
|
state = panel.nametowidget("head.state")
|
||||||
|
assert isinstance(state, IconSlot)
|
||||||
|
assert state.icon == "normal"
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
|
|
||||||
|
|
||||||
@ -163,20 +179,24 @@ def test_an_uncheckable_free_pool_refuses(
|
|||||||
msg = "cannot check the free-day pool"
|
msg = "cannot check the free-day pool"
|
||||||
raise ConflictCheckUnavailableError(msg)
|
raise ConflictCheckUnavailableError(msg)
|
||||||
|
|
||||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||||
assert _status(panel) == "cannot check the free-day pool"
|
assert panel.reason == "unavailable"
|
||||||
|
assert _status(panel) == "warning"
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
|
|
||||||
|
|
||||||
def test_a_refused_write_is_shown_not_raised(
|
def test_a_refused_write_is_shown_not_raised(
|
||||||
tk_root: tk.Tk, synced: list[Paths | None]
|
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
saved: list[SickDay | None] = []
|
saved: list[SickDay | None] = []
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||||
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
||||||
_button(panel, "mark").invoke()
|
with caplog.at_level(logging.WARNING):
|
||||||
assert "already a sick day" in _status(panel)
|
_button(panel, "mark").invoke()
|
||||||
|
assert panel.reason == "refused"
|
||||||
|
assert _status(panel) == "warning"
|
||||||
|
assert "already a sick day" in caplog.text
|
||||||
assert saved == []
|
assert saved == []
|
||||||
assert synced == []
|
assert synced == []
|
||||||
|
|
||||||
@ -189,7 +209,9 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
|
|||||||
on_saved=lambda _e: None,
|
on_saved=lambda _e: None,
|
||||||
on_cancel=lambda: cancelled.append(True),
|
on_cancel=lambda: cancelled.append(True),
|
||||||
)
|
)
|
||||||
_button(panel, "cancel").invoke()
|
cancel = _button(panel, "cancel")
|
||||||
|
assert cancel.icon == "cross"
|
||||||
|
cancel.invoke()
|
||||||
assert cancelled == [True]
|
assert cancelled == [True]
|
||||||
|
|
||||||
|
|
||||||
@ -242,4 +264,5 @@ def test_a_write_during_a_sync_is_refused_not_lost(
|
|||||||
for entry in saved:
|
for entry in saved:
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
||||||
assert _status(second) == _text.BUSY_NOTE
|
assert second.reason == "busy"
|
||||||
|
assert _status(second) == "hourglass"
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The picker's wording, calendar arithmetic and tokens -- no display needed."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
|
||||||
from sickdays.gui import _text
|
|
||||||
from sickdays.gui import _tokens as tok
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
DAY = date(2026, 10, 20)
|
|
||||||
|
|
||||||
|
|
||||||
def test_labels_match_the_phone() -> None:
|
|
||||||
assert _text.day_label(DAY) == "Tue 2026-10-20"
|
|
||||||
assert _text.panel_title(DAY, sick=False) == "Mark sick: Tue 2026-10-20"
|
|
||||||
assert _text.panel_title(DAY, sick=True) == "Sick day: Tue 2026-10-20"
|
|
||||||
assert list(_text.LOCKER_LABELS.values()) == [
|
|
||||||
"Workout lock",
|
|
||||||
"LeetCode",
|
|
||||||
"Book",
|
|
||||||
"Diet",
|
|
||||||
"Wake alarm",
|
|
||||||
"Focus",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_kept_label_follows_locker_order_not_the_alphabet() -> None:
|
|
||||||
assert _text.kept_label({"alarm", "book", "workout"}) == (
|
|
||||||
"Workout lock, Book, Wake alarm"
|
|
||||||
)
|
|
||||||
assert _text.kept_label(frozenset()) == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_free_day_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
assert _text.free_day_refusal(DAY) is None
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
|
||||||
)
|
|
||||||
assert _text.free_day_refusal(DAY) == (
|
|
||||||
"2026-10-20 is a free day — release it in Free days first."
|
|
||||||
)
|
|
||||||
|
|
||||||
def unavailable() -> None:
|
|
||||||
msg = "freedays is not installed"
|
|
||||||
raise ConflictCheckUnavailableError(msg)
|
|
||||||
|
|
||||||
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
|
||||||
assert _text.free_day_refusal(DAY) == "freedays is not installed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_month_grid_is_monday_first() -> None:
|
|
||||||
weeks = _text.month_weeks(date(2026, 10, 1))
|
|
||||||
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
|
|
||||||
assert weeks[-1][-1] is None
|
|
||||||
assert _text.month_title(date(2026, 10, 1)) == "October 2026"
|
|
||||||
|
|
||||||
|
|
||||||
def test_month_arithmetic_and_range() -> None:
|
|
||||||
assert _text.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
|
||||||
assert _text.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
|
|
||||||
assert _text.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_workdays_are_tuesday_to_thursday() -> None:
|
|
||||||
assert [_text.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
|
|
||||||
False,
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_tokens() -> None:
|
|
||||||
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
|
|
||||||
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
|
|
||||||
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
|
|
||||||
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK
|
|
||||||
@ -10,8 +10,9 @@ from typing import TYPE_CHECKING
|
|||||||
from sickdays._api import add_sick_day
|
from sickdays._api import add_sick_day
|
||||||
from sickdays._day import to_iso, today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._model import SickDay
|
from sickdays._model import SickDay
|
||||||
from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
|
from sickdays.gui import KeepPanel, SickDaysApp, _dates, _window
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
import pytest
|
||||||
@ -19,13 +20,19 @@ if TYPE_CHECKING:
|
|||||||
FUTURE = today() + timedelta(days=3)
|
FUTURE = today() + timedelta(days=3)
|
||||||
|
|
||||||
|
|
||||||
def _texts(widget: tk.Misc) -> list[str]:
|
def _upcoming(app: SickDaysApp) -> dict[str, list[str]]:
|
||||||
found: list[str] = []
|
"""Each listed day's ISO date -> the locker icons it keeps."""
|
||||||
for child in widget.winfo_children():
|
rows = app.calendar.master.nametowidget("upcoming").winfo_children()
|
||||||
if isinstance(child, tk.Label):
|
listed: dict[str, list[str]] = {}
|
||||||
found.append(str(child.cget("text")))
|
for row in rows:
|
||||||
found.extend(_texts(child))
|
icons = [
|
||||||
return found
|
str(slot.icon)
|
||||||
|
for slot in row.winfo_children()
|
||||||
|
if isinstance(slot, IconSlot)
|
||||||
|
]
|
||||||
|
(date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)]
|
||||||
|
listed[str(date_label.cget("text"))] = icons
|
||||||
|
return listed
|
||||||
|
|
||||||
|
|
||||||
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
||||||
@ -40,14 +47,17 @@ def _panel(app: SickDaysApp) -> KeepPanel:
|
|||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
|
def test_an_empty_pool_lists_nothing_ahead(tk_root: tk.Tk) -> None:
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
assert tk_root.title() == "Sick days"
|
assert tk_root.title() == "Sick days"
|
||||||
assert _text.NONE_AHEAD in _texts(tk_root)
|
assert _upcoming(app) == {}
|
||||||
|
assert app.status.icon is None
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
|
|
||||||
|
|
||||||
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
||||||
|
later = FUTURE + timedelta(days=1)
|
||||||
|
add_sick_day(later)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.calendar.month = FUTURE.replace(day=1)
|
app.calendar.month = FUTURE.replace(day=1)
|
||||||
app.calendar.redraw()
|
app.calendar.redraw()
|
||||||
@ -58,10 +68,8 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
|||||||
assert panel.sync_thread is not None
|
assert panel.sync_thread is not None
|
||||||
panel.sync_thread.join(timeout=5)
|
panel.sync_thread.join(timeout=5)
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
assert app.status.cget("text") == _window.saved_message(
|
assert app.status.icon == "check"
|
||||||
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
|
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
|
||||||
)
|
|
||||||
assert "Kept: LeetCode" in _texts(tk_root)
|
|
||||||
cell = _cell(app, FUTURE)
|
cell = _cell(app, FUTURE)
|
||||||
assert str(cell.cget("bg")).upper() == tok.SICK
|
assert str(cell.cget("bg")).upper() == tok.SICK
|
||||||
assert cell.nametowidget("dot").winfo_exists()
|
assert cell.nametowidget("dot").winfo_exists()
|
||||||
@ -78,13 +86,12 @@ def test_a_free_day_is_refused_before_the_panel_opens(
|
|||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.select(FUTURE)
|
app.select(FUTURE)
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
assert str(app.status.cget("text")).endswith("release it in Free days first.")
|
assert app.status.icon == "no_entry"
|
||||||
assert str(app.status.cget("fg")).upper() == tok.DANGER
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_sick_day_on_a_free_day_still_opens(
|
def test_a_sick_day_on_a_free_day_still_opens(
|
||||||
@ -92,7 +99,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
|||||||
) -> None:
|
) -> None:
|
||||||
add_sick_day(FUTURE)
|
add_sick_day(FUTURE)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.select(FUTURE)
|
app.select(FUTURE)
|
||||||
@ -102,19 +109,31 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
|||||||
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
||||||
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
||||||
add_sick_day(FUTURE + timedelta(days=1))
|
add_sick_day(FUTURE + timedelta(days=1))
|
||||||
texts = _texts(SickDaysApp(tk_root).calendar.master)
|
past = today() - timedelta(days=1)
|
||||||
assert _text.day_label(FUTURE) in texts
|
add_sick_day(past, now=past)
|
||||||
assert "Kept: Workout lock, Wake alarm" in texts
|
assert _upcoming(SickDaysApp(tk_root)) == {
|
||||||
assert _text.NONE_AHEAD not in texts
|
to_iso(FUTURE): ["workout", "alarm"],
|
||||||
|
to_iso(FUTURE + timedelta(days=1)): [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_saved_message_wording() -> None:
|
def test_saved_icon() -> None:
|
||||||
sick = SickDay(FUTURE, is_sick=True)
|
assert _window.saved_icon(None) is None
|
||||||
label = _text.day_label(FUTURE)
|
assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check"
|
||||||
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
|
assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check"
|
||||||
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
|
|
||||||
cleared = SickDay(FUTURE, is_sick=False)
|
|
||||||
assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
|
def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None:
|
||||||
|
app = SickDaysApp(tk_root)
|
||||||
|
title = app.calendar.nametowidget("header").winfo_children()[-1]
|
||||||
|
assert isinstance(title, tk.Label)
|
||||||
|
assert title.cget("text") == _dates.month_title(today().replace(day=1))
|
||||||
|
marks = [
|
||||||
|
child.icon
|
||||||
|
for child in app.calendar.nametowidget("days").winfo_children()
|
||||||
|
if isinstance(child, IconSlot)
|
||||||
|
]
|
||||||
|
assert marks == ["dot"] * 5 + ["ring"] * 2
|
||||||
|
|
||||||
|
|
||||||
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
||||||
@ -128,7 +147,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
|||||||
|
|
||||||
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
first, last = _text.month_bounds(today())
|
first, last = _dates.month_bounds(today())
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
app.calendar.step(1)
|
app.calendar.step(1)
|
||||||
assert app.calendar.month == last
|
assert app.calendar.month == last
|
||||||
@ -136,6 +155,8 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
|||||||
app.calendar.step(-1)
|
app.calendar.step(-1)
|
||||||
assert app.calendar.month == first
|
assert app.calendar.month == first
|
||||||
back = app.calendar.nametowidget("header.prev")
|
back = app.calendar.nametowidget("header.prev")
|
||||||
|
assert isinstance(back, IconButton)
|
||||||
|
assert back.icon == "prev"
|
||||||
assert str(back.cget("state")) == "disabled"
|
assert str(back.cget("state")) == "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
56
sync_settings_ui/lib/src/sync_settings_cues.dart
Normal file
56
sync_settings_ui/lib/src/sync_settings_cues.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
part of 'sync_settings_screen.dart';
|
||||||
|
|
||||||
|
/// The screen's glyph vocabulary, in place of written headings and status
|
||||||
|
/// sentences (kuhy's textgate rule). Every old sentence survives as the
|
||||||
|
/// spoken label, so screen readers and widget tests still find it.
|
||||||
|
|
||||||
|
/// A result: a glyph, the old sentence (spoken only), and whether it is a
|
||||||
|
/// failure. Colour is the second channel: danger hue for failures.
|
||||||
|
class _Cue {
|
||||||
|
const _Cue(this.icon, this.spoken) : error = false;
|
||||||
|
|
||||||
|
const _Cue.error(this.icon, this.spoken) : error = true;
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String spoken;
|
||||||
|
final bool error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints a [_Cue] as a large glyph.
|
||||||
|
class _CueView extends StatelessWidget {
|
||||||
|
const _CueView(this.cue);
|
||||||
|
|
||||||
|
final _Cue cue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(
|
||||||
|
cue.icon,
|
||||||
|
size: 32,
|
||||||
|
color: cue.error ? scheme.error : scheme.primary,
|
||||||
|
semanticLabel: cue.spoken,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section heading as a glyph, spoken as [spoken].
|
||||||
|
class _Heading extends StatelessWidget {
|
||||||
|
const _Heading(this.icon, this.spoken);
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String spoken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
semanticLabel: spoken,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import 'package:sync_settings_ui/src/backup_slot.dart';
|
|||||||
import 'package:sync_settings_ui/src/firebase_connect_result.dart';
|
import 'package:sync_settings_ui/src/firebase_connect_result.dart';
|
||||||
import 'package:sync_settings_ui/src/firebase_sync_controller.dart';
|
import 'package:sync_settings_ui/src/firebase_sync_controller.dart';
|
||||||
|
|
||||||
|
part 'sync_settings_cues.dart';
|
||||||
part 'sync_settings_sections.dart';
|
part 'sync_settings_sections.dart';
|
||||||
part 'sync_settings_widget.dart';
|
part 'sync_settings_widget.dart';
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _firebaseConnected = false;
|
bool _firebaseConnected = false;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _status;
|
_Cue? _status;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -56,7 +57,12 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
final password = _passwordController.text;
|
final password = _passwordController.text;
|
||||||
if (email.isEmpty || password.isEmpty) {
|
if (email.isEmpty || password.isEmpty) {
|
||||||
setState(() => _status = 'Enter the sync account email and password.');
|
setState(
|
||||||
|
() => _status = const _Cue.error(
|
||||||
|
Icons.edit_off,
|
||||||
|
'Enter the sync account email and password.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
@ -64,7 +70,7 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
onProgress: (stage) {
|
onProgress: (stage) {
|
||||||
if (mounted) setState(() => _status = stage);
|
if (mounted) setState(() => _status = _Cue(Icons.hourglass_top, stage));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -74,7 +80,7 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
Future<void> _connectGoogle() async {
|
Future<void> _connectGoogle() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_status = 'Signing in…';
|
_status = const _Cue(Icons.hourglass_top, 'Signing in…');
|
||||||
});
|
});
|
||||||
final result = await _firebase.connectWithGoogle();
|
final result = await _firebase.connectWithGoogle();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -93,17 +99,35 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
_firebaseConnected = result.outcome == FirebaseConnectOutcome.connected;
|
_firebaseConnected = result.outcome == FirebaseConnectOutcome.connected;
|
||||||
|
// One glyph per outcome; the sentence is spoken only.
|
||||||
_status = switch (result.outcome) {
|
_status = switch (result.outcome) {
|
||||||
FirebaseConnectOutcome.connected => 'Connected to Firebase.',
|
FirebaseConnectOutcome.connected => const _Cue(
|
||||||
FirebaseConnectOutcome.rejected => result.message != null
|
Icons.cloud_done,
|
||||||
? 'Firebase rejected that account: ${result.message}'
|
'Connected to Firebase.',
|
||||||
: 'Firebase rejected that account.',
|
),
|
||||||
FirebaseConnectOutcome.cancelled => 'Google sign-in was cancelled.',
|
FirebaseConnectOutcome.rejected => _Cue.error(
|
||||||
FirebaseConnectOutcome.signedInButNotPersisted =>
|
Icons.lock,
|
||||||
|
result.message != null
|
||||||
|
? 'Firebase rejected that account: ${result.message}'
|
||||||
|
: 'Firebase rejected that account.',
|
||||||
|
),
|
||||||
|
FirebaseConnectOutcome.cancelled => const _Cue(
|
||||||
|
Icons.cancel_outlined,
|
||||||
|
'Google sign-in was cancelled.',
|
||||||
|
),
|
||||||
|
FirebaseConnectOutcome.signedInButNotPersisted => const _Cue.error(
|
||||||
|
Icons.save_as_outlined,
|
||||||
'Signed in, but this device did not save the session - it will '
|
'Signed in, but this device did not save the session - it will '
|
||||||
'sync over GitHub after a restart. Try connecting again.',
|
'sync over GitHub after a restart. Try connecting again.',
|
||||||
FirebaseConnectOutcome.wrongAccount => result.message,
|
),
|
||||||
FirebaseConnectOutcome.failed => 'Sign-in failed: ${result.message}',
|
FirebaseConnectOutcome.wrongAccount => _Cue.error(
|
||||||
|
Icons.no_accounts,
|
||||||
|
result.message ?? '',
|
||||||
|
),
|
||||||
|
FirebaseConnectOutcome.failed => _Cue.error(
|
||||||
|
Icons.error,
|
||||||
|
'Sign-in failed: ${result.message}',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -115,32 +139,37 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
_passwordController.clear();
|
_passwordController.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_firebaseConnected = false;
|
_firebaseConnected = false;
|
||||||
_status = 'Firebase disconnected.';
|
_status = const _Cue(Icons.link_off, 'Firebase disconnected.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _exportBackup(BackupSlot backup) async {
|
Future<void> _exportBackup(BackupSlot backup) async {
|
||||||
try {
|
try {
|
||||||
await backup.export();
|
await backup.export();
|
||||||
if (mounted) setState(() => _status = 'Exported ${backup.label}.');
|
_show(_Cue(Icons.upload, 'Exported ${backup.label}.'));
|
||||||
} on BackupCancelled {
|
} on BackupCancelled {
|
||||||
if (mounted) setState(() => _status = null);
|
_show(null);
|
||||||
} on Exception catch (error) {
|
} on Exception catch (error) {
|
||||||
if (mounted) setState(() => _status = 'Export failed: $error');
|
_show(_Cue.error(Icons.upload, 'Export failed: $error'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _importBackup(BackupSlot backup) async {
|
Future<void> _importBackup(BackupSlot backup) async {
|
||||||
try {
|
try {
|
||||||
await backup.import();
|
await backup.import();
|
||||||
if (mounted) setState(() => _status = 'Imported ${backup.label}.');
|
_show(_Cue(Icons.download, 'Imported ${backup.label}.'));
|
||||||
} on BackupCancelled {
|
} on BackupCancelled {
|
||||||
if (mounted) setState(() => _status = null);
|
_show(null);
|
||||||
} on Exception catch (error) {
|
} on Exception catch (error) {
|
||||||
if (mounted) setState(() => _status = 'Import failed: $error');
|
_show(_Cue.error(Icons.download, 'Import failed: $error'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows [cue] as the latest result (null clears it), if still mounted.
|
||||||
|
void _show(_Cue? cue) {
|
||||||
|
if (mounted) setState(() => _status = cue);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
@ -150,20 +179,23 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Glyphs in place of the title and section headings (kuhy's textgate
|
||||||
|
// rule: nothing he sees carries app-written words); the old words are
|
||||||
|
// the spoken labels.
|
||||||
|
const title = Icon(Icons.sync, semanticLabel: 'Sync settings');
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sync settings')),
|
appBar: AppBar(title: title),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final backup = widget.backup;
|
final backup = widget.backup;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sync settings')),
|
appBar: AppBar(title: title),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Text('Firebase sync', style: textTheme.titleMedium),
|
const _Heading(Icons.local_fire_department, 'Firebase sync'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_FirebaseSection(
|
_FirebaseSection(
|
||||||
connected: _firebaseConnected,
|
connected: _firebaseConnected,
|
||||||
@ -180,7 +212,7 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('Backup', style: textTheme.titleMedium),
|
const _Heading(Icons.backup_outlined, 'Backup'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_BackupSection(
|
_BackupSection(
|
||||||
backup: backup,
|
backup: backup,
|
||||||
@ -188,9 +220,9 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
|||||||
onImport: () => _importBackup(backup),
|
onImport: () => _importBackup(backup),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_status != null) ...[
|
if (_status case final status?) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status!, style: textTheme.bodyMedium),
|
_CueView(status),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -36,9 +36,9 @@ class _FirebaseSection extends StatelessWidget {
|
|||||||
const Icon(Icons.cloud_done, size: 20),
|
const Icon(Icons.cloud_done, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: Text(emailController.text)),
|
Expanded(child: Text(emailController.text)),
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: busy ? null : onDisconnect,
|
onPressed: busy ? null : onDisconnect,
|
||||||
child: const Text('Disconnect'),
|
icon: const Icon(Icons.link_off, semanticLabel: 'Disconnect'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -49,10 +49,12 @@ class _FirebaseSection extends StatelessWidget {
|
|||||||
if (googleAvailable) ...[
|
if (googleAvailable) ...[
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FilledButton.icon(
|
child: FilledButton(
|
||||||
onPressed: busy ? null : onConnectGoogle,
|
onPressed: busy ? null : onConnectGoogle,
|
||||||
icon: const Icon(Icons.account_circle),
|
child: const Icon(
|
||||||
label: const Text('Sign in with Google'),
|
Icons.account_circle,
|
||||||
|
semanticLabel: 'Sign in with Google',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@ -61,24 +63,35 @@ class _FirebaseSection extends StatelessWidget {
|
|||||||
// is not the "control that can never succeed" trap -- that one
|
// is not the "control that can never succeed" trap -- that one
|
||||||
// looks live and reports a fake cancellation. This one says why,
|
// looks live and reports a fake cancellation. This one says why,
|
||||||
// which beats a screen that silently offers less on one platform.
|
// which beats a screen that silently offers less on one platform.
|
||||||
Align(
|
// The why is a block glyph beside it, the reason spoken only.
|
||||||
alignment: Alignment.centerLeft,
|
Row(
|
||||||
child: FilledButton.icon(
|
children: [
|
||||||
onPressed: null,
|
const FilledButton(
|
||||||
icon: const Icon(Icons.account_circle),
|
onPressed: null,
|
||||||
label: const Text('Sign in with Google'),
|
child: Icon(
|
||||||
),
|
Icons.account_circle,
|
||||||
),
|
semanticLabel: 'Sign in with Google',
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
Text(
|
),
|
||||||
googleUnavailableReason!,
|
const SizedBox(width: 8),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
Icon(
|
||||||
|
Icons.block,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
semanticLabel: googleUnavailableReason,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
ExpansionTile(
|
ExpansionTile(
|
||||||
initiallyExpanded: !googleAvailable,
|
initiallyExpanded: !googleAvailable,
|
||||||
title: const Text('Use the account password instead'),
|
title: const Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(
|
||||||
|
Icons.password,
|
||||||
|
semanticLabel: 'Use the account password instead',
|
||||||
|
),
|
||||||
|
),
|
||||||
tilePadding: EdgeInsets.zero,
|
tilePadding: EdgeInsets.zero,
|
||||||
childrenPadding: const EdgeInsets.only(bottom: 8),
|
childrenPadding: const EdgeInsets.only(bottom: 8),
|
||||||
children: [
|
children: [
|
||||||
@ -87,7 +100,10 @@ class _FirebaseSection extends StatelessWidget {
|
|||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Sync account email',
|
prefixIcon: Icon(
|
||||||
|
Icons.alternate_email,
|
||||||
|
semanticLabel: 'Sync account email',
|
||||||
|
),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -97,17 +113,22 @@ class _FirebaseSection extends StatelessWidget {
|
|||||||
obscureText: true,
|
obscureText: true,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Sync account password',
|
prefixIcon: Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
semanticLabel: 'Sync account password',
|
||||||
|
),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FilledButton.icon(
|
child: FilledButton(
|
||||||
onPressed: busy ? null : onConnectPassword,
|
onPressed: busy ? null : onConnectPassword,
|
||||||
icon: const Icon(Icons.cloud_done),
|
child: const Icon(
|
||||||
label: const Text('Connect Firebase'),
|
Icons.cloud_done,
|
||||||
|
semanticLabel: 'Connect Firebase',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -133,18 +154,19 @@ class _BackupSection extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton(
|
||||||
onPressed: onExport,
|
onPressed: onExport,
|
||||||
icon: const Icon(Icons.upload),
|
child: Icon(Icons.upload, semanticLabel: 'Export ${backup.label}'),
|
||||||
label: Text('Export ${backup.label}'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton(
|
||||||
onPressed: onImport,
|
onPressed: onImport,
|
||||||
icon: const Icon(Icons.download),
|
child: Icon(
|
||||||
label: Text('Import ${backup.label}'),
|
Icons.download,
|
||||||
|
semanticLabel: 'Import ${backup.label}',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ description: >-
|
|||||||
injected closures -- no keystore or platform plugin access lives in this
|
injected closures -- no keystore or platform plugin access lives in this
|
||||||
package itself.
|
package itself.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.6.2
|
version: 0.7.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@ -19,7 +19,7 @@ dependencies:
|
|||||||
data_transfer_flutter:
|
data_transfer_flutter:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: data_transfer_flutter-v0.2.1
|
ref: data_transfer_flutter-v0.2.2
|
||||||
path: data_transfer_flutter
|
path: data_transfer_flutter
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -38,15 +38,15 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Export notes'));
|
await tester.tap(find.bySemanticsLabel('Export notes'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Exported notes.'), findsNothing);
|
expect(find.bySemanticsLabel('Exported notes.'), findsNothing);
|
||||||
expect(find.textContaining('failed'), findsNothing);
|
expect(spoken('failed'), findsNothing);
|
||||||
|
|
||||||
await tester.tap(find.text('Import notes'));
|
await tester.tap(find.bySemanticsLabel('Import notes'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Imported notes.'), findsNothing);
|
expect(find.bySemanticsLabel('Imported notes.'), findsNothing);
|
||||||
expect(find.textContaining('failed'), findsNothing);
|
expect(spoken('failed'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('omitted entirely when backup is null', (tester) async {
|
testWidgets('omitted entirely when backup is null', (tester) async {
|
||||||
@ -63,7 +63,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Backup'), findsNothing);
|
expect(find.bySemanticsLabel('Backup'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('export and import call the injected callbacks', (
|
testWidgets('export and import call the injected callbacks', (
|
||||||
@ -89,16 +89,16 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Backup'), findsOneWidget);
|
expect(find.bySemanticsLabel('Backup'), findsOneWidget);
|
||||||
await tester.tap(find.text('Export notes'));
|
await tester.tap(find.bySemanticsLabel('Export notes'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(exported, isTrue);
|
expect(exported, isTrue);
|
||||||
expect(find.text('Exported notes.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Exported notes.'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Import notes'));
|
await tester.tap(find.bySemanticsLabel('Import notes'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(imported, isTrue);
|
expect(imported, isTrue);
|
||||||
expect(find.text('Imported notes.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Imported notes.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('export failure reports the error', (tester) async {
|
testWidgets('export failure reports the error', (tester) async {
|
||||||
@ -120,9 +120,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Export notes'));
|
await tester.tap(find.bySemanticsLabel('Export notes'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Export failed'), findsOneWidget);
|
expect(spoken('Export failed'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('import failure reports the error', (tester) async {
|
testWidgets('import failure reports the error', (tester) async {
|
||||||
@ -144,9 +144,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Import notes'));
|
await tester.tap(find.bySemanticsLabel('Import notes'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Import failed'), findsOneWidget);
|
expect(spoken('Import failed'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -173,3 +173,7 @@ void main() {
|
|||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node whose spoken label contains [part] literally.
|
||||||
|
Finder spoken(String part) =>
|
||||||
|
find.bySemanticsLabel(RegExp(RegExp.escape(part)));
|
||||||
|
|||||||
@ -35,10 +35,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
await tester.tap(find.bySemanticsLabel('Sign in with Google'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Google sign-in was cancelled.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Google sign-in was cancelled.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@ -60,11 +60,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
await tester.tap(find.bySemanticsLabel('Sign in with Google'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('did not save the session'),
|
spoken('did not save the session'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -89,10 +89,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
await tester.tap(find.bySemanticsLabel('Sign in with Google'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('wrong uid'), findsOneWidget);
|
expect(find.bySemanticsLabel('wrong uid'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Google sign-in generic failure surfaces the error text', (
|
testWidgets('Google sign-in generic failure surfaces the error text', (
|
||||||
@ -113,10 +113,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
await tester.tap(find.bySemanticsLabel('Sign in with Google'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('Sign-in failed'), findsOneWidget);
|
expect(spoken('Sign-in failed'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@ -138,7 +138,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Sign in with Google'), findsNothing);
|
expect(find.bySemanticsLabel('Sign in with Google'), findsNothing);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -161,12 +161,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Sign in with Google'), findsOneWidget);
|
expect(find.bySemanticsLabel('Sign in with Google'), findsOneWidget);
|
||||||
expect(find.text('Not available on this platform.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Not available on this platform.'), findsOneWidget);
|
||||||
|
|
||||||
// Disabled, so it cannot report a misleading "cancelled".
|
// Disabled, so it cannot report a misleading "cancelled".
|
||||||
final button = tester.widget<FilledButton>(
|
final button = tester.widget<FilledButton>(
|
||||||
find.widgetWithText(FilledButton, 'Sign in with Google'),
|
find.widgetWithIcon(FilledButton, Icons.account_circle),
|
||||||
);
|
);
|
||||||
expect(button.onPressed, isNull);
|
expect(button.onPressed, isNull);
|
||||||
});
|
});
|
||||||
@ -189,7 +189,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Sign in with Google'), findsNothing);
|
expect(find.bySemanticsLabel('Sign in with Google'), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node whose spoken label contains [part] literally.
|
||||||
|
Finder spoken(String part) =>
|
||||||
|
find.bySemanticsLabel(RegExp(RegExp.escape(part)));
|
||||||
|
|||||||
@ -52,7 +52,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('a@b.com'), findsOneWidget);
|
expect(find.text('a@b.com'), findsOneWidget);
|
||||||
expect(find.text('Disconnect'), findsOneWidget);
|
expect(find.bySemanticsLabel('Disconnect'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('disconnect clears the account and shows status', (
|
testWidgets('disconnect clears the account and shows status', (
|
||||||
@ -73,12 +73,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Disconnect'));
|
await tester.tap(find.bySemanticsLabel('Disconnect'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(cleared, isTrue);
|
expect(cleared, isTrue);
|
||||||
expect(find.text('Firebase disconnected.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Firebase disconnected.'), findsOneWidget);
|
||||||
expect(find.text('Connect Firebase'), findsOneWidget);
|
expect(find.bySemanticsLabel('Connect Firebase'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('connect with password shows an error when empty', (
|
testWidgets('connect with password shows an error when empty', (
|
||||||
@ -98,11 +98,11 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Google unavailable by default -> password form starts expanded.
|
// Google unavailable by default -> password form starts expanded.
|
||||||
await tester.tap(find.text('Connect Firebase'));
|
await tester.tap(find.bySemanticsLabel('Connect Firebase'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.text('Enter the sync account email and password.'),
|
find.bySemanticsLabel('Enter the sync account email and password.'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -124,17 +124,17 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextField, 'Sync account email'),
|
find.widgetWithIcon(TextField, Icons.alternate_email),
|
||||||
'a@b.com',
|
'a@b.com',
|
||||||
);
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextField, 'Sync account password'),
|
find.widgetWithIcon(TextField, Icons.lock_outline),
|
||||||
'pw',
|
'pw',
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Connect Firebase'));
|
await tester.tap(find.bySemanticsLabel('Connect Firebase'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Connected to Firebase.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Connected to Firebase.'), findsOneWidget);
|
||||||
expect(find.text('a@b.com'), findsOneWidget);
|
expect(find.text('a@b.com'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -153,17 +153,17 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextField, 'Sync account email'),
|
find.widgetWithIcon(TextField, Icons.alternate_email),
|
||||||
'a@b.com',
|
'a@b.com',
|
||||||
);
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextField, 'Sync account password'),
|
find.widgetWithIcon(TextField, Icons.lock_outline),
|
||||||
'wrong',
|
'wrong',
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Connect Firebase'));
|
await tester.tap(find.bySemanticsLabel('Connect Firebase'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Firebase rejected that account.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Firebase rejected that account.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@ -186,23 +186,23 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextField, 'Sync account email'),
|
find.widgetWithIcon(TextField, Icons.alternate_email),
|
||||||
'a@b.com',
|
'a@b.com',
|
||||||
);
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextField, 'Sync account password'),
|
find.widgetWithIcon(TextField, Icons.lock_outline),
|
||||||
'wrong',
|
'wrong',
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Connect Firebase'));
|
await tester.tap(find.bySemanticsLabel('Connect Firebase'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Signing in to Firebase…'), findsNothing);
|
expect(find.bySemanticsLabel('Signing in to Firebase…'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
find.text('Firebase rejected that account: bad creds'),
|
find.bySemanticsLabel('Firebase rejected that account: bad creds'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
final button = tester.widget<FilledButton>(
|
final button = tester.widget<FilledButton>(
|
||||||
find.widgetWithText(FilledButton, 'Connect Firebase'),
|
find.widgetWithIcon(FilledButton, Icons.cloud_done),
|
||||||
);
|
);
|
||||||
expect(button.onPressed, isNotNull);
|
expect(button.onPressed, isNotNull);
|
||||||
},
|
},
|
||||||
@ -231,18 +231,18 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Sign in with Google'), findsOneWidget);
|
expect(find.bySemanticsLabel('Sign in with Google'), findsOneWidget);
|
||||||
// The password form is collapsed behind its ExpansionTile when
|
// The password form is collapsed behind its ExpansionTile when
|
||||||
// Google is offered -- only the field labels prove that; the tile's
|
// Google is offered -- only the field labels prove that; the tile's
|
||||||
// own title text is present either way.
|
// own title text is present either way.
|
||||||
expect(
|
expect(
|
||||||
find.widgetWithText(TextField, 'Sync account email'),
|
find.widgetWithIcon(TextField, Icons.alternate_email),
|
||||||
findsNothing,
|
findsNothing,
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
await tester.tap(find.bySemanticsLabel('Sign in with Google'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Connected to Firebase.'), findsOneWidget);
|
expect(find.bySemanticsLabel('Connected to Firebase.'), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user