git.lucas.co / cce-status-interface
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 }