mod menu;
mod watcher;
mod widget;
#[cfg(test)]
mod tests;
use std::cell::RefCell;
use std::future::poll_fn;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Once;
use crabshell_reactive::{ReadSignal, Signal};
use crabshell_ui::{Cx, IconPixels, IconSource};
use zbus::export::futures_core::Stream;
use zbus::fdo::{RequestNameFlags, RequestNameReply};
use zbus::message::Type as Kind;
use zbus::names::BusName;
use zbus::{Connection, DeserializeDict, DynamicType, Message, MessageStream, ObjectPath, Type};
use crate::Shared;
use crate::dbus::{BUS, BUS_PATH, CALL_TIMEOUT, PROPERTIES, bus_call, owner_rule};
use menu::{DBUSMENU, OpenMenu};
pub use menu::{MenuEntry, MenuToggle, TrayMenu};
pub use widget::Tray;
const WATCHER: &str = "org.kde.StatusNotifierWatcher";
const WATCHER_PATH: &str = "/StatusNotifierWatcher";
const ITEM: &str = "org.kde.StatusNotifierItem";
const ITEM_PATH: &str = "/StatusNotifierItem";
const MAX_PIXMAP_SIDE: i32 = 512;
const MAX_PIXMAP_BYTES: usize = 512 * 512 * 4;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TrayStatus {
Passive,
#[default]
Active,
NeedsAttention,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TrayCategory {
#[default]
ApplicationStatus,
Communications,
SystemServices,
Hardware,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TrayItem {
pub id: String,
pub app_id: String,
pub title: String,
pub status: TrayStatus,
pub icon_name: String,
pub icon_pixmaps: Vec<IconPixels>,
pub attention_icon_name: String,
pub icon_theme_path: String,
pub tooltip: String,
pub item_is_menu: bool,
pub menu: String,
pub category: TrayCategory,
}
impl TrayItem {
pub fn icon(&self, size: u32) -> Option<IconSource> {
let name = match self.status {
TrayStatus::NeedsAttention if !self.attention_icon_name.is_empty() => {
&self.attention_icon_name
}
_ => &self.icon_name,
};
if let Some(file) = app_icon_file(Path::new(&self.icon_theme_path), name) {
return Some(IconSource::File(file));
}
let closest = self
.icon_pixmaps
.iter()
.min_by_key(|pixels| pixels.width.max(pixels.height).abs_diff(size));
match closest {
Some(pixels) if name.is_empty() || !self.icon_theme_path.is_empty() => {
Some(IconSource::Pixels(pixels.clone()))
}
_ => (!name.is_empty()).then(|| IconSource::Name(name.clone())),
}
}
}
fn app_icon_file(dir: &Path, name: &str) -> Option<PathBuf> {
let safe = !matches!(name, "" | "." | "..") && !name.contains(['/', '\0']);
if !dir.is_absolute() || !safe {
return None;
}
["png", "svg"]
.into_iter()
.map(|extension| dir.join(format!("{name}.{extension}")))
.find(|path| path.is_file())
}
#[derive(Clone)]
pub struct TrayHost {
service: Option<Rc<Service>>,
items: ReadSignal<Vec<TrayItem>>,
menu: ReadSignal<Option<TrayMenu>>,
}
impl TrayHost {
pub fn items(&self) -> ReadSignal<Vec<TrayItem>> {
self.items
}
pub fn activate(&self, id: &str) {
self.call(id, ITEM, "Activate", (0, 0));
}
pub fn secondary_activate(&self, id: &str) {
self.call(id, ITEM, "SecondaryActivate", (0, 0));
}
pub fn scroll(&self, id: &str, steps: i32) {
self.call(id, ITEM, "Scroll", (steps, "vertical"));
}
pub fn menu(&self) -> ReadSignal<Option<TrayMenu>> {
self.menu
}
pub fn open_menu(&self, id: &str) {
if let Some(service) = &self.service {
service.open_menu(id);
}
}
pub fn open_submenu(&self, entry: i32) {
if let Some(service) = &self.service {
service.open_submenu(entry);
}
}
pub fn close_menu(&self) {
if let Some(service) = &self.service {
service.close_menu();
}
}
pub fn menu_event(&self, id: &str, entry: i32) {
let event = (entry, "clicked", zbus::Variant::from(0), 0u32);
self.call(id, DBUSMENU, "Event", event);
}
fn call<B>(&self, id: &str, interface: &'static str, method: &'static str, body: B)
where
B: serde::Serialize + DynamicType + 'static,
{
let target = self.service.as_ref().and_then(|service| {
let connection = service.state.borrow().as_ref()?.connection.clone()?;
let at = service.with_entry(id, |entry| {
let path = match interface {
ITEM => entry.path.clone(),
_ => entry.shown.as_ref()?.menu.clone(),
};
Some((entry.bus.clone(), path)).filter(|(_, path)| !path.is_empty())
});
let (bus, path) = at.flatten()?;
Some((service, connection, bus, path))
});
let Some((service, connection, bus, path)) = target else {
return tracing::warn!(id, method, "no such tray item");
};
let id = id.to_owned();
let shared = Rc::clone(&service.shared);
service.shared.spawn(async move {
let call = connection.call_method(
Some(bus.as_str()),
path.as_str(),
Some(interface),
method,
&body,
);
if let Err(error) = shared.timeout(CALL_TIMEOUT, call).await {
tracing::info!(id, method, %error, "the tray item refused");
}
});
}
}
pub(crate) fn tray(cx: &Cx) -> TrayHost {
let Some(service) = cx.service::<Service>() else {
static LOGGED: Once = Once::new();
LOGGED.call_once(|| {
tracing::warn!("cx.tray() needs the services the shell provides; the tray stays empty");
});
return TrayHost {
service: None,
items: cx.signal(Vec::new()).read_only(),
menu: cx.signal(None).read_only(),
};
};
let (items, menu) = service.start();
TrayHost {
service: Some(service),
items,
menu,
}
}
pub(crate) struct Service {
shared: Rc<Shared>,
state: RefCell<Option<State>>,
}
struct State {
items: Signal<Vec<TrayItem>>,
connection: Option<Connection>,
entries: Vec<Entry>,
watcher: bool,
menu: Signal<Option<TrayMenu>>,
open: Option<OpenMenu>,
menu_count: u64,
}
struct Entry {
id: String,
bus: String,
path: String,
owner: Option<String>,
reads: u64,
shown: Option<TrayItem>,
}
impl Service {
pub(crate) fn new(shared: Rc<Shared>) -> Service {
Service {
shared,
state: RefCell::new(None),
}
}
fn start(self: &Rc<Self>) -> (ReadSignal<Vec<TrayItem>>, ReadSignal<Option<TrayMenu>>) {
if let Some(state) = &*self.state.borrow() {
return (state.items.read_only(), state.menu.read_only());
}
let items = self.shared.root.signal(Vec::new());
let menu = self.shared.root.signal(None);
*self.state.borrow_mut() = Some(State {
items,
connection: None,
entries: Vec::new(),
watcher: false,
menu,
open: None,
menu_count: 0,
});
match self.shared.config.session_bus.clone() {
Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
None => tracing::info!("no session bus, so the tray is empty"),
}
(items.read_only(), menu.read_only())
}
fn alive(&self) -> bool {
let state = self.state.borrow();
let alive = state
.as_ref()
.and_then(|state| state.items.try_with(|_| ()));
alive.is_some()
}
fn is_watcher(&self) -> bool {
self
.state
.borrow()
.as_ref()
.is_some_and(|state| state.watcher)
}
fn ids(&self) -> Vec<String> {
let state = self.state.borrow();
let entries = state.as_ref().map(|state| state.entries.as_slice());
entries
.unwrap_or_default()
.iter()
.map(|entry| entry.id.clone())
.collect()
}
fn with_entry<R>(&self, id: &str, f: impl FnOnce(&mut Entry) -> R) -> Option<R> {
let mut state = self.state.borrow_mut();
let entry = state
.as_mut()?
.entries
.iter_mut()
.find(|entry| entry.id == id)?;
Some(f(entry))
}
async fn join(self: &Rc<Self>, connection: &Connection) -> zbus::Result<()> {
bus_call(
connection,
"AddMatch",
&format!("type='signal',interface='{ITEM}'"),
)
.await?;
let rule = format!("type='signal',sender='{WATCHER}',interface='{WATCHER}'");
bus_call(connection, "AddMatch", &rule).await?;
let host = format!("org.kde.StatusNotifierHost-{}", std::process::id());
connection.request_name(host.as_str()).await?;
let reply = connection
.request_name_with_flags(WATCHER, RequestNameFlags::empty())
.await?;
if reply == RequestNameReply::InQueue {
let (service, connection) = (Rc::clone(self), connection.clone());
self.shared.spawn(async move {
if let Err(error) = service.use_watcher(&connection, &host).await {
tracing::warn!(%error, "the running tray watcher didn't answer");
}
});
}
Ok(())
}
async fn use_watcher(self: &Rc<Self>, connection: &Connection, host: &str) -> zbus::Result<()> {
tracing::info!("another program runs the tray watcher; using it");
let register = "RegisterStatusNotifierHost";
let path = WATCHER_PATH;
let call = connection.call_method(Some(WATCHER), path, Some(WATCHER), register, &host);
self.shared.timeout(CALL_TIMEOUT, call).await?;
let get = (WATCHER, "RegisteredStatusNotifierItems");
let call = connection.call_method(Some(WATCHER), path, Some(PROPERTIES), "Get", &get);
let reply = self.shared.timeout(CALL_TIMEOUT, call).await?;
let list: Vec<String> = reply
.body()
.deserialize::<zbus::Variant<'_>>()?
.downcast()?;
for service in list {
self.add_registered(connection, &service);
}
Ok(())
}
async fn on_message(self: &Rc<Self>, connection: &Connection, message: &Message) {
match message.header().message_type() {
Kind::MethodCall => watcher::answer(self, connection, message).await,
Kind::Signal => self.on_signal(connection, message),
_ => {}
}
}
fn on_signal(self: &Rc<Self>, connection: &Connection, message: &Message) {
let header = message.header();
let (Some(interface), Some(member)) = (header.interface(), header.member()) else {
return;
};
let from_bus = header.sender().is_some_and(|sender| sender.as_str() == BUS);
let broadcast = header.destination().is_none();
let body = message.body();
match (interface.as_str(), member.as_str()) {
(BUS, "NameAcquired") if from_bus && body.deserialize::<&str>().ok() == Some(WATCHER) => {
self.become_watcher(connection);
}
(BUS, "NameOwnerChanged") if from_bus => {
if let Ok((name, _, owner)) = body.deserialize::<(&str, &str, &str)>() {
self.owner_changed(connection, name, owner);
}
}
(WATCHER, "StatusNotifierItemRegistered") if broadcast && !self.is_watcher() => {
if let Ok(service) = body.deserialize::<&str>() {
self.add_registered(connection, service);
}
}
(WATCHER, "StatusNotifierItemUnregistered") if broadcast && !self.is_watcher() => {
if let Ok(id) = body.deserialize::<&str>() {
self.remove(connection, id);
}
}
(DBUSMENU, "LayoutUpdated" | "ItemsPropertiesUpdated") => {
if let (Some(sender), Some(path)) = (header.sender(), header.path()) {
self.menu_changed(connection, sender.as_str(), path.as_str());
}
}
(ITEM, member) if member.starts_with("New") => {
let (sender, path) = (header.sender(), header.path());
let (Some(sender), Some(path)) = (sender, path) else {
return;
};
let state = self.state.borrow();
let entries = state.as_ref().map(|state| state.entries.as_slice());
let ids: Vec<String> = entries
.unwrap_or_default()
.iter()
.filter(|entry| entry.owner.as_deref() == Some(sender.as_str()))
.filter(|entry| entry.path == path.as_str())
.map(|entry| entry.id.clone())
.collect();
drop(state);
for id in ids {
self.read(connection, id);
}
}
_ => {}
}
}
fn become_watcher(&self, connection: &Connection) {
tracing::info!("running the tray watcher");
if let Some(state) = self.state.borrow_mut().as_mut() {
state.watcher = true;
}
self.emit(connection, "StatusNotifierHostRegistered", None);
}
fn add_registered(self: &Rc<Self>, connection: &Connection, service: &str) {
match parse_service(service, None) {
Some((bus, path)) => self.add(connection, bus, path),
None => tracing::warn!(service, "the tray watcher named a bad item; skipped"),
}
}
fn add(self: &Rc<Self>, connection: &Connection, bus: String, path: String) {
let id = format!("{bus}{path}");
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
if state.entries.iter().any(|entry| entry.id == id) {
return;
}
state.entries.push(Entry {
id: id.clone(),
bus: bus.clone(),
path,
owner: None,
reads: 0,
shown: None,
});
if state.watcher {
self.emit(connection, "StatusNotifierItemRegistered", Some(id.clone()));
}
let service = Rc::clone(self);
let connection = connection.clone();
self.shared.spawn(async move {
if let Err(error) = bus_call(&connection, "AddMatch", &owner_rule(&bus)).await {
tracing::warn!(id, %error, "can't watch a tray item's name");
}
let ask = connection.call_method(Some(BUS), BUS_PATH, Some(BUS), "GetNameOwner", &bus);
let owner = ask
.await
.and_then(|reply| reply.body().deserialize::<String>());
match owner {
Ok(owner) => {
service.with_entry(&id, |entry| entry.owner = Some(owner));
service.read(&connection, id);
}
Err(error) => {
tracing::info!(id, %error, "a tray item left before it was read");
service.remove(&connection, &id);
}
}
});
}
fn read(self: &Rc<Self>, connection: &Connection, id: String) {
let read = self.with_entry(&id, |entry| {
entry.reads += 1;
(entry.bus.clone(), entry.path.clone(), entry.reads)
});
let Some((bus, path, read)) = read else {
return;
};
let service = Rc::clone(self);
let connection = connection.clone();
self.shared.spawn(async move {
let get_all = connection.call_method(
Some(bus.as_str()),
path.as_str(),
Some(PROPERTIES),
"GetAll",
&ITEM,
);
let properties = service
.shared
.timeout(CALL_TIMEOUT, get_all)
.await
.and_then(|reply| reply.body().deserialize::<Properties>());
let item = match properties {
Ok(properties) => properties.into_item(id.clone()),
Err(error) => return tracing::warn!(id, %error, "can't read a tray item"),
};
let newest = service.with_entry(&id, |entry| {
if entry.reads == read {
entry.shown = Some(item);
}
});
if newest.is_some() {
service.publish();
}
});
}
fn owner_changed(self: &Rc<Self>, connection: &Connection, name: &str, owner: &str) {
let ids: Vec<String> = {
let state = self.state.borrow();
let entries = state.as_ref().map(|state| state.entries.as_slice());
let at_name = entries
.unwrap_or_default()
.iter()
.filter(|entry| entry.bus == name);
at_name.map(|entry| entry.id.clone()).collect()
};
for id in ids {
if owner.is_empty() {
self.remove(connection, &id);
} else {
self.close_menu_of(&id);
self.with_entry(&id, |entry| entry.owner = Some(owner.to_owned()));
self.read(connection, id);
}
}
}
fn remove(&self, connection: &Connection, id: &str) {
let removed = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
let at = state.entries.iter().position(|entry| entry.id == id);
at.map(|at| (state.entries.remove(at), state.watcher))
};
let Some((entry, watcher)) = removed else {
return;
};
self.close_menu_of(id);
let rule = owner_rule(&entry.bus);
let bus = connection.clone();
self.shared.spawn(async move {
if let Err(error) = bus_call(&bus, "RemoveMatch", &rule).await {
tracing::debug!(%error, "a tray item's name match was already gone");
}
});
if watcher {
self.emit(connection, "StatusNotifierItemUnregistered", Some(entry.id));
}
self.publish();
}
fn publish(&self) {
let state = self.state.borrow();
let Some(state) = state.as_ref() else {
return;
};
let list: Vec<TrayItem> = state
.entries
.iter()
.filter_map(|entry| entry.shown.clone())
.collect();
if state.items.try_with(|old| *old != list) == Some(true) {
state.items.set(list);
}
}
fn clear(&self) {
self.close_menu();
if let Some(state) = self.state.borrow_mut().as_mut() {
state.entries.clear();
state.connection = None;
}
self.publish();
}
fn emit(&self, connection: &Connection, signal: &'static str, id: Option<String>) {
let connection = connection.clone();
self.shared.spawn(async move {
let sent = match id {
Some(id) => {
let emit = connection.emit_signal(None::<&str>, WATCHER_PATH, WATCHER, signal, &id);
emit.await
}
None => {
let emit = connection.emit_signal(None::<&str>, WATCHER_PATH, WATCHER, signal, &());
emit.await
}
};
if let Err(error) = sent {
tracing::warn!(signal, %error, "the tray watcher couldn't send a signal");
}
});
}
}
async fn run(service: Rc<Service>, address: zbus::Address) {
let builder = zbus::connection::Builder::address(address);
let connection = match builder.build().await {
Ok(connection) => connection,
Err(error) => {
return tracing::info!(%error, "can't reach the session bus, so the tray is empty");
}
};
let mut messages = MessageStream::from(&connection);
if let Some(state) = service.state.borrow_mut().as_mut() {
state.connection = Some(connection.clone());
}
if let Err(error) = service.join(&connection).await {
return tracing::warn!(%error, "couldn't join the tray on the session bus");
}
loop {
let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
if !service.alive() {
return service.clear();
}
match next {
Some(Ok(message)) => service.on_message(&connection, &message).await,
Some(Err(error)) => tracing::warn!(%error, "a bad message on the session bus"),
None => break,
}
}
tracing::warn!("the session bus closed, so the tray is empty");
service.clear();
}
fn parse_service(service: &str, sender: Option<&str>) -> Option<(String, String)> {
let (bus, path) = match service.find('/') {
Some(0) => (sender?, service),
Some(at) => service.split_at(at),
None => (service, ITEM_PATH),
};
BusName::try_from(bus).ok()?;
ObjectPath::try_from(path).ok()?;
Some((bus.to_owned(), path.to_owned()))
}
type Pixmaps = Vec<(i32, i32, Vec<u8>)>;
#[derive(Default, DeserializeDict, Type)]
#[cfg_attr(test, derive(Clone, zbus::SerializeDict))]
#[zbus(signature = "a{sv}", rename_all = "PascalCase")]
struct Properties {
id: Option<String>,
title: Option<String>,
status: Option<String>,
icon_name: Option<String>,
icon_pixmap: Option<Pixmaps>,
attention_icon_name: Option<String>,
icon_theme_path: Option<String>,
tool_tip: Option<(String, Pixmaps, String, String)>,
item_is_menu: Option<bool>,
menu: Option<zbus::OwnedObjectPath>,
category: Option<String>,
}
impl Properties {
fn into_item(self, id: String) -> TrayItem {
let status = match self.status.as_deref() {
Some("Passive") => TrayStatus::Passive,
Some("NeedsAttention") => TrayStatus::NeedsAttention,
Some("Active") | None => TrayStatus::Active,
Some(other) => {
tracing::debug!(
id,
status = other,
"a tray item's status is unknown; taken as Active"
);
TrayStatus::Active
}
};
let category = match self.category.as_deref() {
Some("Communications") => TrayCategory::Communications,
Some("SystemServices") => TrayCategory::SystemServices,
Some("Hardware") => TrayCategory::Hardware,
Some("ApplicationStatus") | None => TrayCategory::ApplicationStatus,
Some(other) => {
tracing::debug!(
id,
category = other,
"a tray item's category is unknown; taken as ApplicationStatus"
);
TrayCategory::ApplicationStatus
}
};
TrayItem {
icon_pixmaps: pixmaps(&id, self.icon_pixmap.unwrap_or_default()),
app_id: self.id.unwrap_or_default(),
title: self.title.unwrap_or_default(),
status,
icon_name: self.icon_name.unwrap_or_default(),
attention_icon_name: self.attention_icon_name.unwrap_or_default(),
icon_theme_path: self.icon_theme_path.unwrap_or_default(),
tooltip: self
.tool_tip
.map(|(_, _, title, _)| title)
.unwrap_or_default(),
item_is_menu: self.item_is_menu.unwrap_or_default(),
menu: self
.menu
.map(|path| path.to_string())
.filter(|path| path != "/NO_DBUSMENU")
.unwrap_or_default(),
category,
id,
}
}
}
fn pixmaps(id: &str, raw: Pixmaps) -> Vec<IconPixels> {
let side = 1..=MAX_PIXMAP_SIDE;
let mut kept: Vec<IconPixels> = raw
.into_iter()
.filter_map(|(width, height, data)| {
let fits = side.contains(&width) && side.contains(&height);
if !fits || data.len() != width as usize * height as usize * 4 {
let bytes = data.len();
tracing::debug!(
id,
width,
height,
bytes,
"a tray icon pixmap of a bad size; skipped"
);
return None;
}
let (width, height) = (width as u32, height as u32);
Some(IconPixels {
width,
height,
data: data.into(),
})
})
.collect();
kept.sort_by_key(|pixels| pixels.data.len());
let mut total = 0;
kept.retain(|pixels| {
total += pixels.data.len();
total <= MAX_PIXMAP_BYTES
});
kept
}