Skip to content

Wayland: seal xdg-activation tokens with the last pointer-press serial#4612

Open
Le-Syl21 wants to merge 2 commits into
rust-windowing:masterfrom
Le-Syl21:wayland-activation-set-serial
Open

Wayland: seal xdg-activation tokens with the last pointer-press serial#4612
Le-Syl21 wants to merge 2 commits into
rust-windowing:masterfrom
Le-Syl21:wayland-activation-set-serial

Conversation

@Le-Syl21

@Le-Syl21 Le-Syl21 commented Jul 2, 2026

Copy link
Copy Markdown

Problem

Window::request_activation_token() (and the xdg-activation path of request_user_attention) builds its xdg_activation_token_v1 request with set_surface + commit, but never calls set_serial.

The protocol marks set_serial as optional, but adds:

Some compositors might refuse to activate toplevels when the token doesn't have a valid and recent enough event serial.

In practice, compositors enforcing focus-stealing prevention (mutter, KWin, niri in strict mode) validate the token's serial against the pointer's grab serial and silently refuse the ensuing activate() when it's absent. On mutter the minted token string ends in _TIME0 and the activation degrades to demands-attention at best. Net effect: an app that requests a token on user click and hands it to a child process via XDG_ACTIVATION_TOKEN sees the child's window open without focus — defeating the exact purpose of the API.

This mirrors what GTK4/GDK and Qt do internally (both seal launch tokens with the serial of the triggering input event), and is the missing piece behind several "launched app opens behind the launcher on GNOME Wayland" reports across the ecosystem (e.g. wezterm/wezterm#3619, ghostty-org/ghostty#5812 discuss the same compositor behavior).

Fix

  • Record the (WlSeat, serial) of the last pointer button press on the window's WindowState (recorded in the pointer handler, which already locks that state).
  • Seal both token issuers (request_activation_token, request_user_attention) with it via set_serial(serial, &seat) before commit.

Why press only: compositors update the pointer grab serial exclusively on button press (mutter meta-wayland-pointer.c). UI toolkits fire click actions on release, so recording the release serial too would always overwrite the press serial with one the compositor doesn't recognize — we hit exactly this during testing (token still refused when sealed with the release serial; accepted with the press serial).

Storing per-WindowState (rather than globally) also keeps the serial and the set_surface target referring to the same surface, which matches how compositors validate the pair, and avoids growing the platform Window enum.

If the window has never seen a button press, behavior is unchanged (token issued without serial, best-effort as before).

Testing

Tested on a GNOME 46 / mutter Wayland cabinet setup (winit-based launcher app, fullscreen):

  1. Launcher requests a token on user click, receives it via ActivationTokenDone, and passes it in XDG_ACTIVATION_TOKEN to a spawned process.
  2. Before: token sealed without serial → mutter refuses activation → child window opens unfocused. Confirmed via mutter source (46.0 meta-wayland-activation.c: token_can_activatemeta_wayland_seat_get_grab_info).
  3. After: spawning gnome-text-editor (GTK4, reference consumer of XDG_ACTIVATION_TOKEN) with the sealed token transfers keyboard focus reliably (3/3 runs, including >30 s after launcher startup, i.e. long past any startup token's validity).
  4. Instrumented traces confirmed the press serial is captured and applied at set_serial time.

cargo +nightly fmt, cargo clippy -p winit-wayland and cargo test -p winit-wayland are clean; the change is confined to winit-wayland.

Future work

Keyboard (wl_keyboard.key) and touch (wl_touch.down) serials can be recorded the same way for apps driven without a pointer; this PR keeps the scope to the pointer path.

Tokens issued by Window::request_activation_token and the
user-attention path were built with set_surface + commit but never
set_serial. Compositors enforcing focus-stealing prevention (mutter,
kwin, niri) validate the token's serial against the pointer's grab
serial and refuse the ensuing activation when it is absent — the
launched application's window opens unfocused, defeating the purpose
of handing it an activation token.

Record the (seat, serial) of the last pointer button *press* on the
window's WindowState, and seal both token issuers with it before
commit. Press only: compositors update the pointer grab serial
exclusively on button press (mutter meta-wayland-pointer.c), and UI
toolkits fire click actions on release, so recording the release
serial too would always overwrite the one the compositor recognizes.

Verified on GNOME 46 / mutter Wayland: a launcher app requesting a
token on click and passing it via XDG_ACTIVATION_TOKEN to a spawned
GTK4 app now transfers keyboard focus; before the patch the same flow
produced a token that mutter discarded.
@Le-Syl21

Le-Syl21 commented Jul 2, 2026

Copy link
Copy Markdown
Author

Note on CI: the cargo-deny failure is unrelated to this PR — it flags RUSTSEC-2026-0194 / RUSTSEC-2026-0195 (quick-xml) and the ttf-parser unmaintained advisory, all against dependencies already on master (this PR adds no dependencies). Master's last CI run (Jun 27) predates these advisories, which is why it was still green. All 20+ platform test jobs pass.

@kchibisov kchibisov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's already latest button serial stored on wl pointer, can just use it.

Per review — reuse the pointer-side storage rather than introducing
window-level state. The existing `latest_button_serial` can't be used
directly: it is updated on both press and release (both winit's copy
and sctk's), while compositors validate activation serials against the
pointer's grab serial, which only changes on button press (mutter
`meta-wayland-pointer.c::handle_button_event` updates `grab_serial`
solely on CLUTTER_BUTTON_PRESS). Toolkits fire click actions on
release, so at request time the combined serial always holds the
release value — one the compositor refuses.

Add `latest_press_serial` next to it on `WinitPointerDataInner`, and
read it from the token issuers through the same `apply_on_pointer`
pattern `drag_window` / `show_window_menu` use.
@Le-Syl21

Le-Syl21 commented Jul 2, 2026

Copy link
Copy Markdown
Author

Thanks for the review! Reworked in 5660929 — the serial now lives on WinitPointerData and the token issuers read it through the same apply_on_pointer pattern as drag_window / show_window_menu; the WindowState field is gone.

One nuance on reusing the existing latest_button_serial as-is: it's updated on both press and release (winit's copy and sctk's PointerData::latest_button_serial alike — sctk documents it as "Serial from the latest button Press and Release events"). Compositors, however, validate activation serials against the pointer's grab serial, which only changes on button press — mutter 46 meta-wayland-pointer.c::handle_button_event:

implicit_grab = (clutter_event_type (event) == CLUTTER_BUTTON_PRESS) && (pointer->button_count == 1);
...
if (implicit_grab)
    pointer->grab_serial = wl_display_get_serial (seat->wl_display);

and meta_wayland_pointer_can_grab_surface requires pointer->grab_serial == serial. Since toolkits fire click actions on release, the combined press/release serial holds the release value at request time, and the resulting token is refused (we hit exactly this while testing on GNOME 46 — sealing with the release serial failed, press serial succeeded). Hence the separate latest_press_serial next to the existing field rather than reusing it.

@kchibisov kchibisov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in the mood to talk with robot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants