diff --git a/screen_locker/_workout_app.py b/screen_locker/_workout_app.py index 1691a32..8f8e254 100644 --- a/screen_locker/_workout_app.py +++ b/screen_locker/_workout_app.py @@ -109,6 +109,10 @@ class ProcessHooks: 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: diff --git a/screen_locker/_workout_session.py b/screen_locker/_workout_session.py index 9b1f741..f0a8e6c 100644 --- a/screen_locker/_workout_session.py +++ b/screen_locker/_workout_session.py @@ -87,8 +87,11 @@ class WorkoutSession: 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( - [str(app), "--lock-mode"], + argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, diff --git a/screen_locker/tests/test_workout_session.py b/screen_locker/tests/test_workout_session.py index 6db8eb0..fd5d19d 100644 --- a/screen_locker/tests/test_workout_session.py +++ b/screen_locker/tests/test_workout_session.py @@ -207,3 +207,32 @@ class TestSessionEdges: 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 diff --git a/scripts/demo_workout_handoff.py b/scripts/demo_workout_handoff.py index 23da203..a3a8419 100644 --- a/scripts/demo_workout_handoff.py +++ b/scripts/demo_workout_handoff.py @@ -1,33 +1,33 @@ -"""Try the workout-app handoff by hand, on a lock you can always escape. +"""Try the workout-app handoff by hand, with one deliberate way out. -Deliberately NOT the production lock screen: ``grab="local"`` and VT switching -left enabled, so nothing here can outlive a Ctrl+Alt+F2. +The lock itself is the real one: the demo builds the same lock window and the +workout app takes the same exclusive seat grab it takes in production, so what +is under test is the actual locking mechanism, not a softened copy. -The escapes are the point, and the first version of this script got them -wrong in two independent ways -- it promised three and delivered none, and the -only way out was a hard reboot. Both are fixed here, and both are worth -knowing about because only one of them was a demo-only problem: +Getting the escape right took two wrong tries, both worth recording because +they are the obvious ideas: -1. ``launch_workout_app`` blocks until the app exits. Called straight from a - button, it froze the Tk event loop, so the Escape binding and the close - button could not fire. The run is driven by :class:`WorkoutSession` now, - which uses ``after`` and keeps the loop pumping. -2. The Flutter app takes its OWN seat grab (``gdk_seat_grab`` with - ``GDK_SEAT_CAPABILITY_ALL``) and never releases it. That is correct for - production and fatal for a demo: while the app is up, X delivers keyboard - input to it, so no Tk binding of ours can fire no matter how healthy the - event loop is. So the escape hatch below is deliberately NOT a key binding - -- it is a file the app cannot intercept. +1. A Tk key binding cannot work. Once the workout app takes its seat grab + (``gdk_seat_grab`` with ``GDK_SEAT_CAPABILITY_ALL``) X delivers every + keystroke to that app and to nothing else, so no binding in this process + can fire -- no matter how healthy its event loop is. +2. "Escape from another terminal" cannot work either. The app covers the + screen with an override-redirect fullscreen window, so there is no other + terminal to reach and no way to raise one. -While the workout app is on screen, escape it from any other terminal or VT: +An escape therefore has to be handled INSIDE the app, by the app, or it cannot +be reached at all. ``--demo-escape`` (passed here, never by the production +supervisor) arms a key handler in the runner that drops the grab and quits: - touch ~/.cache/stop-workout-demo + Ctrl+Shift+Q -A watchdog polls that path and terminates the app, which hands the screen -back. Ctrl+Alt+F2 also still works, because neither this script nor -``enter_lock_mode`` disables VT switching. +Verified in both directions: with the flag the real app exits on that +keystroke while holding the grab; without it the same keystroke does nothing. -One thing a green run here does NOT prove: under a local grab gatelock's +VT switching is left enabled here as a second line of defence, so Ctrl+Alt+F2 +also still works -- but the hatch above is the one this demo is testing. + +One thing a green run does NOT prove: under the demo's local grab gatelock's watchdog early-returns and never re-takes the grab, so the 1000ms steal-back that makes ``_recovery.stop()`` load-bearing in production is not exercised. @@ -45,14 +45,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from gatelock import LockConfig, LockWindow -from screen_locker._workout_app import workout_app_binary +from screen_locker._workout_app import ProcessHooks, workout_app_binary from screen_locker._workout_handoff import lock_grab_handoff from screen_locker._workout_session import WorkoutSession -# Polled while the app holds the seat grab; the one escape X cannot swallow. -# Under $HOME rather than /tmp: predictable for the user, and not world-writable. -ESCAPE_FILE = Path.home() / ".cache" / "stop-workout-demo" - class _DemoHooks: """A minimal lock body: a status line and the two buttons under test.""" @@ -73,8 +69,8 @@ class _DemoHooks: self.status = tk.Label( parent, text=( - "Press “Start workout”. To escape while the app holds the " - f"screen, run: touch {ESCAPE_FILE}" + "Press “Start workout”. While the app holds the screen, " + "press Ctrl+Shift+Q to escape." ), font=("TkDefaultFont", 12), bg="#1B1D21", @@ -102,30 +98,19 @@ class _DemoHooks: def _start(self) -> None: if self.lock is None or self.status is None: # pragma: no cover return - ESCAPE_FILE.unlink(missing_ok=True) self.session = WorkoutSession( lock_grab_handoff(self.lock), after=self.lock.root.after, on_status=self._set_status, + # The only escape that can be RECEIVED once the app grabs the seat. + hooks=ProcessHooks(demo_escape=True), ) self.session.start() - self._poll_escape_file() def _set_status(self, text: str) -> None: if self.status is not None: # pragma: no branch self.status.config(text=text) - def _poll_escape_file(self) -> None: - """The escape the Flutter app's seat grab cannot intercept.""" - if self.session is None or self.lock is None: # pragma: no cover - return - if ESCAPE_FILE.exists(): - ESCAPE_FILE.unlink(missing_ok=True) - self.session.abort() - self.session = None - return - self.lock.root.after(200, self._poll_escape_file) - def _close(self) -> None: if self.lock is not None: # pragma: no cover self.lock.close() diff --git a/stronglift_replacement/workout_app/linux/runner/CMakeLists.txt b/stronglift_replacement/workout_app/linux/runner/CMakeLists.txt index e97dabc..0bf9958 100644 --- a/stronglift_replacement/workout_app/linux/runner/CMakeLists.txt +++ b/stronglift_replacement/workout_app/linux/runner/CMakeLists.txt @@ -9,6 +9,7 @@ project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} "main.cc" "my_application.cc" + "demo_escape.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) diff --git a/stronglift_replacement/workout_app/linux/runner/demo_escape.cc b/stronglift_replacement/workout_app/linux/runner/demo_escape.cc new file mode 100644 index 0000000..cafb9f9 --- /dev/null +++ b/stronglift_replacement/workout_app/linux/runner/demo_escape.cc @@ -0,0 +1,39 @@ +#include "demo_escape.h" + +// Deliberately awkward, so it cannot be hit by accident mid-workout, and +// deliberately NOT Escape: the Dart side already binds Escape for in-app +// navigation, and a demo hatch that shadowed it would change the behaviour +// being tested. +static const guint kEscapeKey = GDK_KEY_Q; +static const GdkModifierType kEscapeMods = + static_cast(GDK_CONTROL_MASK | GDK_SHIFT_MASK); + +static gboolean on_key_press(GtkWidget* widget, GdkEventKey* event, + gpointer user_data) { + (void)user_data; + const guint pressed = gdk_keyval_to_upper(event->keyval); + if (pressed != kEscapeKey) return FALSE; + if ((event->state & kEscapeMods) != kEscapeMods) return FALSE; + + // Drop the grab before quitting: destroying a window that still holds the + // seat can leave the pointer and keyboard grabbed with nothing to release + // them, which would need exactly the reboot this hatch exists to avoid. + GdkWindow* gdk_window = gtk_widget_get_window(widget); + if (gdk_window != nullptr) { + GdkDisplay* display = gdk_window_get_display(gdk_window); + gdk_seat_ungrab(gdk_display_get_default_seat(display)); + } + g_print("WORKOUT_LOCK: demo escape pressed; releasing grab and exiting\n"); + fflush(stdout); + gtk_window_close(GTK_WINDOW(widget)); + return TRUE; +} + +void demo_escape_install(GtkWidget* window, gboolean enabled) { + if (!enabled) return; + gtk_widget_add_events(window, GDK_KEY_PRESS_MASK); + g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), + nullptr); + g_print("WORKOUT_LOCK: demo escape armed (Ctrl+Shift+Q)\n"); + fflush(stdout); +} diff --git a/stronglift_replacement/workout_app/linux/runner/demo_escape.h b/stronglift_replacement/workout_app/linux/runner/demo_escape.h new file mode 100644 index 0000000..af4477a --- /dev/null +++ b/stronglift_replacement/workout_app/linux/runner/demo_escape.h @@ -0,0 +1,22 @@ +// Demo-only escape hatch for the lock-mode window. +// +// In lock mode the app takes an exclusive seat grab and never releases it, so +// while it is up X delivers every keystroke to this app and to nothing else. +// That is the point in production -- but it also means a human testing the +// handoff cannot reach a terminal, a VT, or the supervisor's own key bindings +// to get out. An escape has to be handled INSIDE the grab, by this app, or it +// cannot be reached at all. +// +// Enabled only by --demo-escape, which the demo harness passes and the +// production supervisor never does. The grab itself is unchanged, so the +// locking mechanism under test is the real one. + +#ifndef RUNNER_DEMO_ESCAPE_H_ +#define RUNNER_DEMO_ESCAPE_H_ + +#include + +// Installs the Ctrl+Shift+Q handler on `window`. No-op unless `enabled`. +void demo_escape_install(GtkWidget* window, gboolean enabled); + +#endif // RUNNER_DEMO_ESCAPE_H_ diff --git a/stronglift_replacement/workout_app/linux/runner/my_application.cc b/stronglift_replacement/workout_app/linux/runner/my_application.cc index 80f490c..6983747 100644 --- a/stronglift_replacement/workout_app/linux/runner/my_application.cc +++ b/stronglift_replacement/workout_app/linux/runner/my_application.cc @@ -1,5 +1,7 @@ #include "my_application.h" +#include "demo_escape.h" + #include #ifdef GDK_WINDOWING_X11 #include @@ -34,6 +36,8 @@ static const guint kGrabRetryMs = 200; static gboolean lock_mode_enabled = FALSE; static gboolean lock_grab_held = FALSE; +// Demo harness only; the production supervisor never passes it. +static gboolean demo_escape_enabled = FALSE; // Takes the exclusive seat grab. Returns TRUE while it should keep retrying. static gboolean try_seat_grab(gpointer data) { @@ -101,6 +105,9 @@ static void first_frame_cb(MyApplication* self, FlView* view) { // that is already mapped leaves it mapped but never repainting (a blank // grey rectangle that still holds the grab -- the worst possible lock). enter_lock_mode(GTK_WINDOW(toplevel)); + // Installed before the window is shown, so the hatch is live for every + // keystroke the grab will deliver -- including the first. + demo_escape_install(toplevel, demo_escape_enabled); gtk_widget_show(toplevel); // The supervisor releases its own grab only after seeing this line, so the // screen is already covered by us when it does. Flushed because stdout to @@ -184,7 +191,8 @@ static gboolean my_application_local_command_line(GApplication* application, for (gchar** arg = self->dart_entrypoint_arguments; *arg != nullptr; arg++) { if (g_strcmp0(*arg, "--lock-mode") == 0) { lock_mode_enabled = TRUE; - break; + } else if (g_strcmp0(*arg, "--demo-escape") == 0) { + demo_escape_enabled = TRUE; } }