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