mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20: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
208 lines
6.7 KiB
Python
208 lines
6.7 KiB
Python
"""Launching the desktop workout app as the lock surface.
|
|
|
|
The phone is gone, so the workout has to be doable on the PC — but handing the
|
|
user a normal desktop window while the lock screen is up would mean unlocking
|
|
the machine to earn the unlock. Instead the Flutter app becomes the lock: it
|
|
takes the same override-redirect fullscreen window and the same exclusive X
|
|
grab the Tk locker holds, with no way out but finishing or resetting.
|
|
|
|
Two X clients cannot hold the grab at once (verified: the second gets
|
|
``grab failed: another application has grab``), so the two windows hand it
|
|
over in a fixed order:
|
|
|
|
1. Tk keeps the grab and launches the app.
|
|
2. The app maps fullscreen ON TOP and starts retrying the grab, failing while
|
|
Tk still holds it, and prints ``WORKOUT_LOCK: ready``.
|
|
3. Only then does Tk release — so the screen is covered continuously rather
|
|
than exposing the desktop for the whole of Flutter's cold start.
|
|
|
|
Releasing the *grab* is deliberate: ``gatelock``'s ``close()`` also restores
|
|
VT switching, so tearing the Tk window down here would re-enable
|
|
Ctrl+Alt+F2 and hand the user an exit. The Tk process stays alive as the
|
|
supervisor and re-takes the grab if the app dies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
from pathlib import Path
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._workout_ready import (
|
|
READY_MARKER,
|
|
await_ready,
|
|
default_selector,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from screen_locker._workout_ready import _Selector
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Sequence
|
|
from typing import IO, Protocol
|
|
|
|
class _Child(Protocol):
|
|
"""The half of ``subprocess.Popen`` this module actually drives."""
|
|
|
|
stdout: IO[str] | None
|
|
returncode: int | None
|
|
|
|
def terminate(self) -> None: ...
|
|
|
|
def wait(self) -> int: ...
|
|
|
|
class _Spawner(Protocol):
|
|
"""``subprocess.Popen``, narrowed to the one call shape used here."""
|
|
|
|
def __call__(
|
|
self,
|
|
args: Sequence[str],
|
|
*,
|
|
stdout: int,
|
|
stderr: int,
|
|
text: bool,
|
|
bufsize: int,
|
|
) -> _Child: ...
|
|
|
|
|
|
__all__ = [
|
|
"READY_MARKER",
|
|
"GrabHandoff",
|
|
"ProcessHooks",
|
|
"WorkoutAppResult",
|
|
"launch_workout_app",
|
|
"workout_app_binary",
|
|
]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Where `flutter build linux` puts the bundle, relative to the repo root.
|
|
_BUNDLE = Path("stronglift_replacement/workout_app/build/linux/x64")
|
|
_BINARY_NAME = "workout_app"
|
|
|
|
# A cold Flutter start opens the DB and pulls progression (bounded at 20s in
|
|
# the app's own main()), so the ready line can legitimately take a while.
|
|
READY_TIMEOUT_SECONDS = 60.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GrabHandoff:
|
|
"""The two halves of passing the screen to the app and taking it back.
|
|
|
|
Injected so the handoff is testable without an X server, and so it is
|
|
obvious at the call site that *both* directions must be provided.
|
|
"""
|
|
|
|
release: Callable[[], None]
|
|
reacquire: Callable[[], None]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProcessHooks:
|
|
"""How the supervisor spawns the app and waits on its output.
|
|
|
|
One seam because they are always substituted together: a test that fakes
|
|
the process must fake the wait too.
|
|
"""
|
|
|
|
popen: _Spawner = subprocess.Popen
|
|
selector: _Selector = default_selector
|
|
# Arms an in-app key handler that drops the grab and quits. Only the demo
|
|
# harness sets it: inside the app's own seat grab is the only place an
|
|
# escape can be received, because X delivers keystrokes nowhere else.
|
|
demo_escape: bool = False
|
|
|
|
|
|
class WorkoutAppResult:
|
|
"""Outcome of one supervised workout-app run.
|
|
|
|
Carries a human-readable ``reason`` so a failed launch says what happened
|
|
instead of silently dropping the user back to the lock screen.
|
|
"""
|
|
|
|
def __init__(self, *, launched: bool, reason: str) -> None:
|
|
self.launched = launched
|
|
self.reason = reason
|
|
|
|
def __repr__(self) -> str:
|
|
return f"WorkoutAppResult(launched={self.launched}, reason={self.reason!r})"
|
|
|
|
|
|
def workout_app_binary(repo_root: Path | None = None) -> Path | None:
|
|
"""Return the built desktop app, or None when it has not been built.
|
|
|
|
Prefers the release bundle and falls back to debug, so a machine that has
|
|
only ever run ``flutter build linux --debug`` still works.
|
|
"""
|
|
root = (
|
|
repo_root if repo_root is not None else Path(__file__).resolve().parent.parent
|
|
)
|
|
for flavour in ("release", "debug"):
|
|
candidate = root / _BUNDLE / flavour / "bundle" / _BINARY_NAME
|
|
if candidate.exists():
|
|
return candidate
|
|
_logger.error(
|
|
"The desktop workout app is not built at %s (release or debug) — the "
|
|
"lock screen cannot offer a PC workout. Build it with: "
|
|
"cd stronglift_replacement/workout_app && flutter build linux --release",
|
|
root / _BUNDLE,
|
|
)
|
|
return None
|
|
|
|
|
|
def launch_workout_app(
|
|
handoff: GrabHandoff,
|
|
*,
|
|
binary: Path | None = None,
|
|
resolve_binary: Callable[[], Path | None] = workout_app_binary,
|
|
hooks: ProcessHooks | None = None,
|
|
ready_timeout: float = READY_TIMEOUT_SECONDS,
|
|
) -> WorkoutAppResult:
|
|
"""Run the workout app as the lock surface, then restore the Tk grab.
|
|
|
|
Blocks until the app exits; the caller re-evaluates compliance afterwards
|
|
(a finished workout reaches ``log.json`` through the normal sync path, not
|
|
through this function's return value).
|
|
"""
|
|
io = hooks if hooks is not None else ProcessHooks()
|
|
app = binary if binary is not None else resolve_binary()
|
|
if app is None:
|
|
return WorkoutAppResult(
|
|
launched=False,
|
|
reason="the desktop workout app is not built; run flutter build linux",
|
|
)
|
|
|
|
child = io.popen(
|
|
[str(app), "--lock-mode"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
ready, why = await_ready(child, ready_timeout, io.selector)
|
|
if not ready:
|
|
# Never release the grab to a window that may not be up: that is the
|
|
# one failure mode that leaves the machine unlocked.
|
|
child.terminate()
|
|
return WorkoutAppResult(
|
|
launched=False,
|
|
reason=(
|
|
f"{why} — keeping the lock screen up rather than releasing the "
|
|
"screen to a window that may not have started"
|
|
),
|
|
)
|
|
|
|
handoff.release()
|
|
try:
|
|
child.wait()
|
|
finally:
|
|
# Unconditional: however the app exited, this machine must not be
|
|
# left with nothing holding the screen.
|
|
handoff.reacquire()
|
|
return WorkoutAppResult(
|
|
launched=True,
|
|
reason=f"the workout app exited with code {child.returncode}",
|
|
)
|