Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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());