git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commit4d443695404588dcd78f290cb071d44b288be7ee
parent8ca5f70454
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 16:41
feat(xembed-tray): name tray icons after their app

Icon windows are untitled and their WM_CLASS names the toolkit, so every
Proton icon read "steam_proton". A Wine icon is not even the app's
window: Wine's tray lives in the prefix's explorer.exe, which creates
every program's icon windows. title.rs names an icon by the most common
title among the top-level windows of the programs sharing its
WINEPREFIX (drive-letter exes outside C:\windows\, so not explorer,
Proton's steam.exe shim or xalia), or of its own process for a native
app. Ubisoft Connect titles its windows "Ubisoft Connect" even while it
sits in the tray.

With no titled window yet the exe's stem stands in, and the lookup is
retried at 2, 5, 10 and 30 s through the event queue -- via a weak
sender, so the X side dropping its own still ends the D-Bus loop.

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

 CLAUDE.md                        |  12 ++-
 src/bin/cce-xembed-tray/main.rs  |   5 +-
 src/bin/cce-xembed-tray/sni.rs   |  46 ++++++++-
 src/bin/cce-xembed-tray/title.rs | 214 +++++++++++++++++++++++++++++++++++++++
 src/bin/cce-xembed-tray/x11.rs   |  49 +++++----
 5 files changed, 295 insertions(+), 31 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 004c949..763cabd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,7 +19,7 @@ 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                            # 51 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs
+cargo test                            # 54 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
 make install                          # release build, then `ccebuild install --no-build cce-status-interface`
 ```
 
@@ -53,6 +53,16 @@ selection and, per docked icon:
   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.
+- **names it after its app** (`title.rs`), since icon windows are untitled
+  and their WM_CLASS names the toolkit (`steam_proton` for every Proton
+  program). A Wine icon is not even the app's window: Wine's tray lives in the
+  prefix's `explorer.exe`, which creates every program's icon windows. So the
+  name is the most common title among the top-level windows of the programs
+  sharing the icon's `WINEPREFIX` — drive-letter exes outside `C:\windows\`,
+  which leaves out explorer, Proton's `steam.exe` shim and xalia — or of the
+  icon's own process for a native app. Ubisoft Connect titles its windows
+  "Ubisoft Connect" even while hidden in the tray. With no titled window yet,
+  the exe's stem stands in and the lookup is retried at 2, 5, 10 and 30 s.
 
 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
diff --git a/src/bin/cce-xembed-tray/main.rs b/src/bin/cce-xembed-tray/main.rs
index 7581cc6..249cebc 100644
--- a/src/bin/cce-xembed-tray/main.rs
+++ b/src/bin/cce-xembed-tray/main.rs
@@ -12,6 +12,7 @@
 //! menu).
 
 mod sni;
+mod title;
 mod x11;
 
 use std::sync::atomic::{AtomicI32, Ordering};
@@ -45,6 +46,8 @@ async fn main() {
         }
     };
     let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
+    // The D-Bus side's own way into the same queue: late title lookups.
+    let retry_tx = tx.downgrade();
     let mut tray = match x11::Tray::new(conn, screen, tx) {
         Ok(t) => t,
         Err(e) => {
@@ -67,7 +70,7 @@ async fn main() {
         // Dropping `tray` closes the channel, which ends `sni::run`.
     });
 
-    if let Err(e) = sni::run(handle, rx).await {
+    if let Err(e) = sni::run(handle, rx, retry_tx).await {
         log::error!("D-Bus side failed: {e}");
         code.store(1, Ordering::SeqCst);
     }
diff --git a/src/bin/cce-xembed-tray/sni.rs b/src/bin/cce-xembed-tray/sni.rs
index 66d80bd..d60bbc5 100644
--- a/src/bin/cce-xembed-tray/sni.rs
+++ b/src/bin/cce-xembed-tray/sni.rs
@@ -9,7 +9,7 @@
 use std::collections::HashMap;
 use std::sync::Arc;
 
-use tokio::sync::mpsc::UnboundedReceiver;
+use tokio::sync::mpsc::{UnboundedReceiver, WeakUnboundedSender};
 use tokio_stream::StreamExt;
 
 use crate::x11::{ClickPoint, SniImage, TrayEvent, XHandle};
@@ -164,9 +164,46 @@ async fn update(conn: &zbus::Connection, image: Option<SniImage>, title: Option<
     Ok(())
 }
 
+/// Delays after docking at which an icon whose name was only a fallback
+/// (no titled app window yet — the app is often still starting) is looked
+/// up again. Bounded: an app that never titles a window keeps the fallback.
+const TITLE_RETRIES_SECS: &[u64] = &[2, 5, 10, 30];
+
+/// Look the icon's name up again on the retry schedule, feeding a settled
+/// one back through the event queue as an ordinary title change.
+fn retry_title(x: Arc<XHandle>, icon: u32, fallback: String, events: WeakUnboundedSender<TrayEvent>) {
+    tokio::spawn(async move {
+        let mut waited = 0;
+        for &at in TITLE_RETRIES_SECS {
+            tokio::time::sleep(std::time::Duration::from_secs(at - waited)).await;
+            waited = at;
+            let x = x.clone();
+            let Ok(title) = tokio::task::spawn_blocking(move || x.title(icon)).await else {
+                return;
+            };
+            if title.settled {
+                if title.text != fallback {
+                    log::info!("named {icon:#x} {:?} after {at}s", title.text);
+                    if let Some(events) = events.upgrade() {
+                        let _ = events.send(TrayEvent::Title { icon, title: title.text });
+                    }
+                }
+                return;
+            }
+        }
+    });
+}
+
 /// 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<()> {
+/// `requeue` feeds the same channel, for the late title lookups. It is
+/// weak so that the X side dropping its sender still closes the channel:
+/// that is how this loop learns the X side has stopped.
+pub async fn run(
+    x: Arc<XHandle>,
+    mut events: UnboundedReceiver<TrayEvent>,
+    requeue: WeakUnboundedSender<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?;
@@ -181,7 +218,10 @@ pub async fn run(x: Arc<XHandle>, mut events: UnboundedReceiver<TrayEvent>) -> z
             event = events.recv() => {
                 let Some(event) = event else { break };
                 match event {
-                    TrayEvent::Docked { icon, title, image, at } => {
+                    TrayEvent::Docked { icon, title, title_settled, image, at } => {
+                        if !title_settled {
+                            retry_title(x.clone(), icon, title.clone(), requeue.clone());
+                        }
                         let entry = match image {
                             Some(image) => match publish(&x, icon, title.clone(), image, at).await {
                                 Ok(conn) => Entry::Published(conn),
diff --git a/src/bin/cce-xembed-tray/title.rs b/src/bin/cce-xembed-tray/title.rs
new file mode 100644
index 0000000..c5b4fc9
--- /dev/null
+++ b/src/bin/cce-xembed-tray/title.rs
@@ -0,0 +1,214 @@
+//! What to call a tray icon.
+//!
+//! Icon windows are almost always untitled, and their WM_CLASS names the
+//! toolkit rather than the app: every Proton program's is `steam_proton`.
+//! Worse, a Wine icon is not even the app's window — Wine's tray lives in
+//! the prefix's `explorer.exe`, which creates the icon windows for every
+//! program in that prefix. So the name comes from the APP's own top-level
+//! windows: for a Wine icon, those of the other programs sharing its
+//! `WINEPREFIX` (Wine's own `C:\windows\…` processes excluded); for a native
+//! icon, those of its own process. Ubisoft Connect titles its windows
+//! "Ubisoft Connect" even while it sits hidden in the tray.
+
+use std::collections::HashMap;
+
+use x11rb::connection::Connection;
+use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, Window};
+use x11rb::rust_connection::RustConnection;
+
+use crate::x11::Atoms;
+
+/// A resolved name, and whether it is final. A fallback (the process or
+/// class name) is not: the app may simply not have titled a window yet,
+/// so the caller asks again a little later.
+pub struct Title {
+    pub text: String,
+    pub settled: bool,
+}
+
+/// Wine windows that exist in every Wine process and name nothing.
+const PLUMBING_TITLES: &[&str] = &["Default IME", "MSCTFIME UI"];
+
+pub fn resolve(conn: &RustConnection, root: Window, atoms: &Atoms, icon: Window) -> Title {
+    if let Some(own) = window_title(conn, atoms, icon) {
+        return Title { text: own, settled: true };
+    }
+    let pid = cardinal(conn, icon, atoms._NET_WM_PID);
+    let app = pid.map(app_pids).unwrap_or_default();
+    if !app.is_empty() {
+        let names = toplevel_titles(conn, root, atoms, &app);
+        if let Some(name) = choose_title(&names) {
+            return Title { text: name, settled: true };
+        }
+    }
+    let fallback = app
+        .first()
+        .and_then(|&p| process_name(p))
+        .or_else(|| wm_class(conn, icon))
+        .unwrap_or_else(|| "X11 tray icon".to_string());
+    Title { text: fallback, settled: false }
+}
+
+fn text_property(conn: &RustConnection, window: Window, atom: u32, kind: u32) -> Option<String> {
+    let reply = conn.get_property(false, window, 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)
+}
+
+fn window_title(conn: &RustConnection, atoms: &Atoms, window: Window) -> Option<String> {
+    text_property(conn, window, atoms._NET_WM_NAME, atoms.UTF8_STRING)
+        .or_else(|| text_property(conn, window, AtomEnum::WM_NAME.into(), AtomEnum::ANY.into()))
+}
+
+fn wm_class(conn: &RustConnection, window: Window) -> Option<String> {
+    text_property(conn, window, AtomEnum::WM_CLASS.into(), AtomEnum::STRING.into())
+        .and_then(|c| c.split('\0').nth(1).map(str::to_string))
+        .filter(|c| !c.is_empty())
+}
+
+fn cardinal(conn: &RustConnection, window: Window, atom: u32) -> Option<u32> {
+    let reply = conn.get_property(false, window, atom, AtomEnum::CARDINAL, 0, 1).ok()?.reply().ok()?;
+    reply.value32().and_then(|mut v| v.next())
+}
+
+/// Titles of every top-level window owned by one of `pids`. The
+/// `_NET_WM_PID` lookups are pipelined: one round trip for all of them.
+fn toplevel_titles(conn: &RustConnection, root: Window, atoms: &Atoms, pids: &[u32]) -> Vec<String> {
+    let Some(tree) = conn.query_tree(root).ok().and_then(|c| c.reply().ok()) else {
+        return Vec::new();
+    };
+    let cookies: Vec<_> = tree
+        .children
+        .iter()
+        .map(|&w| (w, conn.get_property(false, w, atoms._NET_WM_PID, AtomEnum::CARDINAL, 0, 1)))
+        .collect();
+    let mut names = Vec::new();
+    for (w, cookie) in cookies {
+        let owner = cookie
+            .ok()
+            .and_then(|c| c.reply().ok())
+            .and_then(|r| r.value32().and_then(|mut v| v.next()));
+        if owner.is_some_and(|p| pids.contains(&p)) {
+            names.extend(window_title(conn, atoms, w));
+        }
+    }
+    let _ = conn.flush();
+    names
+}
+
+/// The name an app's windows agree on: the most common title, the shorter
+/// on a tie ("Ubisoft Connect" over "Ubisoft Connect Notification").
+pub fn choose_title(names: &[String]) -> Option<String> {
+    let mut counts: HashMap<&str, usize> = HashMap::new();
+    for n in names {
+        let n = n.trim();
+        if !n.is_empty() && !PLUMBING_TITLES.contains(&n) {
+            *counts.entry(n).or_default() += 1;
+        }
+    }
+    counts
+        .into_iter()
+        .max_by(|(a, ca), (b, cb)| ca.cmp(cb).then(b.len().cmp(&a.len())).then(b.cmp(a)))
+        .map(|(n, _)| n.to_string())
+}
+
+/// The processes that are "the app" for an icon window owned by `pid`.
+fn app_pids(pid: u32) -> Vec<u32> {
+    let Some(prefix) = environ_var(pid, "WINEPREFIX") else {
+        return vec![pid];
+    };
+    let Ok(dir) = std::fs::read_dir("/proc") else {
+        return Vec::new();
+    };
+    let mut pids: Vec<u32> = dir
+        .flatten()
+        .filter_map(|e| e.file_name().to_str()?.parse::<u32>().ok())
+        .filter(|&p| environ_var(p, "WINEPREFIX").as_deref() == Some(prefix.as_str()))
+        .filter(|&p| argv0(p).is_some_and(|a| is_wine_program(&a)))
+        .collect();
+    pids.sort_unstable();
+    pids
+}
+
+/// A Windows program run by Wine that is not part of Wine itself: a
+/// drive-letter path outside `C:\windows\`. Excludes the prefix's plumbing
+/// (explorer, services, winedevice, Proton's `steam.exe` shim, xalia) and
+/// every Unix process that merely carries the prefix in its environment
+/// (wineserver, Proton's python, pressure-vessel).
+pub fn is_wine_program(argv0: &str) -> bool {
+    let a = argv0.trim().replace('/', "\\").to_ascii_lowercase();
+    let a = a.strip_prefix("\\\\?\\").unwrap_or(&a);
+    let b = a.as_bytes();
+    let drive_path = b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\';
+    drive_path && !a[1..].starts_with(":\\windows\\") && !a.contains("\\xalia\\")
+}
+
+/// A process's name for the fallback: a Windows exe's file stem, or the
+/// kernel's `comm` for anything else.
+fn process_name(pid: u32) -> Option<String> {
+    if let Some(a) = argv0(pid).filter(|a| is_wine_program(a)) {
+        let file = a.trim().rsplit(['\\', '/']).next()?.to_string();
+        let stem = file.rsplit_once('.').map_or(file.as_str(), |(s, _)| s).to_string();
+        return (!stem.is_empty()).then_some(stem);
+    }
+    let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
+    let comm = comm.trim();
+    (!comm.is_empty()).then(|| comm.to_string())
+}
+
+fn argv0(pid: u32) -> Option<String> {
+    let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
+    let first = raw.split(|&b| b == 0).next()?;
+    let s = String::from_utf8_lossy(first).into_owned();
+    (!s.is_empty()).then_some(s)
+}
+
+fn environ_var(pid: u32, name: &str) -> Option<String> {
+    let raw = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
+    let key = format!("{name}=");
+    raw.split(|&b| b == 0)
+        .find(|kv| kv.starts_with(key.as_bytes()))
+        .map(|kv| String::from_utf8_lossy(&kv[key.len()..]).into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{choose_title, is_wine_program};
+
+    #[test]
+    fn app_windows_outvote_plumbing_and_the_notification() {
+        let names: Vec<String> = [
+            "Default IME",
+            "Ubisoft Connect",
+            "Default IME",
+            "Ubisoft Connect",
+            "Ubisoft Connect Notification",
+            "Default IME",
+        ]
+        .iter()
+        .map(|s| s.to_string())
+        .collect();
+        assert_eq!(choose_title(&names).as_deref(), Some("Ubisoft Connect"));
+        assert_eq!(choose_title(&["Default IME".to_string()]), None);
+        assert_eq!(choose_title(&[]), None);
+    }
+
+    #[test]
+    fn a_tie_goes_to_the_shorter_name() {
+        let names = vec!["Launcher Notification".to_string(), "Launcher".to_string()];
+        assert_eq!(choose_title(&names).as_deref(), Some("Launcher"));
+    }
+
+    #[test]
+    fn only_the_apps_own_exes_count() {
+        assert!(is_wine_program(r"C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\upc.exe"));
+        assert!(is_wine_program("D:/Games/thing.exe"));
+        assert!(!is_wine_program(r"C:\windows\system32\explorer.exe"));
+        assert!(!is_wine_program(r"c:\windows\system32\steam.exe"));
+        assert!(!is_wine_program(
+            r"\\?\Z:\home\u\.local\share\Steam\steamapps\common\Proton - Experimental\files\share\wine/../xalia/xalia.exe"
+        ));
+        assert!(!is_wine_program("/usr/bin/python3"));
+        assert!(!is_wine_program("python3"));
+    }
+}
diff --git a/src/bin/cce-xembed-tray/x11.rs b/src/bin/cce-xembed-tray/x11.rs
index 82438e5..f3c0ba6 100644
--- a/src/bin/cce-xembed-tray/x11.rs
+++ b/src/bin/cce-xembed-tray/x11.rs
@@ -34,6 +34,7 @@ x11rb::atom_manager! {
         _XEMBED,
         _XEMBED_INFO,
         _NET_WM_NAME,
+        _NET_WM_PID,
         UTF8_STRING,
         _CCE_XEMBED_TRAY_TIME,
     }
@@ -53,8 +54,10 @@ 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 },
+    /// A new icon. `image` is `None` until it has drawn something;
+    /// `title_settled` is false while the title is only a fallback
+    /// (`title::resolve`), so the D-Bus side asks again later.
+    Docked { icon: Window, title: String, title_settled: bool, image: Option<SniImage>, at: ClickPoint },
     Image { icon: Window, image: SniImage },
     Title { icon: Window, title: String },
     Undocked { icon: Window },
@@ -80,9 +83,15 @@ struct Icon {
 pub struct XHandle {
     conn: Arc<RustConnection>,
     root: Window,
+    atoms: Atoms,
 }
 
 impl XHandle {
+    /// The icon's name, as `Tray` resolves it at dock time. Blocking.
+    pub fn title(&self, icon: Window) -> crate::title::Title {
+        crate::title::resolve(&self.conn, self.root, &self.atoms, icon)
+    }
+
     /// 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.
@@ -212,7 +221,7 @@ impl Tray {
     }
 
     pub fn handle(&self) -> XHandle {
-        XHandle { conn: self.conn.clone(), root: self.root }
+        XHandle { conn: self.conn.clone(), root: self.root, atoms: self.atoms }
     }
 
     /// Take the tray selection and announce it. Another tray already
@@ -319,8 +328,8 @@ impl Tray {
                     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 });
+                        let title = crate::title::resolve(&self.conn, self.root, &self.atoms, e.window);
+                        let _ = self.tx.send(TrayEvent::Title { icon: e.window, title: title.text });
                     }
                 }
                 Event::DamageNotify(e) => {
@@ -420,12 +429,18 @@ impl Tray {
         self.conn.send_event(false, icon, EventMask::NO_EVENT, notify)?;
         self.conn.flush()?;
 
-        let title = self.title_of(icon);
+        let title = crate::title::resolve(&self.conn, self.root, &self.atoms, 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 });
+        log::info!("docked {icon:#x} ({:?}, {size}px, depth {}) in slot {slot}", title.text, geom.depth);
+        let _ = self.tx.send(TrayEvent::Docked {
+            icon,
+            title: title.text,
+            title_settled: title.settled,
+            image,
+            at,
+        });
         Ok(())
     }
 
@@ -459,24 +474,6 @@ impl Tray {
         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> {