A live claim was not "input is locked": it is published before the window grabs, and leetcode-guard's study mode releases the grab while the claim lives on. leisure-watch (contract C2) must pause shadow billing only while a grab is really held. - Claim.grab_held (always serialised; missing key from an old writer reads as grab != "none"). Arbitration still uses the declared grab only. - Arbiter.set_grab_held(held=) rewrites the claim and holder copies in place through the owner's flocked fd (O_APPEND cleared, pwrite at 0, padded so the file never shrinks) -- same inode, flock liveness untouched. Never raises. - Set true only after grab_set/grab_set_global succeeded; RecoveryLoop reports each tick's real answer (a stolen, un-retaken grab reads false). LockWindow.report_grab_held(held=) is the hook for deliberate releases. - gatelock.held_grabs(claims_dir=None): live claims with grab_held and a real grab. Root callers pass /run/user/1000/gatelock/claims. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
4.1 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. Path and retry budget are arguments so each consumer's tests keep
redirecting their own constant.
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).
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