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

commite85a18abd1de2288c0c01ed766a16734e4892dfd
parent8708d692a2
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 16:10
feat(xwayland): never show the XEmbed tray bridge's containers

cce-status-interface's new cce-xembed-tray owns the X11 system tray and
adopts each legacy tray icon into an override-redirect container, which
X must have mapped for the icon to draw at all. The icon is shown in the
status bar, so an OR window with WM_CLASS class cce-xembed-tray gets no
scene node: nothing to draw, hit-test or focus.

verify/clients gains xembed-icon, an XEmbed tray icon docked the way
Wine's systray docks one, printing each milestone and every button it
receives, for driving the bridge in a shadow started with --xwayland.

CLAUDE.md's test paragraph caught up with the tree: global_shortcuts.rs
has tests, and window_manager.rs now has the most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                                |  23 +++-
 src/server/xwayland_override_redirect.rs |  21 ++++
 verify/clients/Cargo.lock                |  28 +++++
 verify/clients/Cargo.toml                |   5 +
 verify/clients/src/bin/xembed_icon.rs    | 180 +++++++++++++++++++++++++++++++
 5 files changed, 253 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 05ba432f..c1b8bd84 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -213,10 +213,12 @@ Native libs via `pkg-config`: `wlroots-0.20`, `wayland-server`, `xkbcommon`,
 
 ## Tests
 
-Nine modules carry unit tests — `backdrop.rs` (the most of any, covering the
-measurement and the desktop/window blend), `window_manager.rs`, `config.rs`,
+Ten modules carry unit tests — `window_manager.rs` (the most of any, among
+them the saved-state matchers: same-program borrowing, untitled entries),
+`backdrop.rs` (the measurement and the desktop/window blend), `config.rs`,
 `xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
-`text.rs`, `cursor.rs` (the swipe lean's direction, `swipe_lean`). They cluster where the logic is
+`text.rs`, `global_shortcuts.rs` (trigger parsing),
+`cursor.rs` (the swipe lean's direction, `swipe_lean`). They cluster where the logic is
 pure and the FFI is not, which is the only kind of thing testable in a crate
 this deep in wlroots. The arrange/slotmap tests live in the sibling
 `cce-window-manager` crate — run them with `cargo test -p cce-window-manager`.
@@ -262,6 +264,14 @@ own `target/`, invisible to ccebuild), built on demand by the drivers:
   position relative to its parent. `--menu-at`, `--menu`, `--sub-at` and
   `--sub` move and size them.
 
+- **`xembed-icon`** — a legacy X11 tray icon docked the way Wine's systray
+  docks one: waits for a `_NET_SYSTEM_TRAY_S0` owner, draws in the visual it
+  advertises, sends SYSTEM_TRAY_REQUEST_DOCK, paints one solid colour and
+  prints each milestone (docked, embedded, every button it receives).
+  `--recolor SECS COLOR` and `--exit-after SECS` exercise the icon updating
+  and leaving. Needs `cce-shadow start --xwayland`; see
+  `../cce-status-interface/CLAUDE.md` for the bridge it tests.
+
 `./verify/popup-constrain-test` drives `popup-nest` to prove submenus are
 fitted to the real screen (`xdg_popup.rs::handle_reposition`): a tall
 submenu low in the window slides up to fit, and one near the right edge
@@ -373,7 +383,12 @@ treats them as opaque.
   `input_relay.rs`/`input_popup.rs` (IME).
 - Shell/surface: `xdg_toplevel.rs`, `xdg_popup.rs`, `shell_surface.rs`,
   `layer_shell.rs`, `xwayland_window.rs`, `xwayland_override_redirect.rs`,
-  `drag_icon.rs`, `wm_node.rs`.
+  `drag_icon.rs`, `wm_node.rs`. An override-redirect window whose WM_CLASS
+  class is `cce-xembed-tray` gets no scene node at all
+  (`is_xembed_tray_container`): it is the tray bridge's container for a
+  legacy X11 tray icon, which X must have mapped for the icon to draw but
+  which is shown in the status bar instead (`cce-status-interface`'s
+  `cce-xembed-tray`).
 - Output: `output.rs`, `output_manager.rs`. Session: `lock_manager.rs`,
   `idle_inhibit_manager.rs`. Rendering: `scene.rs`, `scene_node_data.rs`.
 
diff --git a/src/server/xwayland_override_redirect.rs b/src/server/xwayland_override_redirect.rs
index 3b4072c9..36922443 100644
--- a/src/server/xwayland_override_redirect.rs
+++ b/src/server/xwayland_override_redirect.rs
@@ -226,7 +226,28 @@ unsafe extern "C" fn handle_map(listener: *mut ffi::wl_listener, _data: *mut std
     handle_map_impl(or);
 }
 
+/// WM_CLASS of the XEmbed tray bridge's container windows
+/// (`cce-status-interface`'s `cce-xembed-tray`).
+const XEMBED_TRAY_CLASS: &str = "cce-xembed-tray";
+
+/// Whether this override-redirect window is one of the tray bridge's
+/// containers. Each holds a legacy X11 tray icon the bridge adopted and
+/// republishes as a StatusNotifierItem; X needs it mapped for the icon to
+/// draw at all, but the icon is shown in the status bar, so the window
+/// itself must never be. The bridge also gives it an empty input region,
+/// so X never routes the pointer into it either.
+unsafe fn is_xembed_tray_container(xsurface: *mut ffi::wlr_xwayland_surface) -> bool {
+    let class = (*xsurface).class;
+    !class.is_null() && std::ffi::CStr::from_ptr(class).to_bytes() == XEMBED_TRAY_CLASS.as_bytes()
+}
+
 unsafe fn handle_map_impl(or: *mut XwaylandOverrideRedirect) {
+    // No scene node at all: nothing to draw, hit-test or focus. Unmap
+    // copes with the missing tree and the unconnected listeners.
+    if is_xembed_tray_container((*or).xsurface) {
+        log::debug!("xembed tray container mapped; not shown");
+        return;
+    }
     let surface = (*(*or).xsurface).surface;
     let override_redirect_tree = (*(*or).server).scene.layers.override_redirect;
 
diff --git a/verify/clients/Cargo.lock b/verify/clients/Cargo.lock
index 936e008c..31531104 100644
--- a/verify/clients/Cargo.lock
+++ b/verify/clients/Cargo.lock
@@ -26,6 +26,7 @@ dependencies = [
  "wayland-client",
  "wayland-protocols",
  "wayland-protocols-misc",
+ "x11rb",
  "xkbcommon",
 ]
 
@@ -51,6 +52,16 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
 
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link",
+]
+
 [[package]]
 name = "libc"
 version = "0.2.189"
@@ -227,6 +238,23 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
 [[package]]
 name = "xkbcommon"
 version = "0.8.0"
diff --git a/verify/clients/Cargo.toml b/verify/clients/Cargo.toml
index 03069f23..05bdcf40 100644
--- a/verify/clients/Cargo.toml
+++ b/verify/clients/Cargo.toml
@@ -20,12 +20,17 @@ path = "src/bin/float_pair.rs"
 name = "popup-nest"
 path = "src/bin/popup_nest.rs"
 
+[[bin]]
+name = "xembed-icon"
+path = "src/bin/xembed_icon.rs"
+
 [dependencies]
 wayland-client = "0.31"
 wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
 wayland-protocols-misc = { version = "0.3", features = ["client"] }
 xkbcommon = "0.8"
 libc = "0.2"
+x11rb = "0.13"
 
 # Deliberately NOT a member of the cce workspace: these are test clients built
 # on demand by verify/ scripts, invisible to ccebuild and `cargo build` at the
diff --git a/verify/clients/src/bin/xembed_icon.rs b/verify/clients/src/bin/xembed_icon.rs
new file mode 100644
index 00000000..5ced4faf
--- /dev/null
+++ b/verify/clients/src/bin/xembed_icon.rs
@@ -0,0 +1,180 @@
+// xembed-icon — a legacy X11 tray icon, the way Wine's systray docks one.
+//
+// Waits for an owner of `_NET_SYSTEM_TRAY_S0`, creates a window in the
+// visual the tray advertises (`_NET_SYSTEM_TRAY_VISUAL`, ARGB when there is
+// one), sets `_XEMBED_INFO` mapped, and sends SYSTEM_TRAY_REQUEST_DOCK. It
+// paints itself one solid colour on every Expose. One milestone per line:
+//
+//   tray owner 0xWIN
+//   window 0xWIN depth D
+//   docked into 0xPARENT          (ReparentNotify away from the root)
+//   embedded                      (XEMBED_EMBEDDED_NOTIFY)
+//   button N press|release x,y root rx,ry
+//   recolored 0xAARRGGBB
+//   undocked                      (reparented back to the root)
+//
+// Args: --color 0xAARRGGBB (premultiplied pixel, default opaque red)
+//       --recolor SECS 0xAARRGGBB   repaint in a second colour later
+//       --exit-after SECS           destroy the window and exit
+//       --size N                    window size, default 32
+
+use std::time::{Duration, Instant};
+
+use x11rb::connection::Connection;
+use x11rb::protocol::xproto::*;
+use x11rb::protocol::Event;
+use x11rb::wrapper::ConnectionExt as _;
+use x11rb::{CURRENT_TIME, NONE};
+
+fn parse_hex(s: &str) -> u32 {
+    u32::from_str_radix(s.trim_start_matches("0x"), 16).expect("hex colour")
+}
+
+fn main() {
+    let mut color = 0xffd0_2020u32;
+    let mut recolor: Option<(f64, u32)> = None;
+    let mut exit_after: Option<f64> = None;
+    let mut size: u16 = 32;
+    let mut args = std::env::args().skip(1);
+    while let Some(a) = args.next() {
+        match a.as_str() {
+            "--color" => color = parse_hex(&args.next().unwrap()),
+            "--recolor" => {
+                let secs = args.next().unwrap().parse().unwrap();
+                recolor = Some((secs, parse_hex(&args.next().unwrap())));
+            }
+            "--exit-after" => exit_after = Some(args.next().unwrap().parse().unwrap()),
+            "--size" => size = args.next().unwrap().parse().unwrap(),
+            other => panic!("unknown arg {other}"),
+        }
+    }
+
+    let (conn, screen_num) = x11rb::connect(None).expect("X connection");
+    let screen = conn.setup().roots[screen_num].clone();
+    let atom = |name: &str| conn.intern_atom(false, name.as_bytes()).unwrap().reply().unwrap().atom;
+    let selection = atom(&format!("_NET_SYSTEM_TRAY_S{screen_num}"));
+    let opcode = atom("_NET_SYSTEM_TRAY_OPCODE");
+    let tray_visual = atom("_NET_SYSTEM_TRAY_VISUAL");
+    let xembed = atom("_XEMBED");
+    let xembed_info = atom("_XEMBED_INFO");
+
+    let deadline = Instant::now() + Duration::from_secs(20);
+    let owner = loop {
+        let owner = conn.get_selection_owner(selection).unwrap().reply().unwrap().owner;
+        if owner != NONE {
+            break owner;
+        }
+        assert!(Instant::now() < deadline, "no tray appeared");
+        std::thread::sleep(Duration::from_millis(200));
+    };
+    println!("tray owner {owner:#x}");
+
+    // The tray's visual when it names one (and it is on this screen).
+    let advertised = conn
+        .get_property(false, owner, tray_visual, AtomEnum::VISUALID, 0, 1)
+        .unwrap()
+        .reply()
+        .ok()
+        .and_then(|r| r.value32().and_then(|mut v| v.next()));
+    let (depth, visual) = advertised
+        .and_then(|vid| {
+            screen.allowed_depths.iter().find_map(|d| {
+                d.visuals.iter().any(|v| v.visual_id == vid).then_some((d.depth, vid))
+            })
+        })
+        .unwrap_or((screen.root_depth, screen.root_visual));
+    let colormap = conn.generate_id().unwrap();
+    conn.create_colormap(ColormapAlloc::NONE, colormap, screen.root, visual).unwrap();
+
+    let win = conn.generate_id().unwrap();
+    conn.create_window(
+        depth,
+        win,
+        screen.root,
+        0,
+        0,
+        size,
+        size,
+        0,
+        WindowClass::INPUT_OUTPUT,
+        visual,
+        &CreateWindowAux::new()
+            .background_pixel(0)
+            .border_pixel(0)
+            .colormap(colormap)
+            .event_mask(
+                EventMask::EXPOSURE
+                    | EventMask::BUTTON_PRESS
+                    | EventMask::BUTTON_RELEASE
+                    | EventMask::STRUCTURE_NOTIFY,
+            ),
+    )
+    .unwrap();
+    conn.change_property8(PropMode::REPLACE, win, AtomEnum::WM_NAME, AtomEnum::STRING, b"xembed-icon test")
+        .unwrap();
+    conn.change_property8(
+        PropMode::REPLACE,
+        win,
+        AtomEnum::WM_CLASS,
+        AtomEnum::STRING,
+        b"xembed-icon\0XembedIcon\0",
+    )
+    .unwrap();
+    conn.change_property32(PropMode::REPLACE, win, xembed_info, xembed_info, &[0, 1]).unwrap();
+    let gc = conn.generate_id().unwrap();
+    conn.create_gc(gc, win, &CreateGCAux::new().foreground(color)).unwrap();
+    println!("window {win:#x} depth {depth}");
+
+    let dock = ClientMessageEvent::new(32, owner, opcode, [CURRENT_TIME, 0, win, 0, 0]);
+    conn.send_event(false, owner, EventMask::NO_EVENT, dock).unwrap();
+    conn.flush().unwrap();
+
+    let start = Instant::now();
+    let paint = |c: u32| {
+        conn.change_gc(gc, &ChangeGCAux::new().foreground(c)).unwrap();
+        conn.poly_fill_rectangle(win, gc, &[Rectangle { x: 0, y: 0, width: size, height: size }]).unwrap();
+        conn.flush().unwrap();
+    };
+    loop {
+        let elapsed = start.elapsed().as_secs_f64();
+        if let Some((at, c)) = recolor {
+            if elapsed >= at {
+                color = c;
+                paint(color);
+                println!("recolored {color:#010x}");
+                recolor = None;
+            }
+        }
+        if exit_after.is_some_and(|at| elapsed >= at) {
+            conn.destroy_window(win).unwrap();
+            conn.flush().unwrap();
+            println!("exiting");
+            return;
+        }
+        while let Some(event) = conn.poll_for_event().unwrap() {
+            match event {
+                Event::Expose(e) if e.count == 0 => paint(color),
+                Event::ReparentNotify(e) if e.window == win => {
+                    if e.parent == screen.root {
+                        println!("undocked");
+                    } else {
+                        println!("docked into {:#x}", e.parent);
+                    }
+                }
+                Event::ClientMessage(e) if e.type_ == xembed => {
+                    if e.data.as_data32()[1] == 0 {
+                        println!("embedded");
+                    }
+                }
+                Event::ButtonPress(e) => {
+                    println!("button {} press {},{} root {},{}", e.detail, e.event_x, e.event_y, e.root_x, e.root_y)
+                }
+                Event::ButtonRelease(e) => {
+                    println!("button {} release {},{} root {},{}", e.detail, e.event_x, e.event_y, e.root_x, e.root_y)
+                }
+                _ => {}
+            }
+        }
+        std::thread::sleep(Duration::from_millis(20));
+    }
+}