screen-locker/screen_locker/_workout_handoff.py
Krzysztof kuhy Rudnicki a79ccc523b
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Workout handoff clears gatelock's Claim.grab_held while the app has input
Lending the X grab to the workout app stops recovery and releases it with
the claim still live, so gatelock 0.11 held_grabs (leisure-watch C2) read
"held" for the whole workout. release() now reports held=False before
grab_release; reacquire()'s grab_input() sets it true once the grab lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:04:48 +02:00

88 lines
3.7 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()
# Claim.grab_held false BEFORE the release (fail closed), so
# leisure-watch never reads "held" while the workout app has input.
# reacquire() -> grab_input() sets it true once the grab lands.
lock.report_grab_held(held=False)
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, so it is only safe for a caller with no event
loop to starve. Behind a Tk button use :class:`WorkoutSession` instead:
blocking there freezes the loop, which makes every binding on the lock --
including the escapes -- inert until the app exits.
An earlier version of this docstring claimed the blocking was deliberate,
to stop the recovery watchdog ticking mid-workout. That was wrong:
``release`` stops the watchdog first, and ``stop()`` cancels its timers
outright, so there are no ticks to race with either way.
"""
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…"