mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
The locker cannot close() to let the app take over: close() also restores VT switching, which would hand the user Ctrl+Alt+F2 and an exit from the very obligation the lock exists to enforce. Only the grab moves; the Tk window stays mapped underneath, so the screen is still covered if the app dies. The watchdog has to be stopped for the duration, and measuring it is what makes that non-negotiable. gatelock's recovery loop re-takes any grab it finds missing every 1000ms -- by design, so that pulling a cable is not an escape -- and holds_grab() is `grab_current() is not None`, which is False once another X client holds it. Verified on a private Xvfb display: a naked release is stolen back within ~1.5s, while stopping the loop first lets the app keep the screen and the locker take it back cleanly afterwards. The same harness confirms the ordering the handshake depends on: while the locker holds the global grab a second X client cannot even finish connecting (4s, no output at all), so there is no window in which the screen is uncovered. It only gets through after the release. Reaching into lock._recovery earns an SLF001 waiver rather than a public pause on LockWindow: gatelock's _recovery.py carries a monotonicity proof that no transition ever decreases the grab, and a public lend_grab() would be exactly that transition. The waiver is documented where the other four SLF001 waivers in this repo already live. start_workout blocks the Tk loop for the whole workout, which is deliberate -- pumping Tk underneath is what would let a tick steal the screen mid-set. The docstring names the cost: a monitor hotplug is not handled until the app exits and grab_input() re-covers every live output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
82 lines
3.4 KiB
Python
82 lines
3.4 KiB
Python
"""Handing the lock screen to the workout app, and taking it back.
|
|
|
|
The Tk locker cannot simply ``close()`` to let the app take over: ``close()``
|
|
also restores VT switching, which would hand the user Ctrl+Alt+F2 and an exit
|
|
from the very obligation the lock exists to enforce. Only the *grab* moves.
|
|
|
|
The recovery watchdog has to be stopped for the duration, and that is the part
|
|
that is easy to get wrong. It is built never to release -- it re-takes a grab
|
|
it finds missing, deliberately, so that pulling a cable is not an escape. Left
|
|
running it would treat the Flutter app exactly like an intruder and steal the
|
|
screen back within one verify tick, mid-workout. So the order is: stop the
|
|
watchdog, release the grab, run the app, re-take the grab, restart the
|
|
watchdog. The Tk window itself stays mapped underneath the whole time, which
|
|
is what keeps the screen covered if the app dies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._workout_app import GrabHandoff, launch_workout_app
|
|
|
|
if TYPE_CHECKING:
|
|
from gatelock import LockWindow
|
|
|
|
__all__ = ["lock_grab_handoff", "start_workout"]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def lock_grab_handoff(lock: LockWindow) -> GrabHandoff:
|
|
"""Build the release/reacquire pair for a live lock window.
|
|
|
|
Neither direction touches VT switching or the arbiter claim: this is a
|
|
loan of the grab, not a teardown.
|
|
"""
|
|
|
|
def release() -> None:
|
|
# Stop first: a watchdog tick between the release and the app's own
|
|
# grab would take the screen straight back off it.
|
|
lock._recovery.stop()
|
|
with contextlib.suppress(tk.TclError):
|
|
lock.root.grab_release()
|
|
_logger.warning(
|
|
"Released the X grab to the workout app; the lock window stays "
|
|
"mapped and VT switching stays disabled."
|
|
)
|
|
|
|
def reacquire() -> None:
|
|
# Unconditional and idempotent: whatever happened to the app, this
|
|
# machine must not be left with nothing holding the screen.
|
|
lock.grab_input()
|
|
_logger.warning("Re-took the X grab from the workout app.")
|
|
|
|
return GrabHandoff(release=release, reacquire=reacquire)
|
|
|
|
|
|
def start_workout(lock: LockWindow) -> str:
|
|
"""Run the workout app as the lock surface; return a status for the UI.
|
|
|
|
Never raises: this is wired to a button on a screen the user cannot leave,
|
|
so a failure has to come back as a message rather than a traceback that
|
|
would leave the grab in an unknown state.
|
|
|
|
Blocks until the app exits, which stops the Tk event loop for the whole
|
|
workout. That is deliberate rather than merely tolerated: the Flutter
|
|
window is covering the screen, and the alternative -- pumping Tk
|
|
underneath -- is what would let the watchdog tick and take the screen back
|
|
mid-set. The cost is that a monitor hotplug during the workout is not
|
|
handled until the app exits, when ``reacquire`` re-runs ``grab_input()``
|
|
and the watchdog re-covers every live output.
|
|
"""
|
|
result = launch_workout_app(lock_grab_handoff(lock))
|
|
if not result.launched:
|
|
_logger.error("Could not start the PC workout: %s", result.reason)
|
|
return f"Could not start the workout: {result.reason}"
|
|
_logger.warning("The workout app finished: %s", result.reason)
|
|
return "Workout app closed — checking whether it counted…"
|