Compare commits

..

7 Commits

Author SHA1 Message Date
2d341dd41e gatelock 0.13.0: locks stay clickable together
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A keyring prompt (gcr-prompter) that grabbed input before the
leetcode lock armed left the lock visible but unclickable: every
click went to the prompt hidden under it. Each failed
grab_set_global also froze Tk ~1 s, so the lock barely repainted.

- Raise known credential prompts above the lock after 2 s of a
  blocked grab; games and other grabbers stay covered.
- Probe the grab on a side Xlib connection first so a blocked
  grab never stalls the Tk loop (worst gap 1.05 s -> 0.05 s).
- Log the Tk grab error and stop blaming our own claim.
- 1.5 s startup grace in wait_for_turn, and a weaker armed lock
  steps back (windowless, re-arms later) when a stronger one is
  live, so only one lock is ever on screen.
- Real-X checks: verify_prompt_yield, verify_grab_responsive,
  verify_all_lockers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZbpxQZj41WpbQPuxB
2026-11-11 12:49:40 +01:00
3084080d74 sync_settings_ui 0.7.0, gda 0.4.0: text-free
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Glyph + spoken label replaces every app-written string on the shared
Sync settings screen and the GitHub device-code dialog (kuhy's
textgate rule). Old sentences are semantics labels; tests find them
there. The device user code stays visible (GitHub's data).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:27:57 +01:00
9d6b17fd28 Make sickdays (in kuhyx/utils) text-free (textgat…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
Todo: 2d0704cd
2026-11-10 19:07:37 +01:00
ae56037e41 Fix GMS gate APK mode false positive
Some checks are pending
data_transfer_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Decide by AGP library marker files instead of dex strings:
play-services-base/basement reference GoogleSignInAccount without
linking play-services-auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 18:00:23 +01:00
eef7af4c86 Bump file_picker 13.3.3 (dtf 0.2.2, ssui 0.6.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:53:50 +01:00
9daa376254 Gate: no Google non-free sign-in in apps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
scripts/check_no_gms_signin.sh fails when a pubspec.lock or android
gradle file pulls google_sign_in / play-services-auth / googleid, or a
built APK (--apk) carries GMS auth classes, and names
google_account_token as the fix. install_repo_contract_gate.sh now
installs its shim and an always-run hook in Flutter Android repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 16:52:44 +01:00
17e300af0a focus_project: pin backing edge cases and pay
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Tests only: sql_drills/leetcode beats back, other-project and browser
beats don't, submissions back only leetcode projects, the cutover
midnight, a tutor chain leaves earlier reading unbacked; earned_time
pays 0 units for a timer-only post-cutover day (real focus_project).

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:38:48 +01:00
75 changed files with 5508 additions and 695 deletions

View File

@ -4,13 +4,13 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope. dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none' publish_to: 'none'
version: 0.2.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

View 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

View File

@ -9,7 +9,7 @@ clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
earns only once the real clock reaches it. An unreadable registry reads as 0 earns only once the real clock reaches it. An unreadable registry reads as 0
(logged): fail closed. (logged): fail closed.
Unbacked minutes (0.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.
""" """

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

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

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

View 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

View File

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

View File

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

View 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

View File

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

View 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()

View File

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

View File

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

View File

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

View File

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

View 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

View 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"

View 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

View 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

View 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

View File

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

View 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")

View 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()

View 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

View 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()

View 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

View File

@ -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
View 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:]))

View 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:]))

View 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"))

View 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())

View 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:]))

View File

@ -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',
),
), ),
], ],
); );

View File

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

View File

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

View File

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

View File

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

View 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

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

View 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

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,7 +61,7 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
for module in _MODULES: for module in _MODULES:
monkeypatch.setattr(f"{module}.resolve_paths", _resolve) monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free) monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
monkeypatch.setattr("sickdays.gui._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)

View 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()

View 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

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

View File

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

View File

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

View File

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

View 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,
),
);
}

View File

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

View File

@ -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}',
),
), ),
), ),
], ],

View File

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

View File

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

View File

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

View File

@ -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);
}, },
); );
}); });