Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions tools/agent-isolation/gpg-touch-overlay-window-macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
#
# ``<Deactivate>``, not ``<FocusOut>``: 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
# ``<Deactivate>`` 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("<Deactivate>", lambda _event: root.destroy()),
)
return root


Expand Down
37 changes: 37 additions & 0 deletions tools/agent-isolation/tests/test_gpg_touch_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<Deactivate>``: Tk's focus events track the keyboard
moving between widgets inside one application, and nothing moves
between widgets here. ``<FocusOut>`` 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 "<Deactivate>" in bound, (
"the Aqua overlay does not close when another application takes the "
f"keyboard; it binds only {sorted(bound)}"
)
assert "<Escape>" in bound, "the Aqua overlay no longer closes on Esc"