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

src/tray.rs (18.9K)

  1 //! StatusNotifierItem tray host: the SNI watcher/host D-Bus interfaces,
  2 //! item fetching, and icon loading.
  3 
  4 use std::collections::HashMap;
  5 use std::sync::Arc;
  6 
  7 use crate::{CustomEvent, TrayItem, TrayPixmap};
  8 
  9 #[derive(Debug, Clone)]
 10 pub struct NotifierAddress {
 11     pub destination: String,
 12     pub path: String,
 13 }
 14 
 15 impl NotifierAddress {
 16     pub fn from_notifier_service(service: &str, sender: &str) -> Result<Self, String> {
 17         if service.starts_with('/') {
 18             Ok(NotifierAddress {
 19                 destination: sender.to_string(),
 20                 path: service.to_string(),
 21             })
 22         } else if let Some((destination, path)) = service.split_once('/') {
 23             Ok(NotifierAddress {
 24                 destination: destination.to_string(),
 25                 path: format!("/{}", path),
 26             })
 27         } else if service.contains(':') {
 28             let split = service.split(':').collect::<Vec<&str>>();
 29             Ok(NotifierAddress {
 30                 destination: format!(":{}", split[1]),
 31                 path: "/StatusNotifierItem".to_string(),
 32             })
 33         } else {
 34             Ok(NotifierAddress {
 35                 destination: service.to_string(),
 36                 path: "/StatusNotifierItem".to_string(),
 37             })
 38         }
 39     }
 40 }
 41 
 42 #[zbus::proxy(
 43     interface = "org.kde.StatusNotifierItem",
 44     default_path = "/StatusNotifierItem"
 45 )]
 46 pub(crate) trait StatusNotifierItem {
 47     #[zbus(property)]
 48     fn id(&self) -> zbus::Result<String>;
 49 
 50     #[zbus(property)]
 51     fn category(&self) -> zbus::Result<String>;
 52 
 53     #[zbus(property)]
 54     fn status(&self) -> zbus::Result<String>;
 55 
 56     #[zbus(property)]
 57     fn title(&self) -> zbus::Result<String>;
 58 
 59     #[zbus(property)]
 60     fn icon_name(&self) -> zbus::Result<String>;
 61 
 62     #[zbus(property)]
 63     fn icon_theme_path(&self) -> zbus::Result<String>;
 64 
 65     #[zbus(property)]
 66     fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>;
 67 
 68     #[zbus(signal)]
 69     fn new_icon(&self) -> zbus::Result<()>;
 70     #[zbus(signal)]
 71     fn new_title(&self) -> zbus::Result<()>;
 72 
 73     #[zbus(signal)]
 74     fn new_status(&self) -> zbus::Result<()>;
 75 
 76     fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
 77     fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>;
 78 
 79     #[zbus(property)]
 80     fn item_is_menu(&self) -> zbus::Result<bool>;
 81 
 82     #[zbus(property)]
 83     fn menu(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;
 84 }
 85 
 86 pub(crate) fn find_icon_file(dir: &std::path::Path, icon_name: &str) -> Option<std::path::PathBuf> {
 87     if let Ok(entries) = std::fs::read_dir(dir) {
 88         for entry in entries.filter_map(Result::ok) {
 89             if let Ok(file_type) = entry.file_type() {
 90                 let path = entry.path();
 91                 if file_type.is_dir() {
 92                     if !file_type.is_symlink() {
 93                         if let Some(found) = find_icon_file(&path, icon_name) {
 94                             return Some(found);
 95                         }
 96                     }
 97                 } else if file_type.is_file() {
 98                     if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
 99                         if file_name == format!("{}.png", icon_name) || file_name == format!("{}.svg", icon_name) {
100                             return Some(path);
101                         }
102                     }
103                 }
104             }
105         }
106     }
107     None
108 }
109 
110 pub(crate) fn load_png_as_pixmap(path: &std::path::Path) -> Option<TrayPixmap> {
111     let file = std::fs::File::open(path).ok()?;
112     let mut decoder = png::Decoder::new(file);
113     decoder.set_transformations(png::Transformations::EXPAND);
114     let mut reader = decoder.read_info().ok()?;
115     let mut buf = vec![0; reader.output_buffer_size()];
116     let info = reader.next_frame(&mut buf).ok()?;
117     
118     let width = info.width as i32;
119     let height = info.height as i32;
120     let mut argb_pixels = Vec::with_capacity((width * height * 4) as usize);
121     
122     let actual_bytes = &buf[..info.buffer_size()];
123     match info.color_type {
124         png::ColorType::Rgba => {
125             for chunk in actual_bytes.chunks_exact(4) {
126                 argb_pixels.push(chunk[3]); // A
127                 argb_pixels.push(chunk[0]); // R
128                 argb_pixels.push(chunk[1]); // G
129                 argb_pixels.push(chunk[2]); // B
130             }
131         }
132         png::ColorType::Rgb => {
133             for chunk in actual_bytes.chunks_exact(3) {
134                 argb_pixels.push(255);      // A
135                 argb_pixels.push(chunk[0]); // R
136                 argb_pixels.push(chunk[1]); // G
137                 argb_pixels.push(chunk[2]); // B
138             }
139         }
140         png::ColorType::Grayscale => {
141             for &g in actual_bytes {
142                 argb_pixels.push(255); // A
143                 argb_pixels.push(g);   // R
144                 argb_pixels.push(g);   // G
145                 argb_pixels.push(g);   // B
146             }
147         }
148         png::ColorType::GrayscaleAlpha => {
149             for chunk in actual_bytes.chunks_exact(2) {
150                 argb_pixels.push(chunk[1]); // A
151                 argb_pixels.push(chunk[0]); // R
152                 argb_pixels.push(chunk[0]); // G
153                 argb_pixels.push(chunk[0]); // B
154             }
155         }
156         _ => return None,
157     }
158     
159     Some(TrayPixmap {
160         width,
161         height,
162         pixels: argb_pixels,
163     })
164 }
165 
166 pub(crate) fn load_svg_as_pixmap(path: &std::path::Path) -> Option<TrayPixmap> {
167     let svg_data = std::fs::read(path).ok()?;
168     let opt = resvg::usvg::Options::default();
169     let fontdb = resvg::usvg::fontdb::Database::new();
170     let tree = resvg::usvg::Tree::from_data(&svg_data, &opt, &fontdb).ok()?;
171     
172     let target_w = 48;
173     let target_h = 48;
174     let mut pixmap = resvg::tiny_skia::Pixmap::new(target_w, target_h)?;
175     
176     let orig_w = tree.size().width();
177     let orig_h = tree.size().height();
178     let sx = target_w as f32 / orig_w;
179     let sy = target_h as f32 / orig_h;
180     let transform = resvg::tiny_skia::Transform::from_scale(sx, sy);
181     
182     resvg::render(&tree, transform, &mut pixmap.as_mut());
183     
184     let raw_pixels = pixmap.data();
185     let mut argb_pixels = Vec::with_capacity((target_w * target_h * 4) as usize);
186     for chunk in raw_pixels.chunks_exact(4) {
187         argb_pixels.push(chunk[3]); // A
188         argb_pixels.push(chunk[0]); // R
189         argb_pixels.push(chunk[1]); // G
190         argb_pixels.push(chunk[2]); // B
191     }
192     
193     Some(TrayPixmap {
194         width: target_w as i32,
195         height: target_h as i32,
196         pixels: argb_pixels,
197     })
198 }
199 
200 pub(crate) fn resolve_icon_path(theme_path: Option<&str>, icon_name: &str) -> Option<std::path::PathBuf> {
201     if icon_name.is_empty() {
202         return None;
203     }
204 
205     // Dropbox registers plain "dropbox" but ships only dropboxstatus-* icons.
206     let icon_name = if icon_name == "dropbox" { "dropboxstatus-idle" } else { icon_name };
207 
208     // SNI IconThemePath: the item may point at its own icon directory, which
209     // outranks any installed theme. Layout inside it is unspecified, hence the
210     // recursive scan.
211     if let Some(path_str) = theme_path {
212         if !path_str.is_empty() {
213             let path = std::path::Path::new(path_str);
214             if path.exists() {
215                 if let Some(found) = find_icon_file(path, icon_name) {
216                     return Some(found);
217                 }
218             }
219         }
220     }
221 
222     cce_ui::icon::lookup_in(icon_name, &["status", "apps"])
223 }
224 
225 pub(crate) async fn fetch_tray_item(conn: &zbus::Connection, addr: &NotifierAddress) -> Result<TrayItem, zbus::Error> {
226     let proxy = StatusNotifierItemProxy::builder(conn)
227         .destination(addr.destination.clone())?
228         .path(addr.path.clone())?
229         .build()
230         .await?;
231 
232     let id = format!("{}/{}", addr.destination, addr.path.trim_start_matches('/'));
233     let icon_name = proxy.icon_name().await.ok();
234     let icon_theme_path = proxy.icon_theme_path().await.ok();
235     let title = proxy.title().await.ok();
236     let dbus_id = proxy.id().await.ok();
237 
238     let mut pixmaps = proxy.icon_pixmap().await.ok().and_then(|v| {
239         if v.is_empty() || (v.len() == 1 && v[0].0 == 0 && v[0].1 == 0) {
240             None
241         } else {
242             Some(v.into_iter()
243                 .map(|(w, h, pixels)| TrayPixmap {
244                     width: w,
245                     height: h,
246                     pixels,
247                 })
248                 .collect::<Vec<_>>())
249         }
250     });
251 
252     if pixmaps.is_none() {
253         if let Some(ref name) = icon_name {
254             if let Some(icon_path) = resolve_icon_path(icon_theme_path.as_deref(), name) {
255                 let ext = icon_path.extension().and_then(|e| e.to_str()).unwrap_or("");
256                 let pixmap = if ext.eq_ignore_ascii_case("svg") {
257                     load_svg_as_pixmap(&icon_path)
258                 } else {
259                     load_png_as_pixmap(&icon_path)
260                 };
261                 if let Some(pixmap) = pixmap {
262                     pixmaps = Some(vec![pixmap]);
263                 }
264             }
265         }
266     }
267 
268     Ok(TrayItem {
269         id,
270         icon_name,
271         icon_theme_path,
272         pixmaps,
273         title,
274         dbus_id,
275     })
276 }
277 
278 struct Watcher {
279     registered_items: Arc<tokio::sync::Mutex<HashMap<String, NotifierAddress>>>,
280     sender: calloop::channel::Sender<CustomEvent>,
281     tokio_handle: tokio::runtime::Handle,
282 }
283 
284 #[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
285 impl Watcher {
286     async fn register_status_notifier_item(
287         &self,
288         service: &str,
289         #[zbus(header)] header: zbus::MessageHeader<'_>,
290         #[zbus(connection)] conn: &zbus::Connection,
291     ) {
292         let sender = header
293             .sender()
294             .map(|s| s.to_string())
295             .unwrap_or_else(|| service.to_string());
296         
297         if let Ok(addr) = NotifierAddress::from_notifier_service(service, &sender) {
298             let mut items = self.registered_items.lock().await;
299             let full_address = format!("{}/{}", addr.destination, addr.path.trim_start_matches('/'));
300             if !items.contains_key(&full_address) {
301                 items.insert(full_address.clone(), addr.clone());
302                 
303                 let conn = conn.clone();
304                 let addr_clone = addr.clone();
305                 let sender_clone = self.sender.clone();
306                 
307                 self.tokio_handle.spawn(async move {
308                     if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
309                         let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
310                     }
311                     
312                     // Listen for updates
313                     if let Ok(proxy) = StatusNotifierItemProxy::builder(&conn)
314                         .destination(addr_clone.destination.clone())
315                         .unwrap()
316                         .path(addr_clone.path.clone())
317                         .unwrap()
318                         .build()
319                         .await
320                     {
321                         let mut new_icon_stream = proxy.receive_new_icon().await.ok();
322                         let mut new_title_stream = proxy.receive_new_title().await.ok();
323                         let mut new_status_stream = proxy.receive_new_status().await.ok();
324                         
325                         use tokio_stream::StreamExt;
326                         loop {
327                             tokio::select! {
328                                 Some(_) = async {
329                                     if let Some(ref mut s) = new_icon_stream {
330                                         s.next().await
331                                     } else {
332                                         std::future::pending().await
333                                     }
334                                 } => {
335                                     if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
336                                         let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
337                                     }
338                                 }
339                                 Some(_) = async {
340                                     if let Some(ref mut s) = new_title_stream {
341                                         s.next().await
342                                     } else {
343                                         std::future::pending().await
344                                     }
345                                 } => {
346                                     if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
347                                         let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
348                                     }
349                                 }
350                                 Some(_) = async {
351                                     if let Some(ref mut s) = new_status_stream {
352                                         s.next().await
353                                     } else {
354                                         std::future::pending().await
355                                     }
356                                 } => {
357                                     if let Ok(item) = fetch_tray_item(&conn, &addr_clone).await {
358                                         let _ = sender_clone.send(CustomEvent::TrayUpdated(item));
359                                     }
360                                 }
361                             }
362                         }
363                     }
364                 });
365             }
366         }
367     }
368 
369     async fn register_status_notifier_host(&self, _service: &str) {}
370 
371     #[zbus(property)]
372     async fn protocol_version(&self) -> i32 {
373         0
374     }
375 
376     #[zbus(property)]
377     async fn is_status_notifier_host_registered(&self) -> bool {
378         true
379     }
380 
381     #[zbus(property)]
382     async fn registered_status_notifier_items(&self) -> Vec<String> {
383         let items = self.registered_items.lock().await;
384         items.keys().cloned().collect()
385     }
386 }
387 
388 struct StatusInterface;
389 
390 #[zbus::interface(name = "org.clear.StatusInterface")]
391 impl StatusInterface {
392     async fn notify_attention(&self, app_id: String, title: String) {
393         log::debug!("[status-interface] Received NotifyAttention: app_id={}, title={}", app_id, title);
394         let title_escaped = title.replace('\'', "'\\''");
395         let app_id_escaped = app_id.replace('\'', "'\\''");
396         let cmd = format!(
397             "notify-send -a '{}' '{} needs attention' 'This window has requested activation.'",
398             app_id_escaped, title_escaped
399         );
400         std::process::Command::new("sh")
401             .args(["-c", &cmd])
402             .spawn()
403             .ok();
404     }
405 }
406 
407 pub(crate) async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEvent>) {
408     let registered_items = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
409     let tokio_handle = tokio::runtime::Handle::current();
410 
411     // Across a service restart the outgoing tray process can still own the
412     // well-known name for a moment, so NameTaken here is normally transient.
413     // Without the retry the new process gave up for good and the tray hosted
414     // no icons until the next restart.
415     let mut conn = None;
416     for attempt in 1..=10 {
417         if attempt > 1 {
418             tokio::time::sleep(std::time::Duration::from_millis(500)).await;
419         }
420         let watcher = Watcher {
421             registered_items: registered_items.clone(),
422             sender: sender.clone(),
423             tokio_handle: tokio_handle.clone(),
424         };
425         let builder = match zbus::ConnectionBuilder::session() {
426             Ok(b) => b,
427             Err(e) => {
428                 log::warn!("Failed to initialize D-Bus session: {:?}", e);
429                 return;
430             }
431         };
432         match builder
433             .name("org.kde.StatusNotifierWatcher")
434             .unwrap()
435             .serve_at("/StatusNotifierWatcher", watcher)
436             .unwrap()
437             .serve_at("/StatusInterface", StatusInterface)
438             .unwrap()
439             .build()
440             .await
441         {
442             Ok(c) => {
443                 conn = Some(c);
444                 break;
445             }
446             Err(e) => log::warn!("Failed to build D-Bus connection (attempt {}/10): {:?}", attempt, e),
447         }
448     }
449     let Some(conn) = conn else {
450         log::warn!("StatusNotifierWatcher name never became available; tray disabled");
451         return;
452     };
453 
454     log::info!("StatusNotifierWatcher running successfully on D-Bus!");
455 
456     // Start NameOwnerChanged listener to detect when tray apps disconnect
457     let dbus_proxy = match zbus::fdo::DBusProxy::new(&conn).await {
458         Ok(p) => p,
459         Err(e) => {
460             log::warn!("Failed to create DBusProxy: {:?}", e);
461             return;
462         }
463     };
464     let mut owner_changes = match dbus_proxy.receive_name_owner_changed().await {
465         Ok(oc) => oc,
466         Err(e) => {
467             log::warn!("Failed to receive name owner changed: {:?}", e);
468             return;
469         }
470     };
471 
472     let registered_items_clone = registered_items.clone();
473     let sender_clone = sender.clone();
474     
475     tokio::spawn(async move {
476         use tokio_stream::StreamExt;
477         while let Some(signal) = owner_changes.next().await {
478             if let Ok(args) = signal.args() {
479                 let old = args.old_owner;
480                 let new = args.new_owner;
481                 let old_opt: &Option<_> = &*old;
482                 if let Some(ref old_owner) = old_opt {
483                     if new.is_none() {
484                         let mut items = registered_items_clone.lock().await;
485                         let mut to_remove = Vec::new();
486                         for (key, addr) in items.iter() {
487                             if addr.destination == old_owner.as_str() {
488                                 to_remove.push(key.clone());
489                             }
490                         }
491                         for key in to_remove {
492                             items.remove(&key);
493                             let _ = sender_clone.send(CustomEvent::TrayRemoved(key));
494                         }
495                     }
496                 }
497             }
498         }
499     });
500 
501     // Keep the task alive
502     loop {
503         tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
504     }
505 }
506 
507 #[cfg(test)]
508 mod tests {
509     use super::resolve_icon_path;
510 
511     /// The two tray-specific pieces kept local when the theme search moved to
512     /// `cce_ui::icon::lookup_in`: the dropbox alias, and the SNI IconThemePath
513     /// directory outranking the theme search (recursively — Dropbox nests its
514     /// icons under images/hicolor/<size>/status/).
515     #[test]
516     fn theme_path_override_and_dropbox_alias() {
517         let root = std::env::temp_dir().join("cce-tray-icon-test");
518         let status = root.join("hicolor/16x16/status");
519         std::fs::create_dir_all(&status).unwrap();
520         let icon = status.join("dropboxstatus-idle.png");
521         std::fs::write(&icon, b"x").unwrap();
522 
523         let theme_path = root.to_str().unwrap();
524         assert_eq!(resolve_icon_path(Some(theme_path), "dropbox"), Some(icon.clone()));
525         assert_eq!(resolve_icon_path(Some(theme_path), "dropboxstatus-idle"), Some(icon));
526         assert_eq!(resolve_icon_path(Some(theme_path), ""), None);
527 
528         std::fs::remove_dir_all(&root).ok();
529     }
530 }