utils/gatelock/README.md
Krzysztof kuhy Rudnicki 2d341dd41e
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
gatelock 0.13.0: locks stay clickable together
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

5.7 KiB

gatelock

Shared fullscreen lock-window + HMAC log-integrity backend, extracted from three personal automation tools that each independently implemented "lock the screen with a blocking overlay until a condition is met": diet_guard, wake_alarm, and screen-locker.

Why

All three reimplemented the same tkinter fullscreen mechanics (overrideredirect, input grab, VT-switch disable) at different levels of maturity, and the HMAC-signed-state module had already been hand-copied between two of them because they live in separate repos. gatelock is the one place that logic now lives.

Install

pip install "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.1#subdirectory=gatelock"

Usage

import tkinter as tk
from gatelock import GateRoot, LockConfig, LockWindow

class MyGate:
    def __init__(self, *, demo_mode: bool) -> None:
        self.root = GateRoot()
        self.root.on_callback_error = self._handle_callback_error
        config = LockConfig(mode="soft" if demo_mode else "hard")
        self._lock = LockWindow(self.root, config, hooks=self)
        self._lock.setup()
        # ... build your widgets ...
        self._lock.grab_input()

    def on_focus_ready(self) -> None:
        self.my_entry.focus_force()

    def on_callback_error(self) -> None:
        self.close()

    def on_close(self) -> None:
        ...  # release any hardware/state held while locked

    def close(self) -> None:
        self._lock.close()

    def run(self) -> None:
        self._lock.run()

LockConfig's mode preset bundles the common combination ("soft" = topmost only, typeable, WM-escapable; "hard" = overrideredirect + global grab + VT-disable). overrideredirect and the grab=GrabPolicy(kind=..., disable_vt=..., retry_ms=...) axes can be set explicitly to reproduce a consumer's exact prior behavior where it diverges from the preset. Colours, type scale and spacing are the palette, typography and spacing records (gatelock.LockPalette etc.), overridden as a unit.

gatelock.log_integrity ports the HMAC-signed state module used by all three projects; DEFAULT_HMAC_KEY_FILE (/etc/workout-locker/hmac.key) is unchanged so existing signed history keeps verifying.

gatelock.morning_session.morning_skip is the one reader of wake-alarm's signed morning_session.json (the wake-early carrot: no lock until the signed exempt_until, 11:00 for a morning completed in time). screen-locker, leetcode-guard and book-guard all call it; the policy itself stays in wake-alarm. The arming run waits up to 180 s for a freshly booted PC's refresher (status paths never wait). Path and retry budget are arguments so each consumer's tests keep redirecting their own constant.

gatelock.corner_strip is the top-right always-on-top status strip for a gate that is stood down but not done (build_corner_strip, CornerStrip, StripContent, corner_rect, corner_geometry). It never grabs input; its tick ends when the owner's supply returns None.

gatelock.night_lockdown.evening_lockdown_today answers "has tonight's evening night-lockdown already happened?" from night-lockdown's /var/lib/night-lockdown/locked_at_epoch (local date today, local time

= 12:00; the 04:01 morning re-lock does not count). Earner gates (screen-locker's workout lock, leetcode-guard, book-guard) opt in and do not re-arm for the rest of such a day; diet-guard does not. Missing or garbage means "no lockdown" (garbage warns), so the gate locks as before.

gatelock.held_grabs(claims_dir=None) -> tuple[Claim, ...] answers "is a locker's X grab ACTUALLY held right now?": live claims (flock-proved) whose grab_held is true and whose declared grab is not none. grab_held turns true only after grab_set/grab_set_global succeeded, follows each recovery tick, and is cleared/restored around a deliberate release via LockWindow.report_grab_held(held=...) (or Arbiter.set_grab_held) -- leetcode-guard's study mode. It is rewritten in place under the owner's flock. 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 (/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

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest
pre-commit install && pre-commit install --hook-type pre-push