git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitbebebc0e6a81208b53b881e40123383e191334f9
parenta3da8c3411
authorLucas Galante <[email protected]>
date2026-09-21 12:36
xdg-activation focuses and raises a mapped window; a borrowed state never births one minimized

Observed 2026-09-21: 1Password's CLI "Authorize" prompt — a parentless
400x370 toplevel with the main window's app_id — came up focused per
`ccectl windows` and was never seen. In a shadow the compositor's
stacking is not at fault: every `Seat::focus` on a Floating window
already raises it, `render_finish` keeps the floating plane above the
tiled one in render-list order, and a two-window Electron app whose log
matches the live one line for line (`Restoring saved state` → `xdg
activation request` → `Seat::focus`) lands its dialog on top. What the
handler did NOT do was act on an activation for an already-mapped
window: it only fired the "needs attention" D-Bus notification, so the
main window's own request at 15:52 changed nothing on screen.

- `handle_request_activate`: for a Mapped, non-shy window, un-minimize,
  focus, raise and dirty — what `ccectl focus-window` does. A request
  that lands before the map (Chromium activates between app_id and the
  first buffer) is left to the map path and its settle rules. Startup
  grace still suppresses both the notification and the focus.
- `try_restore`: `minimized` is applied from a session entry only. The
  app_id-only third pass of `match_last_window_state` hands a parentless
  dialog the LIVE main window's entry, so a main window the user had
  minimized produced a dialog that was focused, listed and invisible.
- `try_restore`: a Floating window whose borrowed origin coincides with
  a mapped sibling's is cascaded off it (`cascade_off_siblings`, 40 px
  diagonal steps, bounded) — the dialog no longer opens flush on the
  main window's corner where it reads as part of it.
- `ccectl windows` prints `stack=N`, the render-list position, so
  stacking can be asserted from a shadow without a screenshot.
- verify/clients: `float-pair` (one client, two parentless floats, the
  second activated before its first buffer; `--reactivate` activates
  the mapped main window later) and `vkey hold` (a headless seat has no
  keyboard, and Chromium crashes in xkb_state_update_mask on a
  modifiers event with no keymap before it).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                            |  41 +++-
 src/server/server.rs                 |  25 +++
 src/server/window.rs                 |  67 ++++++-
 src/server/window_manager.rs         |  22 +-
 verify/clients/Cargo.toml            |   6 +-
 verify/clients/src/bin/float_pair.rs | 378 +++++++++++++++++++++++++++++++++++
 verify/clients/src/bin/vkey.rs       |  16 +-
 7 files changed, 550 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c173054..4a62e9a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -233,7 +233,16 @@ own `target/`, invisible to ccebuild), built on demand by the drivers:
 - **`vkey`** — injects key events through `zwp_virtual_keyboard_v1`
   (wtype-style; evdev keycodes plus `mod:MASK` args for held modifiers).
   This exercises the same `KeyboardGroup::handle_group_key` path hardware
-  keys take, so keybindings and builtins fire for injected keys.
+  keys take, so keybindings and builtins fire for injected keys. `vkey hold`
+  keeps the virtual keyboard alive until killed: a headless seat has no
+  keyboard otherwise, and a Chromium/Electron client that gains focus there
+  crashes on a modifiers event with no keymap before it.
+- **`float-pair`** — one client, two parentless Floating toplevels with one
+  app_id: a 1024x800 main window, then an "Authorize" dialog that insists on
+  400x370 (min == max, ignores the configure) and is activated with a token
+  BEFORE its first buffer, the way Chromium/Electron open a dialog.
+  `--reactivate SECS` later activates the by-then-unfocused main window — an
+  activation for an already-mapped window. Prints one milestone per line.
 - **`status-stub`** — maps an xdg toplevel with a `cce-status*` app_id 400px
   tall, which `any_expanded_status_segment` reads as an open in-surface menu
   (expanded is geometric: thicker than `layout.bar_height`). It subscribes to
@@ -545,6 +554,36 @@ what the user pans along (cce-data-editor parked left of the first column
 came back mid-view every login before 2026-09-14). The recall is for a
 window with no tiled neighbour within a screen.
 
+### xdg-activation
+
+`handle_request_activate` (`server.rs`) runs for every activation wlroots
+accepts — the token was checked against a recent input serial or the
+requesting surface's focus. For a MAPPED window it now does what `ccectl
+focus-window` does: un-minimize, `seat.focus`, `raise_window`, dirty. Until
+2026-09-21 it only fired the "needs attention" D-Bus notification, so an
+activation for an already-mapped window changed nothing on screen. A request
+that lands before the map (Chromium/Electron activate a new window between
+its app_id and its first buffer, so the log reads `Restoring saved state` →
+`xdg activation request` → `Seat::focus`) is left to the map path, which
+focuses under its own settle rules. Every `Seat::focus` on a Floating window
+raises it, and `render_finish` keeps the floating plane above the tiled one
+in render-list order, so focus IS visibility for a float — `ccectl windows`
+prints `stack=N` (render-list position, higher is nearer) so that order can
+be asserted from a shadow without a screenshot.
+
+Two hazards in `try_restore` bite a second toplevel of a running app, which
+the app_id-only third pass of `match_last_window_state` hands the main
+window's remembered entry (a transient is excluded, a parentless dialog is
+not): `minimized` is taken from a session entry only, never from a borrowed
+one — a dialog born minimized is focused, listed and invisible — and a
+Floating window whose borrowed origin coincides with a mapped sibling's is
+cascaded off it (`cascade_off_siblings`, 40 px diagonal steps). Reproduce
+either with `verify/clients` `float-pair` (one client, two parentless
+toplevels, the second activated before its first buffer) or a two-window
+Electron app; Chromium in a shadow needs `vkey hold` running first, since a
+headless seat has no keyboard and Chromium crashes in
+`xkb_state_update_mask` on a modifiers event that no keymap preceded.
+
 ### IPC & status sockets
 
 - **Control socket** `/tmp/cce-{WAYLAND_DISPLAY}.sock` (`ipc_server.rs`): line-oriented
diff --git a/src/server/server.rs b/src/server/server.rs
index e8a4ed1..ac2eafd 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -420,6 +420,31 @@ unsafe extern "C" fn handle_request_activate(listener: *mut ffi::wl_listener, da
                 break;
             }
 
+            // A valid token (wlroots has already checked it against a recent
+            // input serial or the requesting surface's focus) is the user's
+            // intent to see this window, so honour it like `ccectl
+            // focus-window`: un-minimize, focus and raise — `Seat::focus`
+            // raises a Floating window itself, and `raise_window` covers
+            // the rest. Until now this handler only fired the "needs
+            // attention" notification, so an activation for an already-mapped
+            // window changed nothing: the window stayed beneath whatever
+            // covered it. A window that has not mapped yet (the request
+            // often lands between app_id and map) is left to the map path,
+            // which focuses new windows under its own settle rules.
+            if matches!((*win_ptr).state, crate::window::WindowState::Mapped) && !(*win_ptr).is_shy() {
+                if let Some(seat) = (*server).wm.first_seat() {
+                    if (*win_ptr).minimized {
+                        (*win_ptr).minimized = false;
+                    }
+                    (*seat).focus(crate::seat::Focus::Window(win_ptr));
+                    (*server).wm.raise_window(win_ptr);
+                    (*server).wm.dirty_windowing();
+                    log::info!("xdg activation focused and raised '{}' ({})", title, app_id);
+                }
+            } else {
+                log::info!("xdg activation for unmapped window '{}' ({}): left to the map path", title, app_id);
+            }
+
             let uid = unsafe { libc::getuid() };
             let bus_address = format!("unix:path=/run/user/{}/bus", uid);
             
diff --git a/src/server/window.rs b/src/server/window.rs
index 898a05d..1ab5941 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1213,7 +1213,16 @@ impl Window {
         if let Some(saved) = saved_opt {
             log::info!("Restoring saved state for window: app_id={}, title={}. Position: ({}, {}), Size: {}x{}", app_id_str, title_str, saved.virtual_x, saved.virtual_y, saved.width, saved.height);
             self.tiling_mode = saved.tiling_mode;
-            self.minimized = saved.minimized;
+            // `minimized` is session state, not app memory: a window the
+            // user just opened must never be born hidden. On a
+            // `last_window_states` borrow the flag is whatever the sibling
+            // (or the app's last incarnation) happened to be doing — and a
+            // parentless dialog matched by app_id alone inherits it from
+            // the LIVE main window, which the user may well have minimized
+            // to get it out of the way. Focused, listed, and invisible.
+            if from_session {
+                self.minimized = saved.minimized;
+            }
             self.virtual_x = saved.virtual_x;
             self.virtual_y = saved.virtual_y;
             self.scale = saved.scale;
@@ -1343,6 +1352,27 @@ impl Window {
                 }
             }
 
+            // A borrowed origin is a live sibling's origin whenever the
+            // app_id-only pass matched a window of an app that is still
+            // running: a parentless dialog (1Password's CLI "Authorize"
+            // prompt, a second browser window) lands exactly on the main
+            // window's top-left corner, where it reads as part of that
+            // window rather than a new one. Cascade it off any mapped
+            // sibling already sitting there, the way every stacking WM
+            // offsets a new window from the last. Session entries are
+            // exempt: a restored layout is where the user left it.
+            if !from_session && self.tiling_mode == crate::tiling::TilingMode::Floating {
+                let (nx, ny) = self.cascade_off_siblings(&app_id_str, self.virtual_x, self.virtual_y);
+                if (nx, ny) != (self.virtual_x, self.virtual_y) {
+                    log::info!(
+                        "Cascading new {} window off a sibling at ({:.0},{:.0}) -> ({:.0},{:.0})",
+                        app_id_str, self.virtual_x, self.virtual_y, nx, ny
+                    );
+                    self.virtual_x = nx;
+                    self.virtual_y = ny;
+                }
+            }
+
             self.restored = true;
             self.session_restored = from_session;
             // The saved `focused` flag only means something for the startup
@@ -1353,6 +1383,41 @@ impl Window {
         }
     }
 
+    /// Step an origin diagonally until no mapped sibling of `app_id` (any
+    /// window but this one) has its top-left within a few pixels of it.
+    /// Bounded, so a pathological pile of siblings cannot walk a window off
+    /// the desk: after `MAX_STEPS` the last candidate is taken as is.
+    unsafe fn cascade_off_siblings(&self, app_id: &str, x: f64, y: f64) -> (f64, f64) {
+        const STEP: f64 = 40.0;
+        const NEAR: f64 = 4.0;
+        const MAX_STEPS: usize = 8;
+        let me = self as *const Window;
+        let origins: Vec<(f64, f64)> = (*self.server)
+            .wm
+            .windows
+            .iter()
+            .copied()
+            .filter(|&w| !w.is_null() && w as *const Window != me && !(*w).closed)
+            .filter(|&w| matches!((*w).state, WindowState::Mapped))
+            .filter(|&w| (*w).get_app_id_string().as_deref() == Some(app_id))
+            .map(|w| ((*w).virtual_x, (*w).virtual_y))
+            .collect();
+        let taken = |cx: f64, cy: f64| {
+            origins
+                .iter()
+                .any(|&(ox, oy)| (ox - cx).abs() <= NEAR && (oy - cy).abs() <= NEAR)
+        };
+        let (mut cx, mut cy) = (x, y);
+        for _ in 0..MAX_STEPS {
+            if !taken(cx, cy) {
+                break;
+            }
+            cx += STEP;
+            cy += STEP;
+        }
+        (cx, cy)
+    }
+
     /// Apply a one-shot `place-next` hint: land the window's top-left just
     /// below-right of the hinted layout position (the control that spawned
     /// it), clamped to the output so it stays fully on-screen. Runs after
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 1294a70..a0df769 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5674,10 +5674,28 @@ impl WindowManager {
                     curr_seat = next_seat;
                 }
 
+                // Render-list position, bottom (0) to top: the stacking
+                // order the reorder pass applies within a layer, which a
+                // window list keyed by slot id cannot show. -1 = not linked.
+                let mut stack_of: std::collections::HashMap<*mut Window, i64> = std::collections::HashMap::new();
+                {
+                    let render_list = &mut self.rendering_requested.list as *mut ffi::wl_list as *mut WlList;
+                    let mut curr = (*render_list).next;
+                    let mut i = 0i64;
+                    while !curr.is_null() && curr != render_list {
+                        let node = crate::container_of!(curr, crate::wm_node::WmNode, link);
+                        if let crate::wm_node::WmNodeType::Window(win) = (*node).get() {
+                            stack_of.insert(win, i);
+                        }
+                        i += 1;
+                        curr = (*curr).next;
+                    }
+                }
                 let mut out = String::new();
                 let sp = self.layout.snap_params();
                 for &w in self.windows.iter() {
                     if !w.is_null() && !(*w).closed && !matches!((*w).state, crate::window::WindowState::Closing | crate::window::WindowState::Init) {
+                        let stack = stack_of.get(&w).copied().unwrap_or(-1);
                         let app_id = (*w).get_app_id_string().unwrap_or_default();
                         let title = (*w).get_title_string().unwrap_or_default();
                         // Which desktop square(s) the window sits on, chess
@@ -5711,6 +5729,7 @@ impl WindowManager {
                                 "minimized": (*w).minimized,
                                 "has_parent": (*w).has_parent,
                                 "focused": w == focused_window,
+                                "stack": stack,
                                 "ssd": (*w).wm_requested.ssd,
                                 // Why a window has (or lacks) rounded corners,
                                 // blur and shadow. Without it the only way to
@@ -5724,7 +5743,7 @@ impl WindowManager {
                             out.push('\n');
                         } else {
                             out.push_str(&format!(
-                                "window id={} app_id={} title=\"{}\" mode={} x={} y={} w={} h={} vx={:.1} vy={:.1} cell={} minimized={} has_parent={} focused={} ssd={} decorated={} beveled={}\n",
+                                "window id={} app_id={} title=\"{}\" mode={} x={} y={} w={} h={} vx={:.1} vy={:.1} cell={} minimized={} has_parent={} focused={} stack={} ssd={} decorated={} beveled={}\n",
                                 (*w).ref_key.index,
                                 app_id,
                                 title,
@@ -5739,6 +5758,7 @@ impl WindowManager {
                                 (*w).minimized,
                                 (*w).has_parent,
                                 w == focused_window,
+                                stack,
                                 (*w).wm_requested.ssd,
                                 self.is_decorated_app(&app_id),
                                 self.is_beveled_app(&app_id),
diff --git a/verify/clients/Cargo.toml b/verify/clients/Cargo.toml
index a975aff..d23774a 100644
--- a/verify/clients/Cargo.toml
+++ b/verify/clients/Cargo.toml
@@ -12,9 +12,13 @@ path = "src/bin/vkey.rs"
 name = "status-stub"
 path = "src/bin/status_stub.rs"
 
+[[bin]]
+name = "float-pair"
+path = "src/bin/float_pair.rs"
+
 [dependencies]
 wayland-client = "0.31"
-wayland-protocols = { version = "0.32", features = ["client"] }
+wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
 wayland-protocols-misc = { version = "0.3", features = ["client"] }
 xkbcommon = "0.8"
 libc = "0.2"
diff --git a/verify/clients/src/bin/float_pair.rs b/verify/clients/src/bin/float_pair.rs
new file mode 100644
index 0000000..2b0b4b6
--- /dev/null
+++ b/verify/clients/src/bin/float_pair.rs
@@ -0,0 +1,378 @@
+// float-pair — one client, two parentless floating toplevels, the second
+// opened later the way a Chromium/Electron app opens an "Authorize" dialog:
+// same app_id, no xdg parent, a fixed size it insists on (min == max, and it
+// commits its own size whatever the configure said), a server-side
+// decoration request, and an xdg-activation request BEFORE its first buffer,
+// with a token issued against the first window's surface.
+//
+// Prints one line per milestone so a driver can wait on them:
+//   main mapped | dialog created | token <t> | activated | dialog mapped
+//
+// Args: --app-id ID  --delay SECS  --main WxH  --dialog WxH
+//       --dialog-honours-configure  --no-activate  --no-decoration
+//       --bare-token (no seat/serial/surface on the token)
+//       --reactivate SECS  that long after the dialog, request activation of
+//                          the (by then unfocused) MAIN window — an activation
+//                          for an already-mapped window
+// Extra milestones: reactivate requested | reactivated
+
+use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
+use std::time::{Duration, Instant};
+use wayland_client::{
+    delegate_noop,
+    protocol::{wl_buffer, wl_compositor, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface},
+    Connection, Dispatch, QueueHandle,
+};
+use wayland_protocols::xdg::activation::v1::client::{xdg_activation_token_v1, xdg_activation_v1};
+use wayland_protocols::xdg::decoration::zv1::client::{
+    zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
+};
+use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+
+struct Win {
+    surface: wl_surface::WlSurface,
+    xdg: xdg_surface::XdgSurface,
+    toplevel: xdg_toplevel::XdgToplevel,
+    want: (i32, i32),
+    honour: bool,
+    pending: Option<(i32, i32)>,
+    needs_buffer: bool,
+    mapped: bool,
+    color: u32,
+    name: &'static str,
+}
+
+#[derive(Default)]
+struct State {
+    compositor: Option<wl_compositor::WlCompositor>,
+    shm: Option<wl_shm::WlShm>,
+    wm_base: Option<xdg_wm_base::XdgWmBase>,
+    activation: Option<xdg_activation_v1::XdgActivationV1>,
+    seat: Option<wl_seat::WlSeat>,
+    decoration: Option<zxdg_decoration_manager_v1::ZxdgDecorationManagerV1>,
+    wins: Vec<Win>,
+    token: Option<String>,
+    closed: bool,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+    fn event(
+        state: &mut Self,
+        registry: &wl_registry::WlRegistry,
+        event: wl_registry::Event,
+        _: &(),
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+    ) {
+        if let wl_registry::Event::Global { name, interface, version } = event {
+            match interface.as_str() {
+                "wl_compositor" => {
+                    state.compositor = Some(
+                        registry.bind::<wl_compositor::WlCompositor, _, _>(name, version.min(4), qh, ()),
+                    );
+                }
+                "wl_shm" => state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, 1, qh, ())),
+                "xdg_wm_base" => {
+                    state.wm_base = Some(registry.bind::<xdg_wm_base::XdgWmBase, _, _>(name, 1, qh, ()))
+                }
+                "xdg_activation_v1" => {
+                    state.activation =
+                        Some(registry.bind::<xdg_activation_v1::XdgActivationV1, _, _>(name, 1, qh, ()))
+                }
+                "wl_seat" => state.seat = Some(registry.bind::<wl_seat::WlSeat, _, _>(name, 1, qh, ())),
+                "zxdg_decoration_manager_v1" => {
+                    state.decoration = Some(
+                        registry
+                            .bind::<zxdg_decoration_manager_v1::ZxdgDecorationManagerV1, _, _>(name, 1, qh, ()),
+                    )
+                }
+                _ => {}
+            }
+        }
+    }
+}
+
+impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
+    fn event(
+        _: &mut Self,
+        wm_base: &xdg_wm_base::XdgWmBase,
+        event: xdg_wm_base::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        if let xdg_wm_base::Event::Ping { serial } = event {
+            wm_base.pong(serial);
+        }
+    }
+}
+
+impl Dispatch<xdg_surface::XdgSurface, ()> for State {
+    fn event(
+        state: &mut Self,
+        xdg_surface: &xdg_surface::XdgSurface,
+        event: xdg_surface::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        if let xdg_surface::Event::Configure { serial } = event {
+            xdg_surface.ack_configure(serial);
+            if let Some(win) = state.wins.iter_mut().find(|w| &w.xdg == xdg_surface) {
+                win.needs_buffer = true;
+            }
+        }
+    }
+}
+
+impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
+    fn event(
+        state: &mut Self,
+        toplevel: &xdg_toplevel::XdgToplevel,
+        event: xdg_toplevel::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        match event {
+            xdg_toplevel::Event::Configure { width, height, .. } => {
+                if width > 0 && height > 0 {
+                    if let Some(win) = state.wins.iter_mut().find(|w| &w.toplevel == toplevel) {
+                        win.pending = Some((width, height));
+                    }
+                }
+            }
+            xdg_toplevel::Event::Close => state.closed = true,
+            _ => {}
+        }
+    }
+}
+
+impl Dispatch<xdg_activation_token_v1::XdgActivationTokenV1, ()> for State {
+    fn event(
+        state: &mut Self,
+        _: &xdg_activation_token_v1::XdgActivationTokenV1,
+        event: xdg_activation_token_v1::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        if let xdg_activation_token_v1::Event::Done { token } = event {
+            state.token = Some(token);
+        }
+    }
+}
+
+delegate_noop!(State: ignore wl_compositor::WlCompositor);
+delegate_noop!(State: ignore wl_shm::WlShm);
+delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
+delegate_noop!(State: ignore wl_buffer::WlBuffer);
+delegate_noop!(State: ignore wl_surface::WlSurface);
+delegate_noop!(State: ignore wl_seat::WlSeat);
+delegate_noop!(State: ignore xdg_activation_v1::XdgActivationV1);
+delegate_noop!(State: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
+delegate_noop!(State: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1);
+
+fn make_buffer(
+    shm: &wl_shm::WlShm,
+    w: i32,
+    h: i32,
+    color: u32,
+    qh: &QueueHandle<State>,
+) -> wl_buffer::WlBuffer {
+    let size = (w * 4 * h) as u64;
+    let fd = unsafe { libc::memfd_create(b"float-pair\0".as_ptr() as *const _, 0) };
+    assert!(fd >= 0, "memfd_create failed");
+    let file = unsafe { std::fs::File::from_raw_fd(fd) };
+    file.set_len(size).unwrap();
+    let mmapped = unsafe {
+        libc::mmap(std::ptr::null_mut(), size as usize, libc::PROT_WRITE, libc::MAP_SHARED, file.as_raw_fd(), 0)
+    };
+    assert!(mmapped != libc::MAP_FAILED, "mmap failed");
+    unsafe {
+        let px = mmapped as *mut u32;
+        for i in 0..(w * h) as usize {
+            *px.add(i) = color;
+        }
+        libc::munmap(mmapped, size as usize);
+    }
+    let fd: OwnedFd = OwnedFd::from(file);
+    let pool = shm.create_pool(fd.as_fd(), w * 4 * h, qh, ());
+    // The pool object is leaked on purpose: the buffer outlives it anyway,
+    // and this is a test client.
+    pool.create_buffer(0, w, h, w * 4, wl_shm::Format::Argb8888, qh, ())
+}
+
+fn parse_size(s: &str) -> (i32, i32) {
+    let (w, h) = s.split_once('x').expect("size is WxH");
+    (w.parse().unwrap(), h.parse().unwrap())
+}
+
+fn main() {
+    let mut app_id = "test.floatpair".to_string();
+    let mut delay = 3.0f64;
+    let mut main_size = (1024, 800);
+    let mut dialog_size = (400, 370);
+    let mut dialog_honours = false;
+    let mut activate = true;
+    let mut decoration = true;
+    let mut bare_token = false;
+    let mut reactivate: Option<f64> = None;
+    let mut args = std::env::args().skip(1);
+    while let Some(a) = args.next() {
+        match a.as_str() {
+            "--app-id" => app_id = args.next().unwrap(),
+            "--delay" => delay = args.next().unwrap().parse().unwrap(),
+            "--main" => main_size = parse_size(&args.next().unwrap()),
+            "--dialog" => dialog_size = parse_size(&args.next().unwrap()),
+            "--dialog-honours-configure" => dialog_honours = true,
+            "--no-activate" => activate = false,
+            "--no-decoration" => decoration = false,
+            "--bare-token" => bare_token = true,
+            "--reactivate" => reactivate = Some(args.next().unwrap().parse().unwrap()),
+            other => panic!("unknown arg {other}"),
+        }
+    }
+
+    let conn = Connection::connect_to_env().expect("connect to wayland display");
+    let display = conn.display();
+    let mut queue = conn.new_event_queue();
+    let qh = queue.handle();
+    let _registry = display.get_registry(&qh, ());
+
+    let mut state = State::default();
+    queue.roundtrip(&mut state).unwrap();
+
+    let compositor = state.compositor.clone().expect("no wl_compositor");
+    let shm = state.shm.clone().expect("no wl_shm");
+    let wm_base = state.wm_base.clone().expect("no xdg_wm_base");
+
+    let make_win = |state: &mut State, title: &str, want: (i32, i32), honour: bool, color: u32, fixed: bool, name: &'static str| {
+        let surface = compositor.create_surface(&qh, ());
+        let xdg = wm_base.get_xdg_surface(&surface, &qh, ());
+        let toplevel = xdg.get_toplevel(&qh, ());
+        toplevel.set_app_id(app_id.clone());
+        toplevel.set_title(title.into());
+        if fixed {
+            toplevel.set_min_size(want.0, want.1);
+            toplevel.set_max_size(want.0, want.1);
+        }
+        if decoration {
+            if let Some(dm) = &state.decoration {
+                let deco = dm.get_toplevel_decoration(&toplevel, &qh, ());
+                deco.set_mode(zxdg_toplevel_decoration_v1::Mode::ServerSide);
+            }
+        }
+        surface.commit();
+        state.wins.push(Win {
+            surface,
+            xdg,
+            toplevel,
+            want,
+            honour,
+            pending: None,
+            needs_buffer: false,
+            mapped: false,
+            color,
+            name,
+        });
+    };
+
+    make_win(&mut state, "Main window", main_size, true, 0xff2a_6f97, false, "main");
+
+    let start = Instant::now();
+    let mut dialog_created = false;
+    let mut token_requested = false;
+    let mut activated = false;
+    let mut react_requested = false;
+    let mut reactivated = false;
+
+    while !state.closed {
+        conn.flush().unwrap();
+        if let Some(guard) = conn.prepare_read() {
+            let mut pfd = libc::pollfd { fd: guard.connection_fd().as_raw_fd(), events: libc::POLLIN, revents: 0 };
+            let n = unsafe { libc::poll(&mut pfd, 1, 50) };
+            if n > 0 && (pfd.revents & libc::POLLIN) != 0 {
+                let _ = guard.read();
+            } else {
+                drop(guard);
+            }
+        }
+        queue.dispatch_pending(&mut state).unwrap();
+
+        for win in state.wins.iter_mut() {
+            if win.needs_buffer {
+                win.needs_buffer = false;
+                let size = match (win.honour, win.pending) {
+                    (true, Some(p)) => p,
+                    _ => win.want,
+                };
+                let buf = make_buffer(&shm, size.0, size.1, win.color, &qh);
+                win.surface.attach(Some(&buf), 0, 0);
+                win.surface.damage_buffer(0, 0, size.0, size.1);
+                win.surface.commit();
+                if !win.mapped {
+                    win.mapped = true;
+                    println!("{} mapped {}x{}", win.name, size.0, size.1);
+                }
+            }
+        }
+
+        // The token is fetched a second before the dialog so it is in hand
+        // when the dialog is created, and the activation goes out right
+        // after the dialog's initial commit — before its first buffer, the
+        // way Chromium does it (the live log shows the request landing
+        // between the app_id and the map).
+        if activate && !token_requested && start.elapsed() + Duration::from_secs(1) >= Duration::from_secs_f64(delay) {
+            token_requested = true;
+            if let (Some(act), Some(seat)) = (state.activation.clone(), state.seat.clone()) {
+                let tok = act.get_activation_token(&qh, ());
+                tok.set_app_id(app_id.clone());
+                if !bare_token {
+                    tok.set_surface(&state.wins[0].surface);
+                    tok.set_serial(0, &seat);
+                }
+                tok.commit();
+            } else {
+                println!("no xdg_activation_v1 or wl_seat; not activating");
+            }
+        }
+        if !dialog_created && start.elapsed() >= Duration::from_secs_f64(delay) {
+            dialog_created = true;
+            make_win(&mut state, "Authorize", dialog_size, dialog_honours, 0xffd9_8c2b, true, "dialog");
+            println!("dialog created");
+            if let Some(t) = state.token.clone() {
+                activated = true;
+                println!("token {}", t);
+                state.activation.as_ref().unwrap().activate(t, &state.wins[1].surface);
+                println!("activated");
+            }
+        }
+        if let Some(secs) = reactivate {
+            if dialog_created && !react_requested && start.elapsed() >= Duration::from_secs_f64(delay + secs) {
+                react_requested = true;
+                state.token = None;
+                let act = state.activation.clone().expect("no xdg_activation_v1");
+                let tok = act.get_activation_token(&qh, ());
+                tok.set_app_id(app_id.clone());
+                tok.commit();
+                println!("reactivate requested");
+            }
+            if react_requested && !reactivated {
+                if let Some(t) = state.token.clone() {
+                    reactivated = true;
+                    state.activation.as_ref().unwrap().activate(t, &state.wins[0].surface);
+                    println!("reactivated");
+                }
+            }
+        }
+        if dialog_created && token_requested && !activated {
+            if let Some(t) = state.token.clone() {
+                activated = true;
+                println!("token (late) {}", t);
+                state.activation.as_ref().unwrap().activate(t, &state.wins[1].surface);
+                println!("activated");
+            }
+        }
+    }
+}
diff --git a/verify/clients/src/bin/vkey.rs b/verify/clients/src/bin/vkey.rs
index 6213900..f4ec2c9 100644
--- a/verify/clients/src/bin/vkey.rs
+++ b/verify/clients/src/bin/vkey.rs
@@ -5,6 +5,11 @@
 //   keycode   evdev code, pressed then released (h=35 e=18 l=38 o=24, 1=Escape)
 //   mod:MASK  set held modifiers for the keys that follow (xkb depressed mask
 //             under the us keymap: shift=1 ctrl=4 alt=8 super=64); cleared on exit
+//   hold      after the events, keep the virtual keyboard alive until killed.
+//             A headless shadow seat has no keyboard at all, so a client that
+//             gains focus there gets wl_keyboard.modifiers with no keymap
+//             before it — Chromium/Electron crash in xkb_state_update_mask on
+//             that. Holding one keyboard gives every later client a keymap.
 
 use std::io::Write;
 use std::os::fd::{BorrowedFd, FromRawFd, OwnedFd};
@@ -122,7 +127,8 @@ fn main() {
     queue.roundtrip(&mut state).unwrap();
 
     let mut t = 0u32;
-    for arg in &args {
+    let hold = args.iter().any(|a| a == "hold");
+    for arg in args.iter().filter(|a| *a != "hold") {
         if let Some(mask) = arg.strip_prefix("mod:") {
             let depressed: u32 = mask.parse().expect("mod mask must be a number");
             vk.modifiers(depressed, 0, 0, 0);
@@ -136,6 +142,14 @@ fn main() {
     vk.modifiers(0, 0, 0, 0);
     queue.roundtrip(&mut state).unwrap();
 
+    if hold {
+        println!("holding virtual keyboard");
+        std::io::stdout().flush().ok();
+        loop {
+            queue.blocking_dispatch(&mut state).unwrap();
+        }
+    }
+
     vk.destroy();
     queue.roundtrip(&mut state).unwrap();
     println!("sent {} event(s)", args.len());