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
115 lines
3.7 KiB
Python
115 lines
3.7 KiB
Python
"""Tests for lending the lock screen's grab to the workout app.
|
|
|
|
The invariant: the grab moves, nothing else does. VT switching stays disabled
|
|
and the arbiter claim stays held, because ``close()`` would undo both and hand
|
|
the user Ctrl+Alt+F2 out of the obligation the lock exists to enforce.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
|
|
from screen_locker._workout_handoff import lock_grab_handoff, start_workout
|
|
|
|
|
|
class _FakeRecovery:
|
|
"""Stands in for gatelock's watchdog, which re-grabs every 1000ms."""
|
|
|
|
def __init__(self) -> None:
|
|
self.running = True
|
|
self.events: list[str] = []
|
|
|
|
def stop(self) -> None:
|
|
self.running = False
|
|
self.events.append("stop")
|
|
|
|
|
|
class _FakeLock:
|
|
"""A lock window that records the order of grab operations."""
|
|
|
|
def __init__(self, *, release_raises: bool = False) -> None:
|
|
self._recovery = _FakeRecovery()
|
|
self.events: list[str] = []
|
|
self.closed = False
|
|
self._release_raises = release_raises
|
|
outer = self
|
|
|
|
class _Root:
|
|
def grab_release(self) -> None:
|
|
outer.events.append("grab_release")
|
|
if outer._release_raises:
|
|
msg = "no grab to release"
|
|
raise tk.TclError(msg)
|
|
|
|
self.root = _Root()
|
|
|
|
def grab_input(self) -> None:
|
|
self.events.append("grab_input")
|
|
self._recovery.running = True
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class TestLockGrabHandoff:
|
|
"""Releasing and re-taking, in the only safe order."""
|
|
|
|
def test_stops_the_watchdog_before_releasing(self) -> None:
|
|
"""A tick between release and the app's own grab would steal it back."""
|
|
lock = _FakeLock()
|
|
lock_grab_handoff(lock).release()
|
|
assert lock._recovery.events == ["stop"]
|
|
assert lock.events == ["grab_release"]
|
|
assert not lock._recovery.running
|
|
|
|
def test_reacquire_restores_the_grab_and_the_watchdog(self) -> None:
|
|
handoff = lock_grab_handoff(lock := _FakeLock())
|
|
handoff.release()
|
|
handoff.reacquire()
|
|
assert lock.events == ["grab_release", "grab_input"]
|
|
assert lock._recovery.running
|
|
|
|
def test_never_closes_the_lock_window(self) -> None:
|
|
"""close() also restores VT switching -- that is the escape hatch."""
|
|
handoff = lock_grab_handoff(lock := _FakeLock())
|
|
handoff.release()
|
|
handoff.reacquire()
|
|
assert not lock.closed
|
|
|
|
def test_tolerates_having_no_grab_to_release(self) -> None:
|
|
"""Tk raises when releasing a grab we do not hold; not fatal."""
|
|
handoff = lock_grab_handoff(lock := _FakeLock(release_raises=True))
|
|
handoff.release()
|
|
handoff.reacquire()
|
|
assert lock.events == ["grab_release", "grab_input"]
|
|
|
|
|
|
class TestStartWorkout:
|
|
"""The button handler never raises onto a screen with no way out."""
|
|
|
|
def test_reports_a_failed_launch_as_a_message(self, monkeypatch: object) -> None:
|
|
from screen_locker import _workout_handoff
|
|
|
|
class _Result:
|
|
launched = False
|
|
reason = "not built"
|
|
|
|
monkeypatch.setattr(
|
|
_workout_handoff, "launch_workout_app", lambda _h: _Result()
|
|
)
|
|
message = start_workout(_FakeLock())
|
|
assert "Could not start the workout" in message
|
|
assert "not built" in message
|
|
|
|
def test_reports_a_finished_run(self, monkeypatch: object) -> None:
|
|
from screen_locker import _workout_handoff
|
|
|
|
class _Result:
|
|
launched = True
|
|
reason = "exited with code 0"
|
|
|
|
monkeypatch.setattr(
|
|
_workout_handoff, "launch_workout_app", lambda _h: _Result()
|
|
)
|
|
assert "checking whether it counted" in start_workout(_FakeLock())
|