status bar
git clone https://git.lucas.co/cce-status-interface.git
feat: bridge the XEmbed system tray into the bar (cce-xembed-tray)
X11 apps older than StatusNotifierItem -- Wine and Proton programs above
all -- dock tray icons with whichever X client owns _NET_SYSTEM_TRAY_S0.
With no owner Wine shows a fallback window holding the icons, which sat
on the desk as a blank white window beside Ubisoft Connect.
cce-xembed-tray (src/bin/cce-xembed-tray, cce-xembed-tray.service) owns
that selection and, per docked icon: reparents it into an override-
redirect container the compositor never shows (WM_CLASS
cce-xembed-tray), with an empty input region so it can never swallow a
real X window's clicks; reads its pixels on every X Damage report, in
the ARGB visual it advertises, into IconPixmap; and forwards Activate,
ContextMenu and Scroll as button events, so the app draws its own menu.
Each item is its own bus connection registering by path, so the
watcher's unique-name tracking removes it when the icon goes. The
bridge re-registers when the watcher (re)appears, waits out another
tray rather than displacing it, and hands icons back to the root window
on SIGTERM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 41 ++-
Cargo.lock | 28 ++
Cargo.toml | 3 +
cce-xembed-tray.service | 16 ++
src/bin/cce-xembed-tray/main.rs | 75 ++++++
src/bin/cce-xembed-tray/sni.rs | 249 ++++++++++++++++++
src/bin/cce-xembed-tray/x11.rs | 563 ++++++++++++++++++++++++++++++++++++++++
7 files changed, 974 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 82eff1f..004c949 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,13 +19,52 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks).
```sh
cargo build --release # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test # 48 tests: main.rs (contrast, parsers), config.rs, tray.rs
+cargo test # 51 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs
make install # release build, then `ccebuild install --no-build cce-status-interface`
```
Running it requires a live cce compositor session (`$WAYLAND_DISPLAY` plus the cce
sockets); there is no meaningful headless mode.
+## `cce-xembed-tray` — legacy X11 tray icons in the bar
+
+A second binary (`src/bin/cce-xembed-tray/`, run by `cce-xembed-tray.service`)
+bridges the **XEmbed system tray** into the SNI tray `tray.rs` hosts. X11 apps
+older than StatusNotifierItem — Wine and Proton programs above all — dock their
+icons with whichever X client owns `_NET_SYSTEM_TRAY_S0`; with no owner, Wine
+shows a fallback window of its own holding the icons, which is how a blank white
+window came to sit beside Ubisoft Connect (2026-09-26). The bridge owns that
+selection and, per docked icon:
+
+- **reparents the icon window into a container** — an override-redirect window
+ with `WM_CLASS` `cce-xembed-tray`, which the compositor never shows
+ (`cce-compositor/src/server/xwayland_override_redirect.rs`,
+ `is_xembed_tray_container`; keep the class in step). X needs it mapped or
+ the icon never draws. It also gets an **empty input region**, so X never
+ routes the pointer into it: a hidden container stacked over a real X window
+ would otherwise swallow that window's clicks.
+- **reads its pixels** with `GetImage` whenever X Damage reports a redraw, in
+ the ARGB visual it advertises through `_NET_SYSTEM_TRAY_VISUAL` (so icons keep
+ their transparency), and publishes them as `IconPixmap` — skipped while the
+ icon is still fully transparent, so an undrawn icon is never an empty slot.
+- **forwards clicks** as `SendEvent` button presses to the icon: `Activate` is
+ button 1, `ContextMenu` button 3 (the app draws its own menu, which is why
+ there is deliberately no `Menu` property — its presence makes the bar fetch a
+ D-Bus menu instead), `Scroll` buttons 4-7. The root position in the event is
+ where the container sits, along the top-right of the X screen, so an app that
+ opens its menu at the cursor opens it near the tray.
+
+Each item is its own session-bus connection registering by object **path**, so
+the watcher records its unique name — the only kind of name whose disappearance
+`spawn_status_tray` notices. Closing the connection is the whole of
+unregistering. On SIGTERM the bridge hands every icon back to the root window,
+unmapped, which XEmbed clients read as "the tray is gone". Another tray already
+owning the selection is waited out, not displaced.
+
+Verify it in a shadow with `cce-shadow start --xwayland` and
+`cce-compositor/verify/clients`' `xembed-icon`, under `dbus-run-session` so the
+test bar and bridge never reach the live session's bus.
+
## Process model (the most important thing to know)
One binary, three modes, selected by CLI args in `main()`:
diff --git a/Cargo.lock b/Cargo.lock
index fc7050e..e8a9224 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -437,6 +437,7 @@ dependencies = [
"tokio",
"tokio-stream",
"wayland-client",
+ "x11rb",
"xkeysym",
"zbus 4.4.0",
]
@@ -965,6 +966,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -3066,6 +3077,23 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
[[package]]
name = "xcursor"
version = "0.3.10"
diff --git a/Cargo.toml b/Cargo.toml
index 71c9928..76767a6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,9 @@ env_logger = "0.11"
kdl = "4.6"
# Real dependency: PR_SET_PDEATHSIG on the `pactl subscribe` child.
libc = "0.2"
+# The cce-xembed-tray bridge (src/bin/cce-xembed-tray): the XEmbed system
+# tray selection, the icons' pixels (damage) and their input region (shape).
+x11rb = { version = "0.13", features = ["damage", "shape", "xfixes"] }
diff --git a/cce-xembed-tray.service b/cce-xembed-tray.service
new file mode 100644
index 0000000..d1a0b81
--- /dev/null
+++ b/cce-xembed-tray.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=CCE XEmbed tray bridge (legacy X11 tray icons into the status bar)
+After=graphical-session.target cce-status-interface.service
+PartOf=graphical-session.target
+
+[Service]
+Type=simple
+ExecStart=%h/.local/bin/cce-xembed-tray
+# Exit 0 means another X11 tray took the selection over; only a failure
+# (Xwayland gone with the compositor) is worth a restart.
+Restart=on-failure
+RestartSec=3
+Environment=RUST_LOG=info
+
+[Install]
+WantedBy=graphical-session.target
diff --git a/src/bin/cce-xembed-tray/main.rs b/src/bin/cce-xembed-tray/main.rs
new file mode 100644
index 0000000..7581cc6
--- /dev/null
+++ b/src/bin/cce-xembed-tray/main.rs
@@ -0,0 +1,75 @@
+//! `cce-xembed-tray` — the legacy X11 system tray, bridged into the bar.
+//!
+//! X11 apps that predate StatusNotifierItem (Wine and Proton programs above
+//! all) put their tray icons in an XEmbed tray: whichever X client owns the
+//! `_NET_SYSTEM_TRAY_S0` selection. With no owner, Wine falls back to a
+//! window of its own holding the icons, which on this desktop showed up as
+//! a blank white window beside Ubisoft Connect. This process owns that
+//! selection, adopts each icon window into a container the compositor never
+//! shows, and republishes it as an SNI item the status bar's tray hosts:
+//! pixels from the icon window as it redraws, clicks forwarded as X button
+//! events (left = Activate, right = ContextMenu, so the app draws its own
+//! menu).
+
+mod sni;
+mod x11;
+
+use std::sync::atomic::{AtomicI32, Ordering};
+use std::sync::Arc;
+
+fn connect() -> x11::Res<(Arc<x11rb::rust_connection::RustConnection>, usize)> {
+ // Xwayland comes up with the compositor, which may still be starting
+ // when the session's units are.
+ let mut last = None;
+ for attempt in 0..10 {
+ if attempt > 0 {
+ std::thread::sleep(std::time::Duration::from_secs(1));
+ }
+ match x11rb::connect(None) {
+ Ok((conn, screen)) => return Ok((Arc::new(conn), screen)),
+ Err(e) => last = Some(e),
+ }
+ }
+ Err(format!("cannot connect to X: {}", last.map(|e| e.to_string()).unwrap_or_default()).into())
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+ let (conn, screen) = match connect() {
+ Ok(c) => c,
+ Err(e) => {
+ log::error!("{e}");
+ std::process::exit(1);
+ }
+ };
+ let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut tray = match x11::Tray::new(conn, screen, tx) {
+ Ok(t) => t,
+ Err(e) => {
+ log::error!("X setup failed: {e}");
+ std::process::exit(1);
+ }
+ };
+ let handle = Arc::new(tray.handle());
+
+ // 0 once another tray took over cleanly; 1 when X failed, so systemd
+ // restarts us.
+ let code = Arc::new(AtomicI32::new(0));
+ let x_code = code.clone();
+ std::thread::spawn(move || {
+ let result = tray.acquire().and_then(|()| tray.run());
+ if let Err(e) = result {
+ log::error!("X side stopped: {e}");
+ x_code.store(1, Ordering::SeqCst);
+ }
+ // Dropping `tray` closes the channel, which ends `sni::run`.
+ });
+
+ if let Err(e) = sni::run(handle, rx).await {
+ log::error!("D-Bus side failed: {e}");
+ code.store(1, Ordering::SeqCst);
+ }
+ std::process::exit(code.load(Ordering::SeqCst));
+}
diff --git a/src/bin/cce-xembed-tray/sni.rs b/src/bin/cce-xembed-tray/sni.rs
new file mode 100644
index 0000000..66d80bd
--- /dev/null
+++ b/src/bin/cce-xembed-tray/sni.rs
@@ -0,0 +1,249 @@
+//! The D-Bus half: one StatusNotifierItem per docked icon.
+//!
+//! Each item gets its own session-bus connection and registers by object
+//! PATH, so the watcher records the connection's unique name as the item's
+//! address. That is what the status bar's watcher drops items on
+//! (`NameOwnerChanged` for a vanishing unique name), so closing an item's
+//! connection is the whole of unregistering it.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use tokio::sync::mpsc::UnboundedReceiver;
+use tokio_stream::StreamExt;
+
+use crate::x11::{ClickPoint, SniImage, TrayEvent, XHandle};
+
+const WATCHER: &str = "org.kde.StatusNotifierWatcher";
+const ITEM_PATH: &str = "/StatusNotifierItem";
+
+struct Item {
+ icon: u32,
+ title: String,
+ image: SniImage,
+ at: ClickPoint,
+ x: Arc<XHandle>,
+}
+
+impl Item {
+ fn click(&self, button: u8) {
+ if let Err(e) = self.x.click(self.icon, button, self.at) {
+ log::warn!("click on {:#x} failed: {e}", self.icon);
+ }
+ }
+}
+
+#[zbus::interface(name = "org.kde.StatusNotifierItem")]
+impl Item {
+ #[zbus(property)]
+ fn category(&self) -> &str {
+ "ApplicationStatus"
+ }
+
+ #[zbus(property)]
+ fn id(&self) -> String {
+ format!("xembed-{:x}", self.icon)
+ }
+
+ #[zbus(property)]
+ fn title(&self) -> &str {
+ &self.title
+ }
+
+ #[zbus(property)]
+ fn status(&self) -> &str {
+ "Active"
+ }
+
+ #[zbus(property)]
+ fn window_id(&self) -> u32 {
+ 0
+ }
+
+ #[zbus(property)]
+ fn icon_name(&self) -> &str {
+ ""
+ }
+
+ #[zbus(property)]
+ fn icon_pixmap(&self) -> Vec<SniImage> {
+ vec![self.image.clone()]
+ }
+
+ #[zbus(property)]
+ fn tool_tip(&self) -> (String, Vec<SniImage>, String, String) {
+ (String::new(), Vec::new(), self.title.clone(), String::new())
+ }
+
+ /// Always false, and there is deliberately no `Menu` property: an
+ /// XEmbed icon draws its own menu, which a right-click (ContextMenu)
+ /// reaches. The bar fetches a D-Bus menu instead whenever `Menu` exists.
+ #[zbus(property)]
+ fn item_is_menu(&self) -> bool {
+ false
+ }
+
+ fn activate(&self, _x: i32, _y: i32) {
+ self.click(1);
+ }
+
+ fn secondary_activate(&self, _x: i32, _y: i32) {
+ self.click(2);
+ }
+
+ fn context_menu(&self, _x: i32, _y: i32) {
+ self.click(3);
+ }
+
+ fn scroll(&self, delta: i32, orientation: &str) {
+ let horizontal = orientation.eq_ignore_ascii_case("horizontal");
+ let button = match (horizontal, delta > 0) {
+ (false, true) => 5,
+ (false, false) => 4,
+ (true, true) => 7,
+ (true, false) => 6,
+ };
+ self.click(button);
+ }
+
+ #[zbus(signal)]
+ async fn new_icon(ctxt: &zbus::SignalContext<'_>) -> zbus::Result<()>;
+
+ #[zbus(signal)]
+ async fn new_title(ctxt: &zbus::SignalContext<'_>) -> zbus::Result<()>;
+
+ #[zbus(signal)]
+ async fn new_tool_tip(ctxt: &zbus::SignalContext<'_>) -> zbus::Result<()>;
+}
+
+/// An icon the X side reported. It is published once it has pixels: an
+/// icon that has not drawn yet would be an empty slot in the bar.
+enum Entry {
+ Pending { title: String, at: ClickPoint },
+ Published(zbus::Connection),
+}
+
+async fn register(conn: &zbus::Connection) {
+ let reply = conn
+ .call_method(Some(WATCHER), "/StatusNotifierWatcher", Some(WATCHER), "RegisterStatusNotifierItem", &(ITEM_PATH,))
+ .await;
+ if let Err(e) = reply {
+ // No watcher yet: the NameOwnerChanged arm registers everything
+ // once one appears.
+ log::debug!("register with {WATCHER} failed: {e}");
+ }
+}
+
+async fn publish(x: &Arc<XHandle>, icon: u32, title: String, image: SniImage, at: ClickPoint) -> zbus::Result<zbus::Connection> {
+ let item = Item { icon, title, image, at, x: x.clone() };
+ let conn = zbus::ConnectionBuilder::session()?.serve_at(ITEM_PATH, item)?.build().await?;
+ register(&conn).await;
+ log::info!("published {icon:#x} as {}", conn.unique_name().map(|n| n.to_string()).unwrap_or_default());
+ Ok(conn)
+}
+
+async fn update(conn: &zbus::Connection, image: Option<SniImage>, title: Option<String>) -> zbus::Result<()> {
+ let iface = conn.object_server().interface::<_, Item>(ITEM_PATH).await?;
+ {
+ let mut item = iface.get_mut().await;
+ if let Some(image) = image.clone() {
+ item.image = image;
+ }
+ if let Some(title) = title.clone() {
+ item.title = title;
+ }
+ }
+ let ctxt = iface.signal_context();
+ if image.is_some() {
+ Item::new_icon(ctxt).await?;
+ }
+ if title.is_some() {
+ Item::new_title(ctxt).await?;
+ Item::new_tool_tip(ctxt).await?;
+ }
+ Ok(())
+}
+
+/// Mirror the X side's icons onto the bus until it stops (its channel
+/// closes) or the process is told to exit; then hand the icons back.
+pub async fn run(x: Arc<XHandle>, mut events: UnboundedReceiver<TrayEvent>) -> zbus::Result<()> {
+ let control = zbus::Connection::session().await?;
+ let dbus = zbus::fdo::DBusProxy::new(&control).await?;
+ let mut watcher_owner = dbus.receive_name_owner_changed_with_args(&[(0, WATCHER)]).await?;
+ let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+ .map_err(|e| zbus::Error::Failure(e.to_string()))?;
+ let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
+ .map_err(|e| zbus::Error::Failure(e.to_string()))?;
+
+ let mut entries: HashMap<u32, Entry> = HashMap::new();
+ loop {
+ tokio::select! {
+ event = events.recv() => {
+ let Some(event) = event else { break };
+ match event {
+ TrayEvent::Docked { icon, title, image, at } => {
+ let entry = match image {
+ Some(image) => match publish(&x, icon, title.clone(), image, at).await {
+ Ok(conn) => Entry::Published(conn),
+ Err(e) => {
+ log::warn!("publishing {icon:#x} failed: {e}");
+ Entry::Pending { title, at }
+ }
+ },
+ None => Entry::Pending { title, at },
+ };
+ entries.insert(icon, entry);
+ }
+ TrayEvent::Image { icon, image } => match entries.remove(&icon) {
+ Some(Entry::Pending { title, at }) => {
+ let entry = match publish(&x, icon, title.clone(), image, at).await {
+ Ok(conn) => Entry::Published(conn),
+ Err(e) => {
+ log::warn!("publishing {icon:#x} failed: {e}");
+ Entry::Pending { title, at }
+ }
+ };
+ entries.insert(icon, entry);
+ }
+ Some(Entry::Published(conn)) => {
+ if let Err(e) = update(&conn, Some(image), None).await {
+ log::warn!("icon update for {icon:#x} failed: {e}");
+ }
+ entries.insert(icon, Entry::Published(conn));
+ }
+ None => {}
+ },
+ TrayEvent::Title { icon, title } => match entries.get_mut(&icon) {
+ Some(Entry::Pending { title: t, .. }) => *t = title,
+ Some(Entry::Published(conn)) => {
+ if let Err(e) = update(conn, None, Some(title)).await {
+ log::warn!("title update for {icon:#x} failed: {e}");
+ }
+ }
+ None => {}
+ },
+ // Dropping the connection releases its unique name,
+ // which is what removes the item from the bar.
+ TrayEvent::Undocked { icon } => {
+ entries.remove(&icon);
+ }
+ }
+ }
+ Some(change) = watcher_owner.next() => {
+ // A (re)started status bar: register every item with it.
+ let appeared = change.args().map(|a| a.new_owner.is_some()).unwrap_or(false);
+ if appeared {
+ for entry in entries.values() {
+ if let Entry::Published(conn) = entry {
+ register(conn).await;
+ }
+ }
+ }
+ }
+ _ = sigterm.recv() => break,
+ _ = sigint.recv() => break,
+ }
+ }
+ x.release(entries.keys().copied());
+ Ok(())
+}
diff --git a/src/bin/cce-xembed-tray/x11.rs b/src/bin/cce-xembed-tray/x11.rs
new file mode 100644
index 0000000..82438e5
--- /dev/null
+++ b/src/bin/cce-xembed-tray/x11.rs
@@ -0,0 +1,563 @@
+//! The X11 half: own the XEmbed system tray selection, adopt each docked
+//! icon into a hidden container, and report its pixels as they change.
+//!
+//! Everything here runs on one blocking thread that reads X events. The
+//! D-Bus side only ever *sends* requests (clicks, the release on exit)
+//! through an [`XHandle`]; `RustConnection` is thread-safe for that.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use tokio::sync::mpsc::UnboundedSender;
+use x11rb::connection::Connection;
+use x11rb::protocol::damage::{self, ConnectionExt as _};
+use x11rb::protocol::shape::{self, ConnectionExt as _};
+use x11rb::protocol::xfixes::ConnectionExt as _;
+use x11rb::protocol::xproto::*;
+use x11rb::protocol::Event;
+use x11rb::rust_connection::RustConnection;
+use x11rb::wrapper::ConnectionExt as _;
+use x11rb::{COPY_DEPTH_FROM_PARENT, COPY_FROM_PARENT, CURRENT_TIME, NONE};
+
+pub type Res<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
+
+/// One SNI `IconPixmap` entry: width, height, ARGB32 in network byte order
+/// with straight (not premultiplied) alpha.
+pub type SniImage = (i32, i32, Vec<u8>);
+
+x11rb::atom_manager! {
+ pub Atoms: AtomsCookie {
+ MANAGER,
+ _NET_SYSTEM_TRAY_OPCODE,
+ _NET_SYSTEM_TRAY_ORIENTATION,
+ _NET_SYSTEM_TRAY_VISUAL,
+ _XEMBED,
+ _XEMBED_INFO,
+ _NET_WM_NAME,
+ UTF8_STRING,
+ _CCE_XEMBED_TRAY_TIME,
+ }
+}
+
+const SYSTEM_TRAY_REQUEST_DOCK: u32 = 0;
+const XEMBED_EMBEDDED_NOTIFY: u32 = 0;
+const XEMBED_MAPPED: u32 = 1;
+
+/// WM_CLASS (instance, class) of every container. The compositor matches
+/// the class and never shows these windows (`xwayland_override_redirect.rs`,
+/// `is_xembed_tray_container`) — keep the two in step.
+const CONTAINER_CLASS: &[u8] = b"cce-xembed-tray\0cce-xembed-tray\0";
+
+/// Icon windows are kept square, at their own size clamped to this range.
+const MIN_ICON: u16 = 16;
+const MAX_ICON: u16 = 64;
+
+pub enum TrayEvent {
+ /// A new icon. `image` is `None` until it has drawn something.
+ Docked { icon: Window, title: String, image: Option<SniImage>, at: ClickPoint },
+ Image { icon: Window, image: SniImage },
+ Title { icon: Window, title: String },
+ Undocked { icon: Window },
+}
+
+/// Where a forwarded click lands: in root coordinates (what the app reads
+/// back as the cursor position, so where its menu opens) and in the icon's.
+#[derive(Clone, Copy, Debug)]
+pub struct ClickPoint {
+ pub root: (i16, i16),
+ pub local: (i16, i16),
+}
+
+struct Icon {
+ container: Window,
+ damage: damage::Damage,
+ slot: u16,
+ size: u16,
+ last: Option<Vec<u8>>,
+}
+
+/// The request-only view of the connection the D-Bus side holds.
+pub struct XHandle {
+ conn: Arc<RustConnection>,
+ root: Window,
+}
+
+impl XHandle {
+ /// Press and release `button` on `icon`. Sent with an empty event mask,
+ /// which delivers it to the client that created the window — the app
+ /// that docked the icon — whatever it selected.
+ pub fn click(&self, icon: Window, button: u8, at: ClickPoint) -> Res<()> {
+ let mask = 1u16 << (7 + button.min(5));
+ for (kind, state) in [(BUTTON_PRESS_EVENT, 0u16), (BUTTON_RELEASE_EVENT, mask)] {
+ let ev = ButtonPressEvent {
+ response_type: kind,
+ detail: button,
+ sequence: 0,
+ time: CURRENT_TIME,
+ root: self.root,
+ event: icon,
+ child: NONE,
+ root_x: at.root.0,
+ root_y: at.root.1,
+ event_x: at.local.0,
+ event_y: at.local.1,
+ state: KeyButMask::from(state),
+ same_screen: true,
+ };
+ self.conn.send_event(false, icon, EventMask::NO_EVENT, ev)?;
+ }
+ self.conn.flush()?;
+ Ok(())
+ }
+
+ /// Hand every icon back on the way out: unmapped, on the root window,
+ /// which the XEmbed client reads as "the tray is gone" and so goes
+ /// looking for the next one (or its own fallback).
+ pub fn release(&self, icons: impl IntoIterator<Item = Window>) {
+ for icon in icons {
+ let _ = self.conn.unmap_window(icon);
+ let _ = self.conn.reparent_window(icon, self.root, 0, 0);
+ }
+ let _ = self.conn.flush();
+ }
+}
+
+pub struct Tray {
+ conn: Arc<RustConnection>,
+ root: Window,
+ root_width: u16,
+ atoms: Atoms,
+ selection: Atom,
+ manager: Window,
+ depth: u8,
+ visual: Visualid,
+ colormap: Colormap,
+ icons: HashMap<Window, Icon>,
+ tx: UnboundedSender<TrayEvent>,
+}
+
+impl Tray {
+ pub fn new(conn: Arc<RustConnection>, screen_num: usize, tx: UnboundedSender<TrayEvent>) -> Res<Self> {
+ let screen = conn.setup().roots[screen_num].clone();
+ let atoms = Atoms::new(&*conn)?.reply()?;
+ let selection = conn
+ .intern_atom(false, format!("_NET_SYSTEM_TRAY_S{screen_num}").as_bytes())?
+ .reply()?
+ .atom;
+ // Damage needs both versions negotiated before its first request.
+ conn.xfixes_query_version(5, 0)?.reply()?;
+ conn.damage_query_version(1, 1)?.reply()?;
+
+ // An ARGB visual, advertised through _NET_SYSTEM_TRAY_VISUAL, lets
+ // an icon draw with real transparency instead of over a solid box.
+ let (depth, visual) = screen
+ .allowed_depths
+ .iter()
+ .filter(|d| d.depth == 32)
+ .flat_map(|d| {
+ d.visuals
+ .iter()
+ .filter(|v| v.class == VisualClass::TRUE_COLOR)
+ .map(move |v| (d.depth, v.visual_id))
+ })
+ .next()
+ .unwrap_or((screen.root_depth, screen.root_visual));
+ let colormap = conn.generate_id()?;
+ conn.create_colormap(ColormapAlloc::NONE, colormap, screen.root, visual)?;
+
+ let manager = conn.generate_id()?;
+ conn.create_window(
+ COPY_DEPTH_FROM_PARENT,
+ manager,
+ screen.root,
+ -1,
+ -1,
+ 1,
+ 1,
+ 0,
+ WindowClass::INPUT_ONLY,
+ COPY_FROM_PARENT,
+ &CreateWindowAux::new().override_redirect(1).event_mask(EventMask::PROPERTY_CHANGE),
+ )?;
+ conn.change_property32(
+ PropMode::REPLACE,
+ manager,
+ atoms._NET_SYSTEM_TRAY_ORIENTATION,
+ AtomEnum::CARDINAL,
+ &[0], // horizontal
+ )?;
+ conn.change_property32(
+ PropMode::REPLACE,
+ manager,
+ atoms._NET_SYSTEM_TRAY_VISUAL,
+ AtomEnum::VISUALID,
+ &[visual],
+ )?;
+ conn.flush()?;
+ log::info!("tray visual {visual:#x} (depth {depth})");
+
+ Ok(Tray {
+ conn,
+ root: screen.root,
+ root_width: screen.width_in_pixels,
+ atoms,
+ selection,
+ manager,
+ depth,
+ visual,
+ colormap,
+ icons: HashMap::new(),
+ tx,
+ })
+ }
+
+ pub fn handle(&self) -> XHandle {
+ XHandle { conn: self.conn.clone(), root: self.root }
+ }
+
+ /// Take the tray selection and announce it. Another tray already
+ /// holding it is left alone: this waits for that owner to go away
+ /// rather than stealing it, so two trays never fight over the icons.
+ pub fn acquire(&mut self) -> Res<()> {
+ loop {
+ let owner = self.conn.get_selection_owner(self.selection)?.reply()?.owner;
+ if owner == NONE {
+ break;
+ }
+ log::info!("tray selection held by window {owner:#x}; waiting for it to be released");
+ let watch = ChangeWindowAttributesAux::new().event_mask(EventMask::STRUCTURE_NOTIFY);
+ if self.conn.change_window_attributes(owner, &watch)?.check().is_err() {
+ continue; // gone already
+ }
+ if self.conn.get_selection_owner(self.selection)?.reply()?.owner != owner {
+ continue;
+ }
+ loop {
+ if let Event::DestroyNotify(e) = self.conn.wait_for_event()? {
+ if e.window == owner {
+ break;
+ }
+ }
+ }
+ }
+ let time = self.server_time()?;
+ self.conn.set_selection_owner(self.manager, self.selection, time)?;
+ if self.conn.get_selection_owner(self.selection)?.reply()?.owner != self.manager {
+ return Err("lost the race for the tray selection".into());
+ }
+ let announce = ClientMessageEvent::new(
+ 32,
+ self.root,
+ self.atoms.MANAGER,
+ [time, self.selection, self.manager, 0, 0],
+ );
+ self.conn.send_event(false, self.root, EventMask::STRUCTURE_NOTIFY, announce)?;
+ self.conn.flush()?;
+ log::info!("own the XEmbed system tray selection");
+ Ok(())
+ }
+
+ /// A real server timestamp, which ICCCM asks selection owners to use:
+ /// touch a property on our own window and read the notify's time.
+ fn server_time(&self) -> Res<Timestamp> {
+ self.conn.change_property8(
+ PropMode::APPEND,
+ self.manager,
+ self.atoms._CCE_XEMBED_TRAY_TIME,
+ AtomEnum::STRING,
+ &[],
+ )?;
+ self.conn.flush()?;
+ loop {
+ if let Event::PropertyNotify(e) = self.conn.wait_for_event()? {
+ if e.window == self.manager {
+ return Ok(e.time);
+ }
+ }
+ }
+ }
+
+ /// Serve until the selection is taken by another tray (`Ok`) or the X
+ /// connection fails (`Err`).
+ pub fn run(&mut self) -> Res<()> {
+ loop {
+ let event = self.conn.wait_for_event()?;
+ match event {
+ Event::ClientMessage(e)
+ if e.window == self.manager && e.type_ == self.atoms._NET_SYSTEM_TRAY_OPCODE =>
+ {
+ let data = e.data.as_data32();
+ // BEGIN_MESSAGE / CANCEL_MESSAGE (balloons) are ignored.
+ if data[1] == SYSTEM_TRAY_REQUEST_DOCK {
+ if let Err(err) = self.dock(data[2]) {
+ log::warn!("dock of {:#x} failed: {err}", data[2]);
+ }
+ }
+ }
+ Event::SelectionClear(e) if e.selection == self.selection => {
+ log::info!("another tray took the selection; handing over");
+ return Ok(());
+ }
+ Event::DestroyNotify(e) => self.undock(e.window, true),
+ Event::ReparentNotify(e) => {
+ if self.icons.get(&e.window).is_some_and(|i| i.container != e.parent) {
+ self.undock(e.window, false);
+ }
+ }
+ Event::ConfigureNotify(e) => {
+ // The embedder decides an icon's size; an icon that
+ // resizes itself is put back.
+ if let Some(icon) = self.icons.get(&e.window) {
+ if e.width != icon.size || e.height != icon.size || e.x != 0 || e.y != 0 {
+ let size = u32::from(icon.size);
+ let aux = ConfigureWindowAux::new().x(0).y(0).width(size).height(size);
+ self.conn.configure_window(e.window, &aux)?;
+ }
+ }
+ }
+ Event::PropertyNotify(e) if self.icons.contains_key(&e.window) => {
+ if e.atom == self.atoms._XEMBED_INFO {
+ self.apply_xembed_info(e.window)?;
+ } else if e.atom == self.atoms._NET_WM_NAME || e.atom == u32::from(AtomEnum::WM_NAME) {
+ let title = self.title_of(e.window);
+ let _ = self.tx.send(TrayEvent::Title { icon: e.window, title });
+ }
+ }
+ Event::DamageNotify(e) => {
+ self.conn.damage_subtract(e.damage, NONE, NONE)?;
+ if let Some(image) = self.capture_changed(e.drawable) {
+ let _ = self.tx.send(TrayEvent::Image { icon: e.drawable, image });
+ }
+ }
+ // Requests on a window that vanished under us (an icon's
+ // app exiting mid-dock) fail harmlessly.
+ Event::Error(e) => log::debug!("X error: {e:?}"),
+ _ => {}
+ }
+ self.conn.flush()?;
+ }
+ }
+
+ fn dock(&mut self, icon: Window) -> Res<()> {
+ if icon == NONE || self.icons.contains_key(&icon) {
+ return Ok(());
+ }
+ let geom = self.conn.get_geometry(icon)?.reply()?;
+ let size = geom.width.max(geom.height).clamp(MIN_ICON, MAX_ICON);
+ let slot = (0u16..).find(|s| !self.icons.values().any(|i| i.slot == *s)).unwrap_or(0);
+
+ // Containers sit along the top-right of the X screen, one slot per
+ // icon. Nobody sees them — the compositor never draws them — but
+ // an app opens its menu at the click's root position, and the
+ // status bar's tray is up there.
+ let x = (i32::from(self.root_width) - i32::from(slot + 1) * i32::from(size)).max(0) as i16;
+ let container = self.conn.generate_id()?;
+ self.conn.create_window(
+ self.depth,
+ container,
+ self.root,
+ x,
+ 0,
+ size,
+ size,
+ 0,
+ WindowClass::INPUT_OUTPUT,
+ self.visual,
+ &CreateWindowAux::new()
+ .background_pixel(0)
+ .border_pixel(0)
+ .colormap(self.colormap)
+ .override_redirect(1),
+ )?;
+ self.conn.change_property8(
+ PropMode::REPLACE,
+ container,
+ AtomEnum::WM_CLASS,
+ AtomEnum::STRING,
+ CONTAINER_CLASS,
+ )?;
+ // An empty input region: X never routes the pointer into a
+ // container, so one can never swallow a click meant for a real X
+ // window it happens to sit above. Clicks reach icons only as the
+ // events `XHandle::click` sends.
+ self.conn.shape_rectangles(
+ shape::SO::SET,
+ shape::SK::INPUT,
+ ClipOrdering::UNSORTED,
+ container,
+ 0,
+ 0,
+ &[],
+ )?;
+
+ self.conn.change_window_attributes(
+ icon,
+ &ChangeWindowAttributesAux::new()
+ .event_mask(EventMask::STRUCTURE_NOTIFY | EventMask::PROPERTY_CHANGE),
+ )?;
+ // If this process dies, X hands the icon back to the root window
+ // instead of destroying it with the container.
+ self.conn.change_save_set(SetMode::INSERT, icon)?;
+ self.conn.reparent_window(icon, container, 0, 0)?;
+ let s = u32::from(size);
+ self.conn
+ .configure_window(icon, &ConfigureWindowAux::new().x(0).y(0).width(s).height(s))?;
+
+ let damage = self.conn.generate_id()?;
+ self.conn.damage_create(damage, icon, damage::ReportLevel::NON_EMPTY)?;
+ self.icons.insert(icon, Icon { container, damage, slot, size, last: None });
+
+ self.apply_xembed_info(icon)?;
+ self.conn
+ .configure_window(container, &ConfigureWindowAux::new().stack_mode(StackMode::BELOW))?;
+ self.conn.map_window(container)?;
+ let notify = ClientMessageEvent::new(
+ 32,
+ icon,
+ self.atoms._XEMBED,
+ [CURRENT_TIME, XEMBED_EMBEDDED_NOTIFY, 0, container, 0],
+ );
+ self.conn.send_event(false, icon, EventMask::NO_EVENT, notify)?;
+ self.conn.flush()?;
+
+ let title = self.title_of(icon);
+ let half = (size / 2) as i16;
+ let at = ClickPoint { root: (x + half, half), local: (half, half) };
+ let image = self.capture_changed(icon);
+ log::info!("docked {icon:#x} ({title:?}, {size}px, depth {}) in slot {slot}", geom.depth);
+ let _ = self.tx.send(TrayEvent::Docked { icon, title, image, at });
+ Ok(())
+ }
+
+ /// Map or unmap the icon as its `_XEMBED_INFO` flags say. An icon
+ /// without the property is mapped: that is what trays do in practice.
+ fn apply_xembed_info(&self, icon: Window) -> Res<()> {
+ let reply = self
+ .conn
+ .get_property(false, icon, self.atoms._XEMBED_INFO, AtomEnum::ANY, 0, 2)?
+ .reply()?;
+ let flags = reply.value32().and_then(|mut v| v.nth(1));
+ if flags.map_or(true, |f| f & XEMBED_MAPPED != 0) {
+ self.conn.map_window(icon)?;
+ } else {
+ self.conn.unmap_window(icon)?;
+ }
+ Ok(())
+ }
+
+ fn undock(&mut self, icon: Window, destroyed: bool) {
+ let Some(entry) = self.icons.remove(&icon) else {
+ return;
+ };
+ // A destroyed drawable takes its damage object with it.
+ if !destroyed {
+ let _ = self.conn.damage_destroy(entry.damage);
+ }
+ let _ = self.conn.destroy_window(entry.container);
+ let _ = self.conn.flush();
+ log::info!("undocked {icon:#x}");
+ let _ = self.tx.send(TrayEvent::Undocked { icon });
+ }
+
+ /// The icon's name for the tray: `_NET_WM_NAME`, then `WM_NAME`, then
+ /// its WM_CLASS class, since most icon windows are untitled.
+ fn title_of(&self, icon: Window) -> String {
+ let text = |atom: Atom, kind: Atom| -> Option<String> {
+ let reply = self.conn.get_property(false, icon, atom, kind, 0, 256).ok()?.reply().ok()?;
+ let s = String::from_utf8_lossy(&reply.value).trim_end_matches('\0').trim().to_string();
+ (!s.is_empty()).then_some(s)
+ };
+ text(self.atoms._NET_WM_NAME, self.atoms.UTF8_STRING)
+ .or_else(|| text(AtomEnum::WM_NAME.into(), AtomEnum::ANY.into()))
+ .or_else(|| {
+ text(AtomEnum::WM_CLASS.into(), AtomEnum::STRING.into())
+ .and_then(|c| c.split('\0').nth(1).map(str::to_string))
+ .filter(|c| !c.is_empty())
+ })
+ .unwrap_or_else(|| "X11 tray icon".to_string())
+ }
+
+ /// Read the icon's pixels; `Some` only when they differ from what was
+ /// last reported and are not entirely transparent (not drawn yet).
+ fn capture_changed(&mut self, icon: Window) -> Option<SniImage> {
+ let size = self.icons.get(&icon)?.size;
+ let reply = match self
+ .conn
+ .get_image(ImageFormat::Z_PIXMAP, icon, 0, 0, size, size, !0)
+ .ok()?
+ .reply()
+ {
+ Ok(r) => r,
+ Err(e) => {
+ log::debug!("capture of {icon:#x} failed: {e}");
+ return None;
+ }
+ };
+ let lsb = self.conn.setup().image_byte_order == ImageOrder::LSB_FIRST;
+ let pixels = to_sni_argb(&reply.data, reply.depth, lsb)?;
+ if pixels.chunks_exact(4).all(|p| p[0] == 0) {
+ return None;
+ }
+ let entry = self.icons.get_mut(&icon)?;
+ if entry.last.as_ref() == Some(&pixels) {
+ return None;
+ }
+ entry.last = Some(pixels.clone());
+ Some((i32::from(size), i32::from(size), pixels))
+ }
+}
+
+/// Convert a 32-bits-per-pixel ZPixmap to SNI's ARGB32: network byte
+/// order, straight alpha. X's ARGB visuals are premultiplied; a depth-24
+/// image has no alpha and is opaque. `None` for any other layout.
+pub fn to_sni_argb(data: &[u8], depth: u8, lsb_first: bool) -> Option<Vec<u8>> {
+ if data.len() % 4 != 0 || !(depth == 24 || depth == 32) {
+ return None;
+ }
+ let mut out = Vec::with_capacity(data.len());
+ for px in data.chunks_exact(4) {
+ let bytes = [px[0], px[1], px[2], px[3]];
+ let v = if lsb_first { u32::from_le_bytes(bytes) } else { u32::from_be_bytes(bytes) };
+ let a = if depth == 32 { (v >> 24) as u8 } else { 255 };
+ let unpremultiply = |c: u32| -> u8 {
+ match a {
+ 0 => 0,
+ 255 => c as u8,
+ a => ((c * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8,
+ }
+ };
+ out.extend_from_slice(&[
+ a,
+ unpremultiply((v >> 16) & 0xff),
+ unpremultiply((v >> 8) & 0xff),
+ unpremultiply(v & 0xff),
+ ]);
+ }
+ Some(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::to_sni_argb;
+
+ #[test]
+ fn premultiplied_argb_becomes_straight_network_order() {
+ // 50% alpha, premultiplied red 0x80: straight red is 0xff.
+ let px = 0x8080_0000u32.to_le_bytes();
+ assert_eq!(to_sni_argb(&px, 32, true).unwrap(), vec![0x80, 0xff, 0x00, 0x00]);
+ assert_eq!(to_sni_argb(&0x8080_0000u32.to_be_bytes(), 32, false).unwrap(), vec![0x80, 0xff, 0, 0]);
+ }
+
+ #[test]
+ fn depth_24_is_opaque_and_transparent_stays_zero() {
+ let px = 0x0012_3456u32.to_le_bytes();
+ assert_eq!(to_sni_argb(&px, 24, true).unwrap(), vec![0xff, 0x12, 0x34, 0x56]);
+ assert_eq!(to_sni_argb(&[0, 0, 0, 0], 32, true).unwrap(), vec![0, 0, 0, 0]);
+ }
+
+ #[test]
+ fn other_layouts_are_refused() {
+ assert!(to_sni_argb(&[0, 0, 0], 32, true).is_none());
+ assert!(to_sni_argb(&[0, 0, 0, 0], 16, true).is_none());
+ }
+}