mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
Both previous escapes were unreachable, for the same underlying reason stated two different ways. A Tk key binding cannot fire, because once the workout app takes its seat grab X delivers every keystroke to that app alone. And "run touch from another terminal" cannot be done, because the app covers the screen with an override-redirect fullscreen window -- there is no other terminal to reach. An escape has to be handled INSIDE the grab, by the app itself, or it is not an escape. So --demo-escape arms a key handler in the runner: Ctrl+Shift+Q ungrabs the seat and closes the window. The grab, the override-redirect window and the handoff are all unchanged, so the demo exercises the real locking mechanism rather than a softened copy of it, and the hatch is the one deliberate way out. The handler ungrabs before closing. Destroying a window that still holds the seat can leave the pointer and keyboard grabbed with nothing left to release them, which needs exactly the reboot this hatch exists to prevent. Not Escape, which the Dart side already binds for in-app navigation; shadowing it would change the behaviour under test. Verified in both directions against the real debug bundle, each under its own Xvfb display: with the flag, the app exits on Ctrl+Shift+Q while holding the grab and the lock takes the screen back; without it, the identical keystroke does nothing and the app keeps the screen. A test asserts the production path never passes the flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
239 lines
8.2 KiB
Python
239 lines
8.2 KiB
Python
"""Tests for running the workout app without freezing the lock's event loop.
|
|
|
|
The invariant these defend: no phase blocks. Every wait is an ``after``
|
|
callback, because a blocked event loop makes the lock's own escapes inert --
|
|
which is what once forced a hard reboot out of a "safe" demo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._workout_app import GrabHandoff, ProcessHooks
|
|
from screen_locker._workout_ready import READY_MARKER
|
|
from screen_locker._workout_session import WorkoutSession
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
class _FakeAfter:
|
|
"""A drainable stand-in for ``root.after``."""
|
|
|
|
def __init__(self) -> None:
|
|
self.queue: list[Callable[[], None]] = []
|
|
|
|
def __call__(self, _ms: int, callback: Callable[[], None]) -> object:
|
|
self.queue.append(callback)
|
|
return object()
|
|
|
|
def drain(self, limit: int = 5000) -> None:
|
|
for _ in range(limit):
|
|
if not self.queue:
|
|
return
|
|
self.queue.pop(0)()
|
|
|
|
|
|
class _FakeStdout:
|
|
def __init__(self, chunks: list[str]) -> None:
|
|
self._chunks = chunks
|
|
|
|
def readline(self) -> str:
|
|
return self._chunks.pop(0) if self._chunks else ""
|
|
|
|
|
|
class _FakeChild:
|
|
def __init__(self, chunks: list[str], *, exits_after: int = 1) -> None:
|
|
self.stdout = _FakeStdout(chunks)
|
|
self.returncode: int | None = None
|
|
self.terminated = False
|
|
self._polls = 0
|
|
self._exits_after = exits_after
|
|
|
|
def poll(self) -> int | None:
|
|
self._polls += 1
|
|
if self._polls >= self._exits_after:
|
|
self.returncode = 0
|
|
return 0
|
|
return None
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
self.returncode = -15
|
|
|
|
|
|
def _session(child: _FakeChild, after: _FakeAfter, calls: list[str]):
|
|
return WorkoutSession(
|
|
GrabHandoff(
|
|
release=lambda: calls.append("release"),
|
|
reacquire=lambda: calls.append("reacquire"),
|
|
),
|
|
after=after,
|
|
on_status=lambda text: calls.append(f"status:{text[:20]}"),
|
|
binary=Path("/nonexistent/workout_app"),
|
|
hooks=ProcessHooks(
|
|
popen=lambda *_a, **_k: child,
|
|
selector=lambda r, _w, _x, _t: (r, [], []),
|
|
),
|
|
)
|
|
|
|
|
|
class TestWorkoutSession:
|
|
"""Each phase hands control back to the event loop."""
|
|
|
|
def test_releases_the_grab_only_after_the_ready_line(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=2)
|
|
_session(child, after, calls).start()
|
|
assert "release" not in calls # nothing released synchronously
|
|
after.drain()
|
|
assert "release" in calls
|
|
assert calls.index("release") < calls.index("reacquire")
|
|
|
|
def test_never_releases_when_the_app_dies_before_ready(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
_session(_FakeChild([]), after, calls).start()
|
|
after.drain()
|
|
assert "release" not in calls
|
|
|
|
def test_reacquires_when_the_app_exits(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=3)
|
|
_session(child, after, calls).start()
|
|
after.drain()
|
|
assert calls[-2] == "reacquire"
|
|
assert "Workout app closed" in calls[-1]
|
|
|
|
def test_abort_terminates_the_app_and_restores_the_lock(self) -> None:
|
|
"""The escape hatch: usable at any point, from outside X."""
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
|
|
session = _session(child, after, calls)
|
|
session.start()
|
|
for _ in range(3):
|
|
if after.queue:
|
|
after.queue.pop(0)()
|
|
session.abort()
|
|
assert child.terminated
|
|
assert "reacquire" in calls
|
|
|
|
def test_abort_before_release_does_not_reacquire(self) -> None:
|
|
"""Nothing was lent, so there is nothing to take back."""
|
|
after, calls = _FakeAfter(), []
|
|
session = _session(_FakeChild([], exits_after=99), after, calls)
|
|
session.start()
|
|
session.abort()
|
|
assert "reacquire" not in calls
|
|
|
|
def test_times_out_without_ever_blocking(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild(["noise\n"] * 10, exits_after=99)
|
|
session = _session(child, after, calls)
|
|
session._remaining = 3
|
|
session.start()
|
|
after.drain()
|
|
assert child.terminated
|
|
assert "release" not in calls
|
|
|
|
def test_says_so_when_the_app_is_not_built(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
session = WorkoutSession(
|
|
GrabHandoff(release=lambda: None, reacquire=lambda: None),
|
|
after=after,
|
|
on_status=calls.append,
|
|
hooks=ProcessHooks(popen=lambda *_a, **_k: None),
|
|
)
|
|
# Force the unbuilt case regardless of what this machine has built.
|
|
session._binary = None
|
|
monkey = "screen_locker._workout_session.workout_app_binary"
|
|
import unittest.mock
|
|
|
|
with unittest.mock.patch(monkey, return_value=None):
|
|
session.start()
|
|
assert any("not built" in c for c in calls)
|
|
|
|
def test_handles_a_missing_stdout_pipe(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([])
|
|
child.stdout = None
|
|
_session(child, after, calls).start()
|
|
after.drain()
|
|
assert "release" not in calls
|
|
|
|
|
|
class TestSessionEdges:
|
|
"""Paths a fake pipe or a happy path does not reach."""
|
|
|
|
def test_keeps_waiting_when_the_pipe_has_nothing_yet(self) -> None:
|
|
"""A quiet app is not a dead app; poll again, never block."""
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
|
|
session = WorkoutSession(
|
|
GrabHandoff(
|
|
release=lambda: calls.append("release"),
|
|
reacquire=lambda: calls.append("reacquire"),
|
|
),
|
|
after=after,
|
|
on_status=lambda _t: None,
|
|
binary=Path("/nonexistent/workout_app"),
|
|
hooks=ProcessHooks(
|
|
popen=lambda *_a, **_k: child,
|
|
# Never readable: the app is up but silent.
|
|
selector=lambda _r, _w, _x, _t: ([], [], []),
|
|
),
|
|
)
|
|
session._remaining = 2
|
|
session.start()
|
|
after.drain()
|
|
assert "release" not in calls
|
|
assert child.terminated
|
|
|
|
def test_abort_with_no_child_is_harmless(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
session = _session(_FakeChild([]), after, calls)
|
|
session.abort() # never started
|
|
assert len(calls) == 1
|
|
assert "Workout app closed" in calls[0]
|
|
|
|
def test_reads_a_real_pipe_without_waiting_for_a_newline(self) -> None:
|
|
"""The production path: a text stream wrapping a binary buffer."""
|
|
import os
|
|
|
|
from screen_locker._workout_session import _read_available
|
|
|
|
read_fd, write_fd = os.pipe()
|
|
with os.fdopen(read_fd, "r") as reader, os.fdopen(write_fd, "w") as writer:
|
|
writer.write("WORKOUT_")
|
|
writer.flush()
|
|
assert _read_available(reader) == "WORKOUT_"
|
|
|
|
|
|
class TestDemoEscapeFlag:
|
|
"""The flag that arms the app's own escape hatch."""
|
|
|
|
def _argv_for(self, *, demo_escape: bool) -> list[str]:
|
|
seen: list[list[str]] = []
|
|
|
|
def _popen(argv: list[str], **_kwargs: object) -> _FakeChild:
|
|
seen.append(argv)
|
|
return _FakeChild([])
|
|
|
|
WorkoutSession(
|
|
GrabHandoff(release=lambda: None, reacquire=lambda: None),
|
|
after=_FakeAfter(),
|
|
on_status=lambda _t: None,
|
|
binary=Path("/nonexistent/workout_app"),
|
|
hooks=ProcessHooks(popen=_popen, demo_escape=demo_escape),
|
|
).start()
|
|
return seen[0]
|
|
|
|
def test_production_never_arms_the_hatch(self) -> None:
|
|
"""The default must not hand the user a way out of a real lock."""
|
|
assert "--demo-escape" not in self._argv_for(demo_escape=False)
|
|
|
|
def test_demo_arms_the_hatch(self) -> None:
|
|
argv = self._argv_for(demo_escape=True)
|
|
assert argv[-1] == "--demo-escape"
|
|
assert "--lock-mode" in argv
|