From 4d1d2ea64d9d706775e2e212e6192b09960c8e70 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 12:41:07 +0200 Subject: [PATCH] Put the demo's escape hatch inside the app, the only place it can be received 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 Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg --- screen_locker/_workout_app.py | 4 ++ screen_locker/_workout_session.py | 5 +- screen_locker/tests/test_workout_session.py | 29 ++++++++ scripts/demo_workout_handoff.py | 71 ++++++++----------- .../workout_app/linux/runner/CMakeLists.txt | 1 + .../workout_app/linux/runner/demo_escape.cc | 39 ++++++++++ .../workout_app/linux/runner/demo_escape.h | 22 ++++++ .../linux/runner/my_application.cc | 10 ++- 8 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 stronglift_replacement/workout_app/linux/runner/demo_escape.cc create mode 100644 stronglift_replacement/workout_app/linux/runner/demo_escape.h 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; } }