mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +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
169 lines
6.0 KiB
Python
169 lines
6.0 KiB
Python
"""Running the workout app without freezing the lock's event loop.
|
|
|
|
``launch_workout_app`` blocks until the app exits, which is fine for a
|
|
headless caller but wrong behind a Tk button: while it blocks, the event loop
|
|
is not running, so key bindings and buttons on the lock itself are inert. In
|
|
the demo lock -- whose whole promise is that Escape always works -- that
|
|
turned a "safe" screen into one that needed a hard reboot to leave.
|
|
|
|
So the phases are driven by ``after`` instead, on the main thread. Every Tk
|
|
call (``grab_release``, ``grab_input``) stays there, which a worker thread
|
|
could not guarantee, and the loop keeps pumping the entire time the app is up.
|
|
|
|
This does not weaken the production handoff. The grab is still released only
|
|
after the ready line, and the recovery watchdog is still stopped first -- and
|
|
because ``stop()`` cancels its timers outright, there are no ticks to race
|
|
with while the app runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._workout_app import ProcessHooks, workout_app_binary
|
|
from screen_locker._workout_ready import READY_MARKER
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import IO, Protocol
|
|
|
|
from screen_locker._workout_app import GrabHandoff
|
|
|
|
class _Child(Protocol):
|
|
"""The half of ``subprocess.Popen`` this module drives."""
|
|
|
|
stdout: IO[str] | None
|
|
returncode: int | None
|
|
|
|
def poll(self) -> int | None: ...
|
|
|
|
def terminate(self) -> None: ...
|
|
|
|
|
|
__all__ = ["WorkoutSession"]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# How often to look at the app while it runs. Short enough that the lock comes
|
|
# back promptly when the app exits, long enough not to spin the event loop.
|
|
_POLL_MS = 100
|
|
|
|
# 600 polls x 100ms = 60s, matching the blocking supervisor's own deadline.
|
|
_READY_TIMEOUT_POLLS = 600
|
|
|
|
|
|
class WorkoutSession:
|
|
"""One supervised run of the workout app, driven by the Tk event loop.
|
|
|
|
``on_status`` is called with a human-readable line at each transition, so a
|
|
failure is visible on the lock screen rather than only in the journal.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
handoff: GrabHandoff,
|
|
*,
|
|
after: Callable[[int, Callable[[], None]], object],
|
|
on_status: Callable[[str], None],
|
|
binary: Path | None = None,
|
|
hooks: ProcessHooks | None = None,
|
|
) -> None:
|
|
self._handoff = handoff
|
|
self._after = after
|
|
self._on_status = on_status
|
|
self._binary = binary
|
|
self._hooks = hooks if hooks is not None else ProcessHooks()
|
|
self._remaining = _READY_TIMEOUT_POLLS
|
|
self._child: _Child | None = None
|
|
self._seen = ""
|
|
self._released = False
|
|
|
|
def start(self) -> None:
|
|
"""Spawn the app; the rest happens on later event-loop turns."""
|
|
app = self._binary if self._binary is not None else workout_app_binary()
|
|
if app is None:
|
|
self._fail("the desktop workout app is not built; run flutter build linux")
|
|
return
|
|
argv = [str(app), "--lock-mode"]
|
|
if self._hooks.demo_escape:
|
|
argv.append("--demo-escape")
|
|
self._child = self._hooks.popen(
|
|
argv,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
self._on_status("Starting the workout app…")
|
|
self._after(_POLL_MS, self._await_ready)
|
|
|
|
def _await_ready(self) -> None:
|
|
"""Watch for the ready line without ever blocking the loop."""
|
|
child = self._child
|
|
stdout = getattr(child, "stdout", None)
|
|
if stdout is None:
|
|
self._fail("the workout app exited before signalling ready")
|
|
return
|
|
# Zero timeout: ask what is there right now and return to the loop.
|
|
readable, _, _ = self._hooks.selector([stdout], [], [], 0)
|
|
if readable:
|
|
chunk = _read_available(stdout)
|
|
if not chunk:
|
|
self._fail("the workout app exited before signalling ready")
|
|
return
|
|
self._seen += chunk
|
|
if READY_MARKER in self._seen:
|
|
self._release_and_watch()
|
|
return
|
|
self._remaining -= 1
|
|
if self._remaining <= 0:
|
|
# Never release to a window that may not be up.
|
|
child.terminate()
|
|
self._fail("the workout app never signalled ready in time")
|
|
return
|
|
self._after(_POLL_MS, self._await_ready)
|
|
|
|
def _release_and_watch(self) -> None:
|
|
self._handoff.release()
|
|
self._released = True
|
|
self._on_status("The workout app has the screen.")
|
|
self._after(_POLL_MS, self._watch_until_exit)
|
|
|
|
def _watch_until_exit(self) -> None:
|
|
if self._child.poll() is None:
|
|
self._after(_POLL_MS, self._watch_until_exit)
|
|
return
|
|
self.finish()
|
|
|
|
def finish(self) -> None:
|
|
"""Take the screen back. Idempotent, so an early abort is safe."""
|
|
if self._released:
|
|
self._released = False
|
|
self._handoff.reacquire()
|
|
code = getattr(self._child, "returncode", None)
|
|
_logger.warning("The workout app exited with code %s.", code)
|
|
self._on_status("Workout app closed — checking whether it counted…")
|
|
|
|
def abort(self) -> None:
|
|
"""Kill the app and restore the lock, whatever phase we are in."""
|
|
child = self._child
|
|
if child is not None and child.poll() is None:
|
|
child.terminate()
|
|
_logger.warning("Workout app terminated by an explicit abort.")
|
|
self.finish()
|
|
|
|
def _fail(self, reason: str) -> None:
|
|
_logger.error("Could not run the PC workout: %s", reason)
|
|
self._on_status(f"Could not start the workout: {reason}")
|
|
|
|
|
|
def _read_available(stream: IO[str]) -> str:
|
|
"""Read what the pipe holds now; see ``_workout_ready`` for the why."""
|
|
buffer = getattr(stream, "buffer", None)
|
|
if buffer is None:
|
|
return stream.readline()
|
|
return buffer.read1().decode("utf-8", "replace")
|