screen-locker/screen_locker/_workout_app.py
Krzysztof kuhy Rudnicki f3875302d7 Fix pylint findings in source and scripts
Real fixes: split over-long functions (lock-decision chain, manual
sync, validation, phone verification, status), hoist a dead lazy
import, context-manage Xvfb and temp dirs, add encodings, share the
silent-failure argv loop.

lock_grab_handoff read lock._recovery, which gatelock's LockWindow
does not have (its public property is recovery); a contract test now
checks every attribute the handoff reads against the real class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00

210 lines
6.8 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:
"""Ask the process to stop."""
def wait(self) -> int:
"""Block until the process exits; return its exit code."""
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}",
)