Skip to main content

crabshell_services/
tray.rs

1//! The system tray: every app's StatusNotifierItem on the session bus.
2//!
3//! An app's tray icon is an item, an object on the session bus with the
4//! `org.kde.StatusNotifierItem` interface. A watcher, the owner of
5//! `org.kde.StatusNotifierWatcher`, keeps the list of items, and hosts
6//! such as this bar show them. The shell queues for the watcher's name,
7//! so it runs the watcher while nobody else does, and otherwise reads the
8//! list from the one that runs.
9//!
10//! Everything runs on the services' executor, on the main thread. One
11//! stream reads every message the bus sends this connection, so a quiet
12//! bus leaves the shell asleep.
13
14mod menu;
15mod watcher;
16mod widget;
17
18#[cfg(test)]
19mod tests;
20
21use std::cell::RefCell;
22use std::future::poll_fn;
23use std::path::{Path, PathBuf};
24use std::pin::Pin;
25use std::rc::Rc;
26use std::sync::Once;
27
28use crabshell_reactive::{ReadSignal, Signal};
29use crabshell_ui::{Cx, IconPixels, IconSource};
30use zbus::export::futures_core::Stream;
31use zbus::fdo::{RequestNameFlags, RequestNameReply};
32use zbus::message::Type as Kind;
33use zbus::names::BusName;
34use zbus::{Connection, DeserializeDict, DynamicType, Message, MessageStream, ObjectPath, Type};
35
36use crate::Shared;
37use menu::{DBUSMENU, OpenMenu};
38pub use menu::{MenuEntry, MenuToggle, TrayMenu};
39pub use widget::Tray;
40
41const WATCHER: &str = "org.kde.StatusNotifierWatcher";
42const WATCHER_PATH: &str = "/StatusNotifierWatcher";
43const ITEM: &str = "org.kde.StatusNotifierItem";
44const ITEM_PATH: &str = "/StatusNotifierItem";
45const BUS: &str = "org.freedesktop.DBus";
46const BUS_PATH: &str = "/org/freedesktop/DBus";
47const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
48
49/// The largest pixmap side kept, the same as the renderer's cap.
50const MAX_PIXMAP_SIDE: i32 = 512;
51
52/// The most pixmap bytes kept per item: one 512 px pixmap, or a usual
53/// set of sizes from 16 to 256 px.
54const MAX_PIXMAP_BYTES: usize = 512 * 512 * 4;
55
56/// How long a call to an app or another watcher may take, libdbus's
57/// default.
58const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
59
60/// Whether a tray item wants to be seen.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub enum TrayStatus {
63  /// Nothing to report now, so a bar may hide it.
64  Passive,
65  /// The usual state.
66  #[default]
67  Active,
68  /// It asks for the user's attention, and shows its attention icon if
69  /// it has one.
70  NeedsAttention,
71}
72
73/// One app's tray icon, as its StatusNotifierItem describes it.
74#[derive(Clone, Debug, Default, PartialEq)]
75pub struct TrayItem {
76  /// Unique while the item is registered: its bus name and object path,
77  /// such as `:1.42/StatusNotifierItem`. The key to give
78  /// `children_keyed`, and what [`TrayHost::activate`] and the rest take.
79  pub id: String,
80  /// The app's name for itself, such as `nm-applet`. Two copies of one
81  /// app share it.
82  pub app_id: String,
83  /// A name for the item, such as `Network`.
84  pub title: String,
85  /// Whether it wants to be seen.
86  pub status: TrayStatus,
87  /// A freedesktop icon name, or empty.
88  pub icon_name: String,
89  /// The icon as raw pixels, in each size the app sent, for an item with
90  /// no icon name. Each side is at most 512 px.
91  pub icon_pixmaps: Vec<IconPixels>,
92  /// The icon name to show while it needs attention, or empty.
93  pub attention_icon_name: String,
94  /// A directory where the app keeps its own icon files, or empty.
95  pub icon_theme_path: String,
96  /// The tooltip's title, or empty.
97  pub tooltip: String,
98  /// Whether the item offers only its menu, so a left click opens the
99  /// menu instead of calling [`TrayHost::activate`].
100  pub item_is_menu: bool,
101  /// Where its menu is on the bus, or empty for none. Open it with
102  /// [`TrayHost::open_menu`].
103  pub menu: String,
104}
105
106impl TrayItem {
107  /// What to draw at `size` physical pixels, the icon's logical size
108  /// times the output's scale. That is the attention icon while the item
109  /// needs attention and has one, otherwise its icon name, as a file in
110  /// `icon_theme_path` if one is there. An app with an `icon_theme_path`
111  /// that lacks the file keeps its icons apart from the theme, so its
112  /// pixmaps, if it sent any, come before the name. With no name, it is
113  /// the pixmap closest to `size`. `None` when there is nothing to draw.
114  pub fn icon(&self, size: u32) -> Option<IconSource> {
115    let name = match self.status {
116      TrayStatus::NeedsAttention if !self.attention_icon_name.is_empty() => {
117        &self.attention_icon_name
118      }
119      _ => &self.icon_name,
120    };
121    if let Some(file) = app_icon_file(Path::new(&self.icon_theme_path), name) {
122      return Some(IconSource::File(file));
123    }
124    let closest = self
125      .icon_pixmaps
126      .iter()
127      .min_by_key(|pixels| pixels.width.max(pixels.height).abs_diff(size));
128    match closest {
129      Some(pixels) if name.is_empty() || !self.icon_theme_path.is_empty() => {
130        Some(IconSource::Pixels(pixels.clone()))
131      }
132      _ => (!name.is_empty()).then(|| IconSource::Name(name.clone())),
133    }
134  }
135}
136
137/// `<dir>/<name>.png` or `.svg`, where Electron apps put their icon.
138/// `None` for a relative `dir`, a name that could leave it, or no file.
139fn app_icon_file(dir: &Path, name: &str) -> Option<PathBuf> {
140  let safe = !matches!(name, "" | "." | "..") && !name.contains(['/', '\0']);
141  if !dir.is_absolute() || !safe {
142    return None;
143  }
144  ["png", "svg"]
145    .into_iter()
146    .map(|extension| dir.join(format!("{name}.{extension}")))
147    .find(|path| path.is_file())
148}
149
150/// The tray, from `cx.tray()`. A clone is cheap and shares the one
151/// connection.
152#[derive(Clone)]
153pub struct TrayHost {
154  /// `None` when the shell provided no services.
155  service: Option<Rc<Service>>,
156  items: ReadSignal<Vec<TrayItem>>,
157  menu: ReadSignal<Option<TrayMenu>>,
158}
159
160impl TrayHost {
161  /// Every item, in the order they registered. Empty while there is no
162  /// session bus. An item shows once its properties are read.
163  pub fn items(&self) -> ReadSignal<Vec<TrayItem>> {
164    self.items
165  }
166
167  /// Tells item `id` it was clicked, as a left click does. A Wayland
168  /// client can't know where on screen that was, so the item is told
169  /// `(0, 0)`. A refusal or an unknown `id` is logged.
170  pub fn activate(&self, id: &str) {
171    self.call(id, ITEM, "Activate", (0, 0));
172  }
173
174  /// Tells item `id` it was middle-clicked, as for [`activate`](Self::activate).
175  pub fn secondary_activate(&self, id: &str) {
176    self.call(id, ITEM, "SecondaryActivate", (0, 0));
177  }
178
179  /// Tells item `id` the wheel turned `steps`, positive for up, as a
180  /// button's `on_scroll` gives them.
181  pub fn scroll(&self, id: &str, steps: i32) {
182    self.call(id, ITEM, "Scroll", (steps, "vertical"));
183  }
184
185  /// The tray menu that is open, if any. One is open at a time: opening
186  /// another closes it. It is `None` from [`open_menu`](Self::open_menu)
187  /// until the app's layout arrives, and follows the app's changes while
188  /// it stays open.
189  pub fn menu(&self) -> ReadSignal<Option<TrayMenu>> {
190    self.menu
191  }
192
193  /// Opens item `id`'s menu: tells the app, as a right click does, then
194  /// reads the layout into [`menu`](Self::menu). An item with no menu, or
195  /// an unknown `id`, is logged.
196  pub fn open_menu(&self, id: &str) {
197    if let Some(service) = &self.service {
198      service.open_menu(id);
199    }
200  }
201
202  /// Tells the open menu's app that its submenu `entry` opens, as a
203  /// nested popup does. An app that fills submenus only then sends the
204  /// entries, and [`menu`](Self::menu) shows them.
205  pub fn open_submenu(&self, entry: i32) {
206    if let Some(service) = &self.service {
207      service.open_submenu(entry);
208    }
209  }
210
211  /// Closes the open menu and stops following it.
212  pub fn close_menu(&self) {
213    if let Some(service) = &self.service {
214      service.close_menu();
215    }
216  }
217
218  /// Tells item `id` its menu entry `entry` was clicked. A refusal or an
219  /// unknown `id` is logged.
220  pub fn menu_event(&self, id: &str, entry: i32) {
221    let event = (entry, "clicked", zbus::Variant::from(0), 0u32);
222    self.call(id, DBUSMENU, "Event", event);
223  }
224
225  /// Calls `method` on item `id`, or on its menu for [`DBUSMENU`],
226  /// without waiting for the answer.
227  fn call<B>(&self, id: &str, interface: &'static str, method: &'static str, body: B)
228  where
229    B: serde::Serialize + DynamicType + 'static,
230  {
231    let target = self.service.as_ref().and_then(|service| {
232      let connection = service.state.borrow().as_ref()?.connection.clone()?;
233      let at = service.with_entry(id, |entry| {
234        let path = match interface {
235          ITEM => entry.path.clone(),
236          _ => entry.shown.as_ref()?.menu.clone(),
237        };
238        Some((entry.bus.clone(), path)).filter(|(_, path)| !path.is_empty())
239      });
240      let (bus, path) = at.flatten()?;
241      Some((service, connection, bus, path))
242    });
243    let Some((service, connection, bus, path)) = target else {
244      return tracing::warn!(id, method, "no such tray item");
245    };
246    let id = id.to_owned();
247    let shared = Rc::clone(&service.shared);
248    service.shared.spawn(async move {
249      let call = connection.call_method(
250        Some(bus.as_str()),
251        path.as_str(),
252        Some(interface),
253        method,
254        &body,
255      );
256      if let Err(error) = shared.timeout(CALL_TIMEOUT, call).await {
257        tracing::info!(id, method, %error, "the tray item refused");
258      }
259    });
260  }
261}
262
263/// `cx.tray()`.
264pub(crate) fn tray(cx: &Cx) -> TrayHost {
265  let Some(service) = cx.service::<Service>() else {
266    static LOGGED: Once = Once::new();
267    LOGGED.call_once(|| {
268      tracing::warn!("cx.tray() needs the services the shell provides; the tray stays empty");
269    });
270    return TrayHost {
271      service: None,
272      items: cx.signal(Vec::new()).read_only(),
273      menu: cx.signal(None).read_only(),
274    };
275  };
276  let (items, menu) = service.start();
277  TrayHost {
278    service: Some(service),
279    items,
280    menu,
281  }
282}
283
284/// The tray of one shell, started on first use.
285pub(crate) struct Service {
286  shared: Rc<Shared>,
287  state: RefCell<Option<State>>,
288}
289
290struct State {
291  items: Signal<Vec<TrayItem>>,
292  /// The session bus, once connected.
293  connection: Option<Connection>,
294  /// Every registered item, in the order they came, read or not.
295  entries: Vec<Entry>,
296  /// Whether this shell owns the watcher's name.
297  watcher: bool,
298  /// The open menu's layout, `None` while none is shown.
299  menu: Signal<Option<TrayMenu>>,
300  /// The open menu, from `open_menu` until `close_menu`.
301  open: Option<OpenMenu>,
302  /// Counts menu openings and reads, so a task or reply left from an
303  /// earlier one never overwrites a newer one.
304  menu_count: u64,
305}
306
307struct Entry {
308  id: String,
309  bus: String,
310  path: String,
311  /// The unique name behind `bus`, which the item's signals come from.
312  /// `None` until the bus says.
313  owner: Option<String>,
314  /// Counts reads, so a slow reply never overwrites a newer one.
315  reads: u64,
316  /// The item as last read. `None` until a read works.
317  shown: Option<TrayItem>,
318}
319
320impl Service {
321  pub(crate) fn new(shared: Rc<Shared>) -> Service {
322    Service {
323      shared,
324      state: RefCell::new(None),
325    }
326  }
327
328  /// The list and the open menu, joining the bus if this is the first ask.
329  fn start(self: &Rc<Self>) -> (ReadSignal<Vec<TrayItem>>, ReadSignal<Option<TrayMenu>>) {
330    if let Some(state) = &*self.state.borrow() {
331      return (state.items.read_only(), state.menu.read_only());
332    }
333    let items = self.shared.root.signal(Vec::new());
334    let menu = self.shared.root.signal(None);
335    *self.state.borrow_mut() = Some(State {
336      items,
337      connection: None,
338      entries: Vec::new(),
339      watcher: false,
340      menu,
341      open: None,
342      menu_count: 0,
343    });
344    match self.shared.config.session_bus.clone() {
345      Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
346      None => tracing::info!("no session bus, so the tray is empty"),
347    }
348    (items.read_only(), menu.read_only())
349  }
350
351  /// Whether the list is still there: its owner, the root, isn't disposed.
352  fn alive(&self) -> bool {
353    let state = self.state.borrow();
354    let alive = state
355      .as_ref()
356      .and_then(|state| state.items.try_with(|_| ()));
357    alive.is_some()
358  }
359
360  fn is_watcher(&self) -> bool {
361    self
362      .state
363      .borrow()
364      .as_ref()
365      .is_some_and(|state| state.watcher)
366  }
367
368  /// Every registered item's id, read or not, as the watcher lists them.
369  fn ids(&self) -> Vec<String> {
370    let state = self.state.borrow();
371    let entries = state.as_ref().map(|state| state.entries.as_slice());
372    entries
373      .unwrap_or_default()
374      .iter()
375      .map(|entry| entry.id.clone())
376      .collect()
377  }
378
379  fn with_entry<R>(&self, id: &str, f: impl FnOnce(&mut Entry) -> R) -> Option<R> {
380    let mut state = self.state.borrow_mut();
381    let entry = state
382      .as_mut()?
383      .entries
384      .iter_mut()
385      .find(|entry| entry.id == id)?;
386    Some(f(entry))
387  }
388
389  /// Subscribes to what items and the watcher say, then queues for the
390  /// watcher's name and registers as a host.
391  async fn join(self: &Rc<Self>, connection: &Connection) -> zbus::Result<()> {
392    bus_call(
393      connection,
394      "AddMatch",
395      &format!("type='signal',interface='{ITEM}'"),
396    )
397    .await?;
398    // From the watcher's owner only, so no other app can spoof it.
399    let rule = format!("type='signal',sender='{WATCHER}',interface='{WATCHER}'");
400    bus_call(connection, "AddMatch", &rule).await?;
401    let host = format!("org.kde.StatusNotifierHost-{}", std::process::id());
402    connection.request_name(host.as_str()).await?;
403    // Queued rather than refused, so the bus hands the name over when
404    // another watcher leaves. NameAcquired says so, in `on_signal`.
405    let reply = connection
406      .request_name_with_flags(WATCHER, RequestNameFlags::empty())
407      .await?;
408    if reply == RequestNameReply::InQueue {
409      // Apart from the message loop, so a frozen watcher can't stall it.
410      let (service, connection) = (Rc::clone(self), connection.clone());
411      self.shared.spawn(async move {
412        if let Err(error) = service.use_watcher(&connection, &host).await {
413          tracing::warn!(%error, "the running tray watcher didn't answer");
414        }
415      });
416    }
417    Ok(())
418  }
419
420  /// Registers with the watcher another program runs, and takes its list.
421  async fn use_watcher(self: &Rc<Self>, connection: &Connection, host: &str) -> zbus::Result<()> {
422    tracing::info!("another program runs the tray watcher; using it");
423    let register = "RegisterStatusNotifierHost";
424    let path = WATCHER_PATH;
425    let call = connection.call_method(Some(WATCHER), path, Some(WATCHER), register, &host);
426    self.shared.timeout(CALL_TIMEOUT, call).await?;
427    let get = (WATCHER, "RegisteredStatusNotifierItems");
428    let call = connection.call_method(Some(WATCHER), path, Some(PROPERTIES), "Get", &get);
429    let reply = self.shared.timeout(CALL_TIMEOUT, call).await?;
430    let list: Vec<String> = reply
431      .body()
432      .deserialize::<zbus::Variant<'_>>()?
433      .downcast()?;
434    for service in list {
435      self.add_registered(connection, &service);
436    }
437    Ok(())
438  }
439
440  /// Acts on one message from the bus.
441  async fn on_message(self: &Rc<Self>, connection: &Connection, message: &Message) {
442    match message.header().message_type() {
443      Kind::MethodCall => watcher::answer(self, connection, message).await,
444      Kind::Signal => self.on_signal(connection, message),
445      _ => {}
446    }
447  }
448
449  fn on_signal(self: &Rc<Self>, connection: &Connection, message: &Message) {
450    let header = message.header();
451    let (Some(interface), Some(member)) = (header.interface(), header.member()) else {
452      return;
453    };
454    let from_bus = header.sender().is_some_and(|sender| sender.as_str() == BUS);
455    // The bus sends this connection a broadcast signal only when a match
456    // rule picks it, but any app can address one to it directly.
457    let broadcast = header.destination().is_none();
458    let body = message.body();
459    match (interface.as_str(), member.as_str()) {
460      (BUS, "NameAcquired") if from_bus && body.deserialize::<&str>().ok() == Some(WATCHER) => {
461        self.become_watcher(connection);
462      }
463      (BUS, "NameOwnerChanged") if from_bus => {
464        if let Ok((name, _, owner)) = body.deserialize::<(&str, &str, &str)>() {
465          self.owner_changed(connection, name, owner);
466        }
467      }
468      // Ours echo back while this shell is the watcher.
469      (WATCHER, "StatusNotifierItemRegistered") if broadcast && !self.is_watcher() => {
470        if let Ok(service) = body.deserialize::<&str>() {
471          self.add_registered(connection, service);
472        }
473      }
474      (WATCHER, "StatusNotifierItemUnregistered") if broadcast && !self.is_watcher() => {
475        if let Ok(id) = body.deserialize::<&str>() {
476          self.remove(connection, id);
477        }
478      }
479      (DBUSMENU, "LayoutUpdated" | "ItemsPropertiesUpdated") => {
480        if let (Some(sender), Some(path)) = (header.sender(), header.path()) {
481          self.menu_changed(connection, sender.as_str(), path.as_str());
482        }
483      }
484      // NewIcon, NewTitle, NewStatus and the rest: read it all again.
485      (ITEM, member) if member.starts_with("New") => {
486        let (sender, path) = (header.sender(), header.path());
487        let (Some(sender), Some(path)) = (sender, path) else {
488          return;
489        };
490        let state = self.state.borrow();
491        let entries = state.as_ref().map(|state| state.entries.as_slice());
492        let ids: Vec<String> = entries
493          .unwrap_or_default()
494          .iter()
495          .filter(|entry| entry.owner.as_deref() == Some(sender.as_str()))
496          .filter(|entry| entry.path == path.as_str())
497          .map(|entry| entry.id.clone())
498          .collect();
499        drop(state);
500        for id in ids {
501          self.read(connection, id);
502        }
503      }
504      _ => {}
505    }
506  }
507
508  /// This shell owns the watcher's name now, from the start or after
509  /// another watcher left. The items already known stay.
510  fn become_watcher(&self, connection: &Connection) {
511    tracing::info!("running the tray watcher");
512    if let Some(state) = self.state.borrow_mut().as_mut() {
513      state.watcher = true;
514    }
515    self.emit(connection, "StatusNotifierHostRegistered", None);
516  }
517
518  /// Adds the item a watcher's list or signal names. A bad name is logged.
519  fn add_registered(self: &Rc<Self>, connection: &Connection, service: &str) {
520    match parse_service(service, None) {
521      Some((bus, path)) => self.add(connection, bus, path),
522      None => tracing::warn!(service, "the tray watcher named a bad item; skipped"),
523    }
524  }
525
526  /// Starts showing the item at `bus` and `path`, unless it is already
527  /// there: the bus is asked who owns `bus`, then the item is read.
528  fn add(self: &Rc<Self>, connection: &Connection, bus: String, path: String) {
529    let id = format!("{bus}{path}");
530    let mut state = self.state.borrow_mut();
531    let Some(state) = state.as_mut() else {
532      return;
533    };
534    if state.entries.iter().any(|entry| entry.id == id) {
535      return;
536    }
537    state.entries.push(Entry {
538      id: id.clone(),
539      bus: bus.clone(),
540      path,
541      owner: None,
542      reads: 0,
543      shown: None,
544    });
545    if state.watcher {
546      self.emit(connection, "StatusNotifierItemRegistered", Some(id.clone()));
547    }
548    let service = Rc::clone(self);
549    let connection = connection.clone();
550    self.shared.spawn(async move {
551      // Before asking for the owner, so a leave in between is still seen.
552      if let Err(error) = bus_call(&connection, "AddMatch", &owner_rule(&bus)).await {
553        tracing::warn!(id, %error, "can't watch a tray item's name");
554      }
555      let ask = connection.call_method(Some(BUS), BUS_PATH, Some(BUS), "GetNameOwner", &bus);
556      let owner = ask
557        .await
558        .and_then(|reply| reply.body().deserialize::<String>());
559      match owner {
560        Ok(owner) => {
561          service.with_entry(&id, |entry| entry.owner = Some(owner));
562          service.read(&connection, id);
563        }
564        Err(error) => {
565          tracing::info!(id, %error, "a tray item left before it was read");
566          service.remove(&connection, &id);
567        }
568      }
569    });
570  }
571
572  /// Reads item `id`'s properties again and shows them. A failed read is
573  /// logged, and the item keeps what it showed.
574  fn read(self: &Rc<Self>, connection: &Connection, id: String) {
575    let read = self.with_entry(&id, |entry| {
576      entry.reads += 1;
577      (entry.bus.clone(), entry.path.clone(), entry.reads)
578    });
579    let Some((bus, path, read)) = read else {
580      return;
581    };
582    let service = Rc::clone(self);
583    let connection = connection.clone();
584    self.shared.spawn(async move {
585      let get_all = connection.call_method(
586        Some(bus.as_str()),
587        path.as_str(),
588        Some(PROPERTIES),
589        "GetAll",
590        &ITEM,
591      );
592      let properties = service
593        .shared
594        .timeout(CALL_TIMEOUT, get_all)
595        .await
596        .and_then(|reply| reply.body().deserialize::<Properties>());
597      let item = match properties {
598        Ok(properties) => properties.into_item(id.clone()),
599        Err(error) => return tracing::warn!(id, %error, "can't read a tray item"),
600      };
601      let newest = service.with_entry(&id, |entry| {
602        if entry.reads == read {
603          entry.shown = Some(item);
604        }
605      });
606      if newest.is_some() {
607        service.publish();
608      }
609    });
610  }
611
612  /// A name an item is at got a new owner, or none when it left the bus.
613  fn owner_changed(self: &Rc<Self>, connection: &Connection, name: &str, owner: &str) {
614    let ids: Vec<String> = {
615      let state = self.state.borrow();
616      let entries = state.as_ref().map(|state| state.entries.as_slice());
617      let at_name = entries
618        .unwrap_or_default()
619        .iter()
620        .filter(|entry| entry.bus == name);
621      at_name.map(|entry| entry.id.clone()).collect()
622    };
623    for id in ids {
624      if owner.is_empty() {
625        self.remove(connection, &id);
626      } else {
627        // Its menu's signals now come from the new owner.
628        self.close_menu_of(&id);
629        self.with_entry(&id, |entry| entry.owner = Some(owner.to_owned()));
630        self.read(connection, id);
631      }
632    }
633  }
634
635  /// Stops showing item `id`. One that isn't there is ignored.
636  fn remove(&self, connection: &Connection, id: &str) {
637    let removed = {
638      let mut state = self.state.borrow_mut();
639      let Some(state) = state.as_mut() else {
640        return;
641      };
642      let at = state.entries.iter().position(|entry| entry.id == id);
643      at.map(|at| (state.entries.remove(at), state.watcher))
644    };
645    let Some((entry, watcher)) = removed else {
646      return;
647    };
648    self.close_menu_of(id);
649    let rule = owner_rule(&entry.bus);
650    let bus = connection.clone();
651    self.shared.spawn(async move {
652      if let Err(error) = bus_call(&bus, "RemoveMatch", &rule).await {
653        tracing::debug!(%error, "a tray item's name match was already gone");
654      }
655    });
656    if watcher {
657      self.emit(connection, "StatusNotifierItemUnregistered", Some(entry.id));
658    }
659    self.publish();
660  }
661
662  /// Sets the list from the items read so far, if it changed.
663  fn publish(&self) {
664    let state = self.state.borrow();
665    let Some(state) = state.as_ref() else {
666      return;
667    };
668    let list: Vec<TrayItem> = state
669      .entries
670      .iter()
671      .filter_map(|entry| entry.shown.clone())
672      .collect();
673    if state.items.try_with(|old| *old != list) == Some(true) {
674      state.items.set(list);
675    }
676  }
677
678  /// Empties the list, once the bus is gone.
679  fn clear(&self) {
680    self.close_menu();
681    if let Some(state) = self.state.borrow_mut().as_mut() {
682      state.entries.clear();
683      state.connection = None;
684    }
685    self.publish();
686  }
687
688  /// Sends one of the watcher's signals, with an item's id or nothing.
689  fn emit(&self, connection: &Connection, signal: &'static str, id: Option<String>) {
690    let connection = connection.clone();
691    self.shared.spawn(async move {
692      let sent = match id {
693        Some(id) => {
694          let emit = connection.emit_signal(None::<&str>, WATCHER_PATH, WATCHER, signal, &id);
695          emit.await
696        }
697        None => {
698          let emit = connection.emit_signal(None::<&str>, WATCHER_PATH, WATCHER, signal, &());
699          emit.await
700        }
701      };
702      if let Err(error) = sent {
703        tracing::warn!(signal, %error, "the tray watcher couldn't send a signal");
704      }
705    });
706  }
707}
708
709/// Joins the session bus at `address` and acts on what arrives, until the
710/// bus closes or the list is disposed.
711async fn run(service: Rc<Service>, address: zbus::Address) {
712  // A call to an app that hangs fails after this, instead of keeping its
713  // future forever. There is no timer while no call is out.
714  let builder = zbus::connection::Builder::address(address);
715  let connection = match builder.build().await {
716    Ok(connection) => connection,
717    Err(error) => {
718      return tracing::info!(%error, "can't reach the session bus, so the tray is empty");
719    }
720  };
721  // Made before anything is asked, so no reply or signal is missed.
722  let mut messages = MessageStream::from(&connection);
723  if let Some(state) = service.state.borrow_mut().as_mut() {
724    state.connection = Some(connection.clone());
725  }
726  if let Err(error) = service.join(&connection).await {
727    return tracing::warn!(%error, "couldn't join the tray on the session bus");
728  }
729  loop {
730    let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
731    if !service.alive() {
732      return service.clear();
733    }
734    match next {
735      Some(Ok(message)) => service.on_message(&connection, &message).await,
736      Some(Err(error)) => tracing::warn!(%error, "a bad message on the session bus"),
737      None => break,
738    }
739  }
740  tracing::warn!("the session bus closed, so the tray is empty");
741  service.clear();
742}
743
744/// Calls the bus itself, such as `AddMatch` with a match rule.
745async fn bus_call(connection: &Connection, method: &str, rule: &str) -> zbus::Result<()> {
746  let call = connection.call_method(Some(BUS), BUS_PATH, Some(BUS), method, &rule);
747  call.await.map(drop)
748}
749
750/// The match rule that says when `bus` gets a new owner or none. `bus`
751/// is a checked bus name, so it holds no quote.
752fn owner_rule(bus: &str) -> String {
753  format!("type='signal',sender='{BUS}',interface='{BUS}',member='NameOwnerChanged',arg0='{bus}'")
754}
755
756/// The bus name and object path a registration names. A path alone is
757/// at `sender`, as Ayatana and Electron apps register. A name alone is at
758/// `/StatusNotifierItem`. A watcher's list joins the two, as in
759/// `:1.42/org/ayatana/NotificationItem/nm`. `None` for anything else.
760fn parse_service(service: &str, sender: Option<&str>) -> Option<(String, String)> {
761  let (bus, path) = match service.find('/') {
762    Some(0) => (sender?, service),
763    Some(at) => service.split_at(at),
764    None => (service, ITEM_PATH),
765  };
766  BusName::try_from(bus).ok()?;
767  ObjectPath::try_from(path).ok()?;
768  Some((bus.to_owned(), path.to_owned()))
769}
770
771/// An icon in the spec's `a(iiay)`: width, height and ARGB32 bytes.
772type Pixmaps = Vec<(i32, i32, Vec<u8>)>;
773
774/// The properties an item's `GetAll` returns that the tray reads. One of
775/// the wrong type fails the whole read.
776#[derive(Default, DeserializeDict, Type)]
777#[cfg_attr(test, derive(Clone, zbus::SerializeDict))]
778#[zbus(signature = "a{sv}", rename_all = "PascalCase")]
779struct Properties {
780  id: Option<String>,
781  title: Option<String>,
782  status: Option<String>,
783  icon_name: Option<String>,
784  icon_pixmap: Option<Pixmaps>,
785  attention_icon_name: Option<String>,
786  icon_theme_path: Option<String>,
787  /// Icon name, icon pixmaps, title and description.
788  tool_tip: Option<(String, Pixmaps, String, String)>,
789  item_is_menu: Option<bool>,
790  menu: Option<zbus::OwnedObjectPath>,
791}
792
793impl Properties {
794  fn into_item(self, id: String) -> TrayItem {
795    let status = match self.status.as_deref() {
796      Some("Passive") => TrayStatus::Passive,
797      Some("NeedsAttention") => TrayStatus::NeedsAttention,
798      Some("Active") | None => TrayStatus::Active,
799      Some(other) => {
800        tracing::debug!(
801          id,
802          status = other,
803          "a tray item's status is unknown; taken as Active"
804        );
805        TrayStatus::Active
806      }
807    };
808    TrayItem {
809      icon_pixmaps: pixmaps(&id, self.icon_pixmap.unwrap_or_default()),
810      app_id: self.id.unwrap_or_default(),
811      title: self.title.unwrap_or_default(),
812      status,
813      icon_name: self.icon_name.unwrap_or_default(),
814      attention_icon_name: self.attention_icon_name.unwrap_or_default(),
815      icon_theme_path: self.icon_theme_path.unwrap_or_default(),
816      tooltip: self
817        .tool_tip
818        .map(|(_, _, title, _)| title)
819        .unwrap_or_default(),
820      item_is_menu: self.item_is_menu.unwrap_or_default(),
821      // Some apps name this path for "no menu".
822      menu: self
823        .menu
824        .map(|path| path.to_string())
825        .filter(|path| path != "/NO_DBUSMENU")
826        .unwrap_or_default(),
827      id,
828    }
829  }
830}
831
832/// The pixmaps a bar can draw. One that is empty, over 512 px a side, or
833/// whose bytes don't match its size is logged and skipped. The smallest
834/// are kept first, up to [`MAX_PIXMAP_BYTES`] in all.
835fn pixmaps(id: &str, raw: Pixmaps) -> Vec<IconPixels> {
836  let side = 1..=MAX_PIXMAP_SIDE;
837  let mut kept: Vec<IconPixels> = raw
838    .into_iter()
839    .filter_map(|(width, height, data)| {
840      let fits = side.contains(&width) && side.contains(&height);
841      // Both sides are positive and at most 512, so the casts are exact.
842      if !fits || data.len() != width as usize * height as usize * 4 {
843        let bytes = data.len();
844        tracing::debug!(
845          id,
846          width,
847          height,
848          bytes,
849          "a tray icon pixmap of a bad size; skipped"
850        );
851        return None;
852      }
853      let (width, height) = (width as u32, height as u32);
854      Some(IconPixels {
855        width,
856        height,
857        data: data.into(),
858      })
859    })
860    .collect();
861  kept.sort_by_key(|pixels| pixels.data.len());
862  let mut total = 0;
863  kept.retain(|pixels| {
864    total += pixels.data.len();
865    total <= MAX_PIXMAP_BYTES
866  });
867  kept
868}