/*
* the system tray, right of the workspaces. tray apps (StatusNotifierItem) register
* with a watcher on the session bus; nothing on this desktop runs one, so the shell
* owns org.kde.StatusNotifierWatcher and serves it, and is its own host. each app's
* icon comes from its IconName (looked up in the icon themes) or its IconPixmap
* (written out as a png, see png.rs). a left click activates the app (or opens its
* menu if that's all it has), a right click opens its menu (com.canonical.dbusmenu)
* drawn here in the island's look, a middle click is the app's secondary action, a
* scroll its scroll. everything on the bus happens on threads, never while drawing
*/
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError, mpsc};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::{env, fs, thread};
use amane::{
Animation, Argument, Blend, Bus, Button, Center, Color, Column, Full, Horizontal, Image, InputArea, Layer,
LayerWindow, Margin, Monitor, Padding, Parent, Pointer, Rectangle, Row, Scroll, Service, SpaceBetween,
Start, Text, Value, Vertical, Weight, Widget, Zone,
};
use crate::config::appearance::{motion, overlays, text};
use crate::config::island::pill as island_pill;
use crate::config::tray::{menu, pill};
use crate::glyph::{Glyph, icon};
use crate::island::{CLEAR, faded, veiled};
use crate::{edge, parse, png, surface, theme};
const WATCHER: &str = "org.kde.StatusNotifierWatcher";
const WATCHER_PATH: &str = "/StatusNotifierWatcher";
const ITEM: &str = "org.kde.StatusNotifierItem";
const MENU: &str = "com.canonical.dbusmenu";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
// the tray's pill (a slot per icon, like the workspaces' slots) and its menu are settings (config.rs: tray.*)
const INTROSPECTION: &str = r#"
"#;
// one app in the tray
#[derive(Clone, PartialEq)]
pub struct Item {
// what RegisteredStatusNotifierItems lists: its bus name and object path, together
pub key: String,
bus: String,
path: String,
// its unique name (":1.42"), which its signals come from
owner: String,
pub title: String,
pub icon: Option,
// NeedsAttention: a ring around it; Passive: not shown
pub attention: bool,
passive: bool,
// its dbusmenu's object path, and whether a click should open that rather than activate
menu: String,
only_menu: bool,
}
#[derive(Default)]
pub struct Tray {
pub items: Vec,
}
impl Service for Tray {
fn new() -> Self {
Self::default()
}
fn listen() {
let bus = Bus::session();
// the calls are watched before the name is taken, so none is missed
let calls = bus.methods(WATCHER_PATH, WATCHER);
let properties = bus.methods(WATCHER_PATH, PROPERTIES);
let introspection = bus.methods(WATCHER_PATH, "org.freedesktop.DBus.Introspectable");
// another watcher runs (another bar): leave it be, and show nothing
if !bus.own(WATCHER) {
return;
}
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierHostRegistered", &[]);
thread::spawn(move || {
for call in properties {
let value = |name: &str| match name {
"RegisteredStatusNotifierItems" => Some(Argument::TextList(Tray::read().items.iter().map(|item| item.key.clone()).collect())),
"IsStatusNotifierHostRegistered" => Some(Argument::Bool(true)),
"ProtocolVersion" => Some(Argument::Int(0)),
_ => None,
};
match call.name() {
"Get" => {
let name = call.arguments().get(1).map(|name| name.text().to_string()).unwrap_or_default();
match value(&name) {
Some(value) => call.reply(&[Argument::Variant(Box::new(value))]),
None => call.reply(&[]),
}
}
"GetAll" => {
let all = ["RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"]
.into_iter()
.filter_map(|name| Some((name.to_string(), value(name)?)))
.collect();
call.reply(&[Argument::Map(all)]);
}
_ => call.reply(&[]),
}
}
});
thread::spawn(move || {
for call in introspection {
call.reply(&[Argument::from(INTROSPECTION)]);
}
});
// an app that quits leaves the bus: its icon goes
thread::spawn(|| {
for signal in Bus::session().signals("org.freedesktop.DBus", "NameOwnerChanged") {
let arguments = signal.arguments();
let (name, gone) = (arguments.first().map_or("", Value::text), arguments.get(2).is_some_and(|owner| owner.text().is_empty()));
if gone && !name.is_empty() {
forget(name);
}
}
});
// an app that changed its icon, title or status says so
for change in ["NewIcon", "NewAttentionIcon", "NewStatus", "NewTitle", "NewToolTip", "NewMenu"] {
thread::spawn(move || {
for signal in Bus::session().signals(ITEM, change) {
let stale: Vec = Tray::read().items.iter().filter(|item| item.owner == signal.sender() && item.path == signal.path()).cloned().collect();
for item in stale {
refresh(&item.bus, &item.path, &item.owner);
}
}
});
}
for call in calls {
let name = call.name().to_string();
let service = call.arguments().first().map(|service| service.text().to_string()).unwrap_or_default();
// answered at once: the app waits for it
call.reply(&[]);
if name == "RegisterStatusNotifierItem" && !service.is_empty() {
thread::spawn(move || register(&service));
}
}
}
}
/*
* an app's registration: a bus name (its item at /StatusNotifierItem), or, from
* libappindicator, only the object path. amane doesn't say who called, so for a path
* the newest connections are asked which one has that item
*/
fn register(service: &str) {
let bus = Bus::session();
let (name, path) = if service.starts_with('/') {
let Some(owner) = find_owner(&bus, service) else {
return;
};
(owner, service.to_string())
} else {
match service.split_once('/') {
Some((name, path)) => (name.to_string(), format!("/{path}")),
None => (service.to_string(), String::from("/StatusNotifierItem")),
}
};
let owner = if name.starts_with(':') {
name.clone()
} else {
bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner", &[Argument::from(name.as_str())]).text().to_string()
};
if owner.is_empty() {
return;
}
refresh(&name, &path, &owner);
let key = format!("{name}{path}");
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemRegistered", &[Argument::from(key)]);
}
/*
* the connection that serves an item at this path. asked all at once, first answer
* wins: some connections never answer a call they don't know (the shell's own among
* them), and each of those would hold a one-by-one search for the bus's whole timeout
*/
fn find_owner(bus: &Bus, path: &str) -> Option {
let names = bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames", &[]);
let (found, answers) = mpsc::channel();
for name in names.list().iter().map(Value::text).filter(|name| name.starts_with(':')) {
let (name, path, found) = (name.to_string(), path.to_string(), found.clone());
thread::spawn(move || {
let answer = Bus::session().call(&name, &path, PROPERTIES, "Get", &[Argument::from(ITEM), Argument::from("Id")]);
if !matches!(answer, Value::Nothing) {
let _ = found.send(name);
}
});
}
drop(found);
answers.recv_timeout(Duration::from_secs(5)).ok()
}
// reads an item again and puts it in the tray, or takes it out if it doesn't answer
fn refresh(name: &str, path: &str, owner: &str) {
let key = format!("{name}{path}");
let all = Bus::session().call(name, path, PROPERTIES, "GetAll", &[Argument::from(ITEM)]);
if !matches!(all, Value::Map(_)) {
Tray::write().items.retain(|item| item.key != key);
return;
}
let text = |property: &str| all.get(property).text().to_string();
let status = text("Status");
let attention = status == "NeedsAttention";
// the attention icon while it wants attention, else the icon; a name before pixels
let (name_property, pixmap_property) = if attention && !text("AttentionIconName").is_empty() { ("AttentionIconName", "AttentionIconPixmap") } else { ("IconName", "IconPixmap") };
let icon = icon_file(&text(name_property), &text("IconThemePath")).or_else(|| pixmap_file(all.get(pixmap_property)));
// the tooltip's title is often the clearest name
let tooltip = all.get("ToolTip").list().get(2).map(|title| title.text().to_string()).unwrap_or_default();
let title = [text("Title"), tooltip, text("Id")].into_iter().find(|title| !title.is_empty()).unwrap_or_default();
let item = Item {
key: key.clone(),
bus: name.to_string(),
path: path.to_string(),
owner: owner.to_string(),
title,
icon,
attention,
passive: status == "Passive",
menu: text("Menu"),
only_menu: all.get("ItemIsMenu").bool(),
};
let mut tray = Tray::write();
match tray.items.iter_mut().find(|known| known.key == key) {
Some(known) if *known == item => {}
Some(known) => *known = item,
None => tray.items.push(item),
}
}
fn forget(name: &str) {
let gone: Vec = Tray::read().items.iter().filter(|item| item.owner == name || item.bus == name).map(|item| item.key.clone()).collect();
if gone.is_empty() {
return;
}
Tray::write().items.retain(|item| !gone.contains(&item.key));
let bus = Bus::session();
for key in gone {
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemUnregistered", &[Argument::from(key)]);
}
}
// icons
/*
* an icon name as a file: the app's own theme folder first, then the icon themes
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
* per name, the answer kept
*/
pub fn icon_file(name: &str, theme_path: &str) -> Option {
static FOUND: Mutex