utils/gatelock/gatelock/_arming.py
Krzysztof kuhy Rudnicki 35f6f95956
Some checks failed
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
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 pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled
gatelock 0.11.0: Claim.grab_held + held_grabs(), so a reader can tell a locker's X grab is actually held
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
2026-10-19 11:40:46 +02:00

190 lines
6.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Bringing the lock up: VT-disable, backdrop, surfaces, and the input grab.
Split out of :mod:`gatelock._window`, which keeps the object's construction
and its teardown. Everything here runs once, in order, on the way *up* --
:func:`setup` makes the lock visible and :func:`grab_input` makes it
exclusive.
Arming order matters and is deliberate: **arm first, render second.** The grab
and the VT-disable happen regardless of how many outputs are live, and the
surfaces are then built for whatever the user can actually see -- possibly
nothing. Zero live outputs means "lock without showing", never "decline to
lock". The inverse of that rule is what left the machine unlocked on
2026-07-25.
These are free functions over an explicit :class:`ArmingCollaborators` bundle
rather than methods, mirroring :class:`gatelock._recovery.RecoveryCollaborators`.
Nothing here reaches back into the window, so the arming sequence can be read
-- and tested -- without one.
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock._surfaces import needs_backdrop_root
from gatelock._vt import disable_vt_switching
if TYPE_CHECKING:
from collections.abc import Callable
from gatelock._config import LockConfig
from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator
from gatelock._recovery import RecoveryLoop
from gatelock._surfaces import SurfaceSet
_logger = logging.getLogger(__name__)
# Tk's attributes() takes the value positionally, so a bare `True` here trips
# the boolean-positional lint. Naming it satisfies both that rule and the type
# stubs, which have no keyword overload.
_TOPMOST_ON = True
# Default retry interval for a "global" grab that initially fails (e.g. a
# fullscreen game holds it). Used whenever grab_retry_ms is left unset.
_DEFAULT_GRAB_RETRY_MS = 200
def _no_grab_listener() -> None:
"""Default for :attr:`ArmingCollaborators.notify_grab_held`."""
@dataclass(frozen=True)
class ArmingCollaborators:
"""Everything the arming sequence needs from its owning window.
Attributes:
config: Declarative lock behavior for this instance.
surfaces: The per-output surface set to build into.
enumerator: Source of the live-output scan.
detector: Output-change watcher, started once the grab is taken.
recovery: Re-assertion loop, started alongside the detector.
notify_focus_ready: Called once the lock is mapped and grabbed.
log_grab_blocked: Called with the attempt count when a global grab is
still blocked, to name the holder and stand a weaker one down.
notify_grab_held: Called once a grab call has actually succeeded, so
the claim's ``grab_held`` turns true only then.
"""
config: LockConfig
surfaces: SurfaceSet
enumerator: OutputEnumerator
detector: OutputChangeDetector
recovery: RecoveryLoop
notify_focus_ready: Callable[[], None]
log_grab_blocked: Callable[[int], None]
notify_grab_held: Callable[[], None] = _no_grab_listener
def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool:
"""Configure the backdrop and build a surface on every live output.
Disables VT switching first: strengthening the lock must not wait on
being able to draw anything. If no output is live, no surface is built
and the lock stays armed and invisible until one appears.
Args:
root: The Tk root acting as backdrop and grab holder.
collab: The window's arming collaborators.
Returns:
Whether VT switching was disabled, for the caller to remember so the
matching restore happens exactly once on the way back down.
"""
vt_disabled = False
if collab.config.resolved_disable_vt():
vt_disabled = disable_vt_switching()
if needs_backdrop_root(collab.config):
# The root is a plain black backdrop spanning the whole X screen,
# including any region a modeless output left behind. It holds the
# grab (X will not grab for a window that is not viewable) and
# never carries widgets.
root.overrideredirect(boolean=collab.config.resolved_overrideredirect())
collab.surfaces.update_backdrop()
else:
root.attributes("-topmost", _TOPMOST_ON)
scan = collab.enumerator.scan()
delta = collab.surfaces.apply(scan)
if not scan.live:
_logger.warning(
"no live outputs at startup (source=%s); arming the lock with "
"nothing displayed -- it will appear as soon as a monitor "
"comes back",
scan.source,
)
else:
_logger.info(
"lock armed on %d output(s): %s",
len(scan.live),
", ".join(output.name for output in scan.live),
)
if delta.unverified:
_logger.error(
"outputs live but not covered at startup: %s",
", ".join(delta.unverified),
)
return vt_disabled
def grab_input(root: tk.Tk, collab: ArmingCollaborators) -> None:
"""Take the configured grab, then start watching for output changes.
Args:
root: The Tk root that holds the grab.
collab: The window's arming collaborators.
"""
root.update_idletasks()
root.focus_force()
grab = collab.config.resolved_grab()
if grab == "global":
acquire_global_grab(root, collab, attempt=1)
elif grab == "local":
with contextlib.suppress(tk.TclError):
root.grab_set()
collab.notify_grab_held()
collab.detector.start()
collab.recovery.start()
root.after(100, collab.notify_focus_ready)
def acquire_global_grab(
root: tk.Tk, collab: ArmingCollaborators, *, attempt: int
) -> None:
"""Acquire the global input grab, retrying per ``grab_retry_ms``.
Args:
root: The Tk root that holds the grab.
collab: The window's arming collaborators.
attempt: 1-based attempt counter, used only to throttle the log.
"""
retry_ms = collab.config.grab.retry_ms
try:
root.grab_set_global()
except tk.TclError:
if retry_ms == 0:
_logger.warning("Global grab failed, falling back to local grab")
with contextlib.suppress(tk.TclError):
root.grab_set()
collab.notify_grab_held()
return
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
if not attempt % collab.config.grab.log_every:
collab.log_grab_blocked(attempt)
root.after(
effective_retry_ms,
lambda: acquire_global_grab(root, collab, attempt=attempt + 1),
)
return
collab.notify_grab_held()
with contextlib.suppress(tk.TclError):
root.focus_force()
collab.notify_focus_ready()