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