status bar
git clone https://git.lucas.co/cce-status-interface.git
src/bin/cce-xembed-tray/title.rs (8.8K)
1 //! What to call a tray icon.
2 //!
3 //! Icon windows are almost always untitled, and their WM_CLASS names the
4 //! toolkit rather than the app: every Proton program's is `steam_proton`.
5 //! Worse, a Wine icon is not even the app's window — Wine's tray lives in
6 //! the prefix's `explorer.exe`, which creates the icon windows for every
7 //! program in that prefix. So the name comes from the APP's own top-level
8 //! windows: for a Wine icon, those of the other programs sharing its
9 //! `WINEPREFIX` (Wine's own `C:\windows\…` processes excluded); for a native
10 //! icon, those of its own process. Ubisoft Connect titles its windows
11 //! "Ubisoft Connect" even while it sits hidden in the tray.
12
13 use std::collections::HashMap;
14
15 use x11rb::connection::Connection;
16 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, Window};
17 use x11rb::rust_connection::RustConnection;
18
19 use crate::x11::Atoms;
20
21 /// A resolved name, and whether it is final. A fallback (the process or
22 /// class name) is not: the app may simply not have titled a window yet,
23 /// so the caller asks again a little later.
24 pub struct Title {
25 pub text: String,
26 pub settled: bool,
27 }
28
29 /// Wine windows that exist in every Wine process and name nothing.
30 const PLUMBING_TITLES: &[&str] = &["Default IME", "MSCTFIME UI"];
31
32 pub fn resolve(conn: &RustConnection, root: Window, atoms: &Atoms, icon: Window) -> Title {
33 if let Some(own) = window_title(conn, atoms, icon) {
34 return Title { text: own, settled: true };
35 }
36 let pid = cardinal(conn, icon, atoms._NET_WM_PID);
37 let app = pid.map(app_pids).unwrap_or_default();
38 if !app.is_empty() {
39 let names = toplevel_titles(conn, root, atoms, &app);
40 if let Some(name) = choose_title(&names) {
41 return Title { text: name, settled: true };
42 }
43 }
44 let fallback = app
45 .first()
46 .and_then(|&p| process_name(p))
47 .or_else(|| wm_class(conn, icon))
48 .unwrap_or_else(|| "X11 tray icon".to_string());
49 Title { text: fallback, settled: false }
50 }
51
52 fn text_property(conn: &RustConnection, window: Window, atom: u32, kind: u32) -> Option<String> {
53 let reply = conn.get_property(false, window, atom, kind, 0, 256).ok()?.reply().ok()?;
54 let s = String::from_utf8_lossy(&reply.value).trim_end_matches('\0').trim().to_string();
55 (!s.is_empty()).then_some(s)
56 }
57
58 fn window_title(conn: &RustConnection, atoms: &Atoms, window: Window) -> Option<String> {
59 text_property(conn, window, atoms._NET_WM_NAME, atoms.UTF8_STRING)
60 .or_else(|| text_property(conn, window, AtomEnum::WM_NAME.into(), AtomEnum::ANY.into()))
61 }
62
63 fn wm_class(conn: &RustConnection, window: Window) -> Option<String> {
64 text_property(conn, window, AtomEnum::WM_CLASS.into(), AtomEnum::STRING.into())
65 .and_then(|c| c.split('\0').nth(1).map(str::to_string))
66 .filter(|c| !c.is_empty())
67 }
68
69 fn cardinal(conn: &RustConnection, window: Window, atom: u32) -> Option<u32> {
70 let reply = conn.get_property(false, window, atom, AtomEnum::CARDINAL, 0, 1).ok()?.reply().ok()?;
71 reply.value32().and_then(|mut v| v.next())
72 }
73
74 /// Titles of every top-level window owned by one of `pids`. The
75 /// `_NET_WM_PID` lookups are pipelined: one round trip for all of them.
76 fn toplevel_titles(conn: &RustConnection, root: Window, atoms: &Atoms, pids: &[u32]) -> Vec<String> {
77 let Some(tree) = conn.query_tree(root).ok().and_then(|c| c.reply().ok()) else {
78 return Vec::new();
79 };
80 let cookies: Vec<_> = tree
81 .children
82 .iter()
83 .map(|&w| (w, conn.get_property(false, w, atoms._NET_WM_PID, AtomEnum::CARDINAL, 0, 1)))
84 .collect();
85 let mut names = Vec::new();
86 for (w, cookie) in cookies {
87 let owner = cookie
88 .ok()
89 .and_then(|c| c.reply().ok())
90 .and_then(|r| r.value32().and_then(|mut v| v.next()));
91 if owner.is_some_and(|p| pids.contains(&p)) {
92 names.extend(window_title(conn, atoms, w));
93 }
94 }
95 let _ = conn.flush();
96 names
97 }
98
99 /// The name an app's windows agree on: the most common title, the shorter
100 /// on a tie ("Ubisoft Connect" over "Ubisoft Connect Notification").
101 pub fn choose_title(names: &[String]) -> Option<String> {
102 let mut counts: HashMap<&str, usize> = HashMap::new();
103 for n in names {
104 let n = n.trim();
105 if !n.is_empty() && !PLUMBING_TITLES.contains(&n) {
106 *counts.entry(n).or_default() += 1;
107 }
108 }
109 counts
110 .into_iter()
111 .max_by(|(a, ca), (b, cb)| ca.cmp(cb).then(b.len().cmp(&a.len())).then(b.cmp(a)))
112 .map(|(n, _)| n.to_string())
113 }
114
115 /// The processes that are "the app" for an icon window owned by `pid`.
116 fn app_pids(pid: u32) -> Vec<u32> {
117 let Some(prefix) = environ_var(pid, "WINEPREFIX") else {
118 return vec![pid];
119 };
120 let Ok(dir) = std::fs::read_dir("/proc") else {
121 return Vec::new();
122 };
123 let mut pids: Vec<u32> = dir
124 .flatten()
125 .filter_map(|e| e.file_name().to_str()?.parse::<u32>().ok())
126 .filter(|&p| environ_var(p, "WINEPREFIX").as_deref() == Some(prefix.as_str()))
127 .filter(|&p| argv0(p).is_some_and(|a| is_wine_program(&a)))
128 .collect();
129 pids.sort_unstable();
130 pids
131 }
132
133 /// A Windows program run by Wine that is not part of Wine itself: a
134 /// drive-letter path outside `C:\windows\`. Excludes the prefix's plumbing
135 /// (explorer, services, winedevice, Proton's `steam.exe` shim, xalia) and
136 /// every Unix process that merely carries the prefix in its environment
137 /// (wineserver, Proton's python, pressure-vessel).
138 pub fn is_wine_program(argv0: &str) -> bool {
139 let a = argv0.trim().replace('/', "\\").to_ascii_lowercase();
140 let a = a.strip_prefix("\\\\?\\").unwrap_or(&a);
141 let b = a.as_bytes();
142 let drive_path = b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\';
143 drive_path && !a[1..].starts_with(":\\windows\\") && !a.contains("\\xalia\\")
144 }
145
146 /// A process of Wine's own — run under a `WINEPREFIX`, but not a program
147 /// `is_wine_program` counts as an app. `explorer.exe`, which hosts every
148 /// tray icon in the prefix, is the one that matters: its windows are the
149 /// icons and their tooltips, never an app's menu.
150 pub fn is_wine_plumbing(pid: u32) -> bool {
151 environ_var(pid, "WINEPREFIX").is_some() && argv0(pid).is_some_and(|a| !is_wine_program(&a))
152 }
153
154 /// A process's name for the fallback: a Windows exe's file stem, or the
155 /// kernel's `comm` for anything else.
156 fn process_name(pid: u32) -> Option<String> {
157 if let Some(a) = argv0(pid).filter(|a| is_wine_program(a)) {
158 let file = a.trim().rsplit(['\\', '/']).next()?.to_string();
159 let stem = file.rsplit_once('.').map_or(file.as_str(), |(s, _)| s).to_string();
160 return (!stem.is_empty()).then_some(stem);
161 }
162 let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
163 let comm = comm.trim();
164 (!comm.is_empty()).then(|| comm.to_string())
165 }
166
167 fn argv0(pid: u32) -> Option<String> {
168 let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
169 let first = raw.split(|&b| b == 0).next()?;
170 let s = String::from_utf8_lossy(first).into_owned();
171 (!s.is_empty()).then_some(s)
172 }
173
174 fn environ_var(pid: u32, name: &str) -> Option<String> {
175 let raw = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
176 let key = format!("{name}=");
177 raw.split(|&b| b == 0)
178 .find(|kv| kv.starts_with(key.as_bytes()))
179 .map(|kv| String::from_utf8_lossy(&kv[key.len()..]).into_owned())
180 }
181
182 #[cfg(test)]
183 mod tests {
184 use super::{choose_title, is_wine_program};
185
186 #[test]
187 fn app_windows_outvote_plumbing_and_the_notification() {
188 let names: Vec<String> = [
189 "Default IME",
190 "Ubisoft Connect",
191 "Default IME",
192 "Ubisoft Connect",
193 "Ubisoft Connect Notification",
194 "Default IME",
195 ]
196 .iter()
197 .map(|s| s.to_string())
198 .collect();
199 assert_eq!(choose_title(&names).as_deref(), Some("Ubisoft Connect"));
200 assert_eq!(choose_title(&["Default IME".to_string()]), None);
201 assert_eq!(choose_title(&[]), None);
202 }
203
204 #[test]
205 fn a_tie_goes_to_the_shorter_name() {
206 let names = vec!["Launcher Notification".to_string(), "Launcher".to_string()];
207 assert_eq!(choose_title(&names).as_deref(), Some("Launcher"));
208 }
209
210 #[test]
211 fn only_the_apps_own_exes_count() {
212 assert!(is_wine_program(r"C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\upc.exe"));
213 assert!(is_wine_program("D:/Games/thing.exe"));
214 assert!(!is_wine_program(r"C:\windows\system32\explorer.exe"));
215 assert!(!is_wine_program(r"c:\windows\system32\steam.exe"));
216 assert!(!is_wine_program(
217 r"\\?\Z:\home\u\.local\share\Steam\steamapps\common\Proton - Experimental\files\share\wine/../xalia/xalia.exe"
218 ));
219 assert!(!is_wine_program("/usr/bin/python3"));
220 assert!(!is_wine_program("python3"));
221 }
222 }