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 }