diff --git a/tools/agent-isolation/gpg-touch-overlay-window-macos.py b/tools/agent-isolation/gpg-touch-overlay-window-macos.py index d8bb6f40..53b2f971 100644 --- a/tools/agent-isolation/gpg-touch-overlay-window-macos.py +++ b/tools/agent-isolation/gpg-touch-overlay-window-macos.py @@ -42,8 +42,11 @@ showing that should be the overlay, which ignores them — see ``take_focus``. -Closes on Esc or a click — the key must still be touched for the command -to go through, so trapping the screen would buy nothing. +Closes on Esc, on a click, or as soon as it loses focus — the key must +still be touched for the command to go through, so trapping the screen +would buy nothing. Closing on focus loss is what makes that last part +safe: the keyboard is taken once, on the way up, and never grabbed back, +so a window that is no longer listening is never left on screen. """ import ctypes @@ -77,6 +80,9 @@ PULSE_MS = 33 PULSE_PERIOD = 1.9 # seconds, one ripple's whole travel STOP_POLL_MS = 60 +# Long enough to cover the focus churn of activating the application, short +# enough that a window losing focus to something else is still caught. +FOCUS_GRACE_MS = 750 FONT = "Helvetica Neue" MONO = "Menlo" @@ -302,6 +308,27 @@ def build_window(): # around it. root.update() take_focus(root) + + # ``take_focus`` runs once, and the window is ``-topmost``: anything + # that takes the keyboard afterwards — a notification, another app + # activating, the terminal being clicked — leaves it painted over the + # screen with Esc going somewhere else, and nothing but a kill to get + # rid of it. Closing on focus loss is what keeps that state + # unreachable. Re-grabbing the keyboard instead would trap the screen, + # which this window deliberately does not do. + # + # ````, not ````: Tk's focus events track the + # keyboard moving between widgets inside one application, and nothing + # moves between widgets here. Losing the keyboard to a *different* + # application is what has to be caught, and on Aqua that arrives as + # ```` on the toplevel. + # + # The binding is deferred because activation itself churns focus: bound + # immediately, the window closes on its own way up. + root.after( + FOCUS_GRACE_MS, + lambda: root.bind("", lambda _event: root.destroy()), + ) return root diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py index b55ca45f..a8b0d19e 100644 --- a/tools/agent-isolation/tests/test_gpg_touch_overlay.py +++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py @@ -1180,3 +1180,40 @@ def test_the_two_windows_share_one_copy_of_the_context_helpers() -> None: assert set(found) == set(wanted), f"{window.name} is missing {wanted}" helpers.append([found[name] for name in wanted]) assert helpers[0] == helpers[1], "the two windows' context helpers have drifted" + + +def test_the_aqua_window_closes_when_it_loses_the_keyboard() -> None: + """An overlay that is no longer listening must not stay on screen. + + ``take_focus`` takes the keyboard once, on the way up, and the window + is ``-topmost``. Anything that takes the keyboard afterwards leaves it + painted over the screen with Esc going elsewhere and nothing but a + kill to get rid of it — which is what this binding exists to prevent. + + It has to be ````: Tk's focus events track the keyboard + moving between widgets inside one application, and nothing moves + between widgets here. ```` does not fire when a different + application takes over, so binding it instead reads correct and does + nothing. + """ + window = SCRIPT.parent / "gpg-touch-overlay-window-macos.py" + tree = ast.parse(window.read_text()) + build = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "build_window" + ) + bound = { + node.args[0].value + for node in ast.walk(build) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "bind" + and node.args + and isinstance(node.args[0], ast.Constant) + } + assert "" in bound, ( + "the Aqua overlay does not close when another application takes the " + f"keyboard; it binds only {sorted(bound)}" + ) + assert "" in bound, "the Aqua overlay no longer closes on Esc"