1mod 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
47const MAX_PIXMAP_SIDE: i32 = 512;
49
50const MAX_PIXMAP_BYTES: usize = 512 * 512 * 4;
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub enum TrayStatus {
57 Passive,
59 #[default]
61 Active,
62 NeedsAttention,
65}
66
67#[derive(Clone, Debug, Default, PartialEq)]
69pub struct TrayItem {
70 pub id: String,
74 pub app_id: String,
77 pub title: String,
79 pub status: TrayStatus,
81 pub icon_name: String,
83 pub icon_pixmaps: Vec<IconPixels>,
86 pub attention_icon_name: String,
88 pub icon_theme_path: String,
90 pub tooltip: String,
92 pub item_is_menu: bool,
95 pub menu: String,
98}
99
100impl TrayItem {
101 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
131fn 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#[derive(Clone)]
147pub struct TrayHost {
148 service: Option<Rc<Service>>,
150 items: ReadSignal<Vec<TrayItem>>,
151 menu: ReadSignal<Option<TrayMenu>>,
152}
153
154impl TrayHost {
155 pub fn items(&self) -> ReadSignal<Vec<TrayItem>> {
158 self.items
159 }
160
161 pub fn activate(&self, id: &str) {
165 self.call(id, ITEM, "Activate", (0, 0));
166 }
167
168 pub fn secondary_activate(&self, id: &str) {
170 self.call(id, ITEM, "SecondaryActivate", (0, 0));
171 }
172
173 pub fn scroll(&self, id: &str, steps: i32) {
176 self.call(id, ITEM, "Scroll", (steps, "vertical"));
177 }
178
179 pub fn menu(&self) -> ReadSignal<Option<TrayMenu>> {
184 self.menu
185 }
186
187 pub fn open_menu(&self, id: &str) {
191 if let Some(service) = &self.service {
192 service.open_menu(id);
193 }
194 }
195
196 pub fn open_submenu(&self, entry: i32) {
200 if let Some(service) = &self.service {
201 service.open_submenu(entry);
202 }
203 }
204
205 pub fn close_menu(&self) {
207 if let Some(service) = &self.service {
208 service.close_menu();
209 }
210 }
211
212 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 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
257pub(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
278pub(crate) struct Service {
280 shared: Rc<Shared>,
281 state: RefCell<Option<State>>,
282}
283
284struct State {
285 items: Signal<Vec<TrayItem>>,
286 connection: Option<Connection>,
288 entries: Vec<Entry>,
290 watcher: bool,
292 menu: Signal<Option<TrayMenu>>,
294 open: Option<OpenMenu>,
296 menu_count: u64,
299}
300
301struct Entry {
302 id: String,
303 bus: String,
304 path: String,
305 owner: Option<String>,
308 reads: u64,
310 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 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 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 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 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 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 let reply = connection
400 .request_name_with_flags(WATCHER, RequestNameFlags::empty())
401 .await?;
402 if reply == RequestNameReply::InQueue {
403 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 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 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 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 (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 (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 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 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 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 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 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 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 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 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 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 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 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
703async fn run(service: Rc<Service>, address: zbus::Address) {
706 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 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
738fn 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
753type Pixmaps = Vec<(i32, i32, Vec<u8>)>;
755
756#[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 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 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
814fn 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 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}