use std::os::fd::{AsRawFd, OwnedFd}; use std::sync::Arc; use parking_lot::Mutex; use std::time::Duration; use futures_channel::mpsc; use futures_lite::stream::StreamExt; use pangocairo::cairo; use zbus::object_server::SignalEmitter; use zbus::{Connection, interface, proxy}; #[derive(Clone)] pub struct TrayItem { pub key: String, pub title: String, pub icon_name: String, pub icon_theme_path: String, pub tooltip: String, pub pixmap: Option, } unsafe impl Send for TrayItem {} pub enum TrayCommand { Activate { key: String }, SecondaryActivate { key: String }, ContextMenu { key: String, x: i32, y: i32 }, } pub struct TrayHandle { items: Arc>>, commands: mpsc::UnboundedSender, } impl TrayHandle { pub fn spawn(wake: OwnedFd) -> TrayHandle { let items = Arc::new(Mutex::new(Vec::new())); let (commands, commands_rx) = mpsc::unbounded(); let worker_items = items.clone(); std::thread::spawn(move || worker(wake, worker_items, commands_rx)); TrayHandle { items, commands } } pub fn items(&self) -> Vec { self.items.lock().clone() } pub fn send(&self, cmd: TrayCommand) { let _ = self.commands.unbounded_send(cmd); } } #[derive(Clone, Copy)] enum SignalKind { Title, Icon, Status, ToolTip, IconThemePath, } enum Event { Register { bus_name: String, path: String, announced: String, }, Vanished(String), Signal { key: String, kind: SignalKind, }, } enum Input { Event(Event), Command(TrayCommand), } enum Method { Activate, SecondaryActivate, ContextMenu, } #[derive(Default)] struct WatcherShared { registered: Vec, hosts: Vec, } struct WatcherService { shared: Arc>, events: mpsc::UnboundedSender, } #[interface(name = "org.kde.StatusNotifierWatcher")] impl WatcherService { async fn register_status_notifier_item( &self, service: &str, #[zbus(header)] header: zbus::message::Header<'_>, #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, ) -> zbus::fdo::Result<()> { let sender = header.sender().map(|name| name.as_str()).unwrap_or(""); let (bus_name, path) = parse_registration(service, sender); let announced = bus_name.clone(); { let mut shared = self.shared.lock(); if shared.registered.contains(&announced) { return Ok(()); } shared.registered.push(announced.clone()); } emitter.status_notifier_item_registered(&announced).await?; self.registered_status_notifier_items_changed(&emitter).await?; let _ = self.events.unbounded_send(Event::Register { bus_name, path, announced, }); Ok(()) } async fn register_status_notifier_host( &self, service: &str, #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, ) -> zbus::fdo::Result<()> { { let mut shared = self.shared.lock(); if shared.hosts.iter().any(|host| host == service) { return Ok(()); } shared.hosts.push(service.to_string()); } emitter.status_notifier_host_registered().await?; self.is_status_notifier_host_registered_changed(&emitter).await?; Ok(()) } #[zbus(property)] fn registered_status_notifier_items(&self) -> Vec { self.shared.lock().registered.clone() } #[zbus(property)] fn is_status_notifier_host_registered(&self) -> bool { !self.shared.lock().hosts.is_empty() } #[zbus(property(emits_changed_signal = "const"))] fn protocol_version(&self) -> i32 { 0 } #[zbus(signal)] async fn status_notifier_item_registered( emitter: &SignalEmitter<'_>, service: &str, ) -> zbus::Result<()>; #[zbus(signal)] async fn status_notifier_item_unregistered( emitter: &SignalEmitter<'_>, service: &str, ) -> zbus::Result<()>; #[zbus(signal)] async fn status_notifier_host_registered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; #[zbus(signal)] async fn status_notifier_host_unregistered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; } #[proxy( interface = "org.kde.StatusNotifierItem", gen_blocking = false, assume_defaults = false )] trait StatusNotifierItem { fn activate(&self, x: i32, y: i32) -> zbus::Result<()>; fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>; fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>; #[zbus(property(emits_changed_signal = "false"))] fn id(&self) -> zbus::Result; #[zbus(property(emits_changed_signal = "false"))] fn title(&self) -> zbus::Result; #[zbus(property(emits_changed_signal = "false"))] fn status(&self) -> zbus::Result; #[zbus(property(emits_changed_signal = "false"))] fn icon_name(&self) -> zbus::Result; #[zbus(property(emits_changed_signal = "false"))] fn icon_theme_path(&self) -> zbus::Result; #[zbus(property(emits_changed_signal = "false"))] fn tool_tip(&self) -> zbus::Result<(String, Vec<(i32, i32, Vec)>, String, String)>; #[zbus(property(emits_changed_signal = "false"))] fn icon_pixmap(&self) -> zbus::Result)>>; #[zbus(signal)] fn new_title(&self); #[zbus(signal)] fn new_icon(&self); #[zbus(signal)] fn new_status(&self); #[zbus(signal)] fn new_tool_tip(&self); #[zbus(signal)] fn new_icon_theme_path(&self); } struct ActiveItem { key: String, bus_name: String, unique_name: String, announced: String, status: String, id: String, proxy: StatusNotifierItemProxy<'static>, item: TrayItem, _tasks: Vec>, } fn worker( wake: OwnedFd, snapshot: Arc>>, mut commands: mpsc::UnboundedReceiver, ) { loop { match futures_lite::future::block_on(run(&wake, &snapshot, &mut commands)) { Ok(()) => break, Err(err) => eprintln!("i3bar-mango: tray: {err}; retrying in 5s"), } std::thread::sleep(Duration::from_secs(5)); } } async fn run( wake: &OwnedFd, snapshot: &Arc>>, commands: &mut mpsc::UnboundedReceiver, ) -> zbus::Result<()> { let shared = Arc::new(Mutex::new(WatcherShared::default())); let (events_tx, mut events_rx) = mpsc::unbounded::(); let conn = zbus::connection::Builder::session()? .serve_at( "/StatusNotifierWatcher", WatcherService { shared: shared.clone(), events: events_tx.clone(), }, )? .name("org.kde.StatusNotifierWatcher")? .name(format!( "org.kde.StatusNotifierHost-i3bar-mango-{}", std::process::id() ))? .build() .await?; let dbus = zbus::fdo::DBusProxy::new(&conn).await?; let owner_stream = dbus.receive_name_owner_changed().await?; let tx = events_tx.clone(); conn.executor() .spawn( async move { futures_lite::pin!(owner_stream); while let Some(signal) = owner_stream.next().await { if let Ok(args) = signal.args() { if args.new_owner().is_none() { let _ = tx.unbounded_send(Event::Vanished(args.name().to_string())); } } } }, "tray-name-owner", ) .detach(); let mut items: Vec = Vec::new(); loop { let input = futures_lite::future::or( async { events_rx.next().await.map(Input::Event) }, async { commands.next().await.map(Input::Command) }, ) .await; match input { None => return Ok(()), Some(Input::Command(cmd)) => { let (key, method, x, y) = match cmd { TrayCommand::Activate { key } => (key, Method::Activate, 0, 0), TrayCommand::SecondaryActivate { key } => (key, Method::SecondaryActivate, 0, 0), TrayCommand::ContextMenu { key, x, y } => (key, Method::ContextMenu, x, y), }; if let Some(active) = items.iter().find(|item| item.key == key) { let proxy = active.proxy.clone(); conn.executor() .spawn( async move { let result = match method { Method::Activate => proxy.activate(0, 0).await, Method::SecondaryActivate => { proxy.secondary_activate(0, 0).await } Method::ContextMenu => proxy.context_menu(x, y).await, }; if let Err(err) = result { eprintln!("i3bar-mango: tray: command failed: {err}"); } }, "tray-command", ) .detach(); } } Some(Input::Event(Event::Register { bus_name, path, announced, })) => { let key = format!("{bus_name}/{path}"); if items.iter().any(|item| item.key == key) { continue; } let builder = StatusNotifierItemProxy::builder(&conn) .destination(bus_name.clone()) .and_then(|builder| builder.path(path.clone())); let proxy = match builder { Ok(builder) => match builder.build().await { Ok(proxy) => proxy, Err(err) => { eprintln!("i3bar-mango: tray: failed to reach {key}: {err}"); unregister(&conn, &shared, &announced).await; continue; } }, Err(err) => { eprintln!("i3bar-mango: tray: invalid registration {key}: {err}"); unregister(&conn, &shared, &announced).await; continue; } }; let id = match proxy.id().await { Ok(id) => id, Err(err) => { eprintln!("i3bar-mango: tray: failed to read properties of {key}: {err}"); unregister(&conn, &shared, &announced).await; continue; } }; let title = proxy.title().await.unwrap_or_default(); let icon_name = proxy.icon_name().await.unwrap_or_default(); let icon_theme_path = proxy.icon_theme_path().await.unwrap_or_default(); let status = proxy.status().await.unwrap_or_default(); let tooltip = proxy .tool_tip() .await .map(|tooltip| strip_html(&tooltip.3)) .unwrap_or_default(); let pixmap = match proxy.icon_pixmap().await { Ok(pixmaps) => best_pixmap(&pixmaps) .and_then(|(width, height, data)| pixmap_to_surface(*width, *height, data)), Err(_) => None, }; let title = if title.is_empty() { id.clone() } else { title }; let unique_name = if bus_name.starts_with(':') { bus_name.clone() } else { match zbus::names::BusName::try_from(bus_name.as_str()) { Ok(name) => dbus .get_name_owner(name) .await .map(|owner| owner.to_string()) .unwrap_or_default(), Err(_) => String::new(), } }; let tasks = spawn_signal_tasks(&conn, &proxy, &key, &events_tx).await; items.push(ActiveItem { key: key.clone(), bus_name, unique_name, announced, status, id, proxy, item: TrayItem { key, title, icon_name, icon_theme_path, tooltip, pixmap, }, _tasks: tasks, }); update_snapshot(&items, snapshot, wake); } Some(Input::Event(Event::Vanished(name))) => { let removed = if let Some(pos) = items .iter() .position(|item| item.bus_name == name || item.unique_name == name) { Some(items.remove(pos).announced) } else { let is_host = shared.lock().hosts.iter().any(|host| *host == name); if is_host { let mut state = shared.lock(); state.hosts.retain(|host| *host != name); if let Ok(iface) = conn .object_server() .interface::<_, WatcherService>("/StatusNotifierWatcher") .await { let _ = iface.status_notifier_host_unregistered().await; let guard = iface.get().await; let _ = guard .is_status_notifier_host_registered_changed(iface.signal_emitter()) .await; } None } else if shared.lock().registered.iter().any(|n| *n == name) { Some(name) } else { None } }; if let Some(announced) = removed { unregister(&conn, &shared, &announced).await; update_snapshot(&items, snapshot, wake); } } Some(Input::Event(Event::Signal { key, kind })) => { let Some(index) = items.iter().position(|item| item.key == key) else { continue; }; let active = &mut items[index]; match kind { SignalKind::Title => { if let Ok(title) = active.proxy.title().await { active.item.title = if title.is_empty() { active.id.clone() } else { title }; } } SignalKind::Icon => { if let Ok(icon_name) = active.proxy.icon_name().await { active.item.icon_name = icon_name; } if let Ok(pixmaps) = active.proxy.icon_pixmap().await { active.item.pixmap = best_pixmap(&pixmaps).and_then( |(width, height, data)| pixmap_to_surface(*width, *height, data), ); } } SignalKind::Status => { if let Ok(status) = active.proxy.status().await { active.status = status; } } SignalKind::ToolTip => { if let Ok(tooltip) = active.proxy.tool_tip().await { active.item.tooltip = strip_html(&tooltip.3); } } SignalKind::IconThemePath => { if let Ok(path) = active.proxy.icon_theme_path().await { active.item.icon_theme_path = path; } } } update_snapshot(&items, snapshot, wake); } } } } async fn unregister(conn: &Connection, shared: &Arc>, announced: &str) { { let mut state = shared.lock(); let Some(pos) = state.registered.iter().position(|name| name == announced) else { return; }; state.registered.remove(pos); } if let Ok(iface) = conn .object_server() .interface::<_, WatcherService>("/StatusNotifierWatcher") .await { let _ = iface.status_notifier_item_unregistered(announced).await; let guard = iface.get().await; let _ = guard .registered_status_notifier_items_changed(iface.signal_emitter()) .await; } } async fn spawn_signal_tasks( conn: &Connection, proxy: &StatusNotifierItemProxy<'static>, key: &str, events: &mpsc::UnboundedSender, ) -> Vec> { let mut tasks = Vec::new(); macro_rules! forward { ($stream:expr, $kind:expr) => { if let Ok(stream) = $stream.await { let key = key.to_string(); let events = events.clone(); tasks.push(conn.executor().spawn( async move { futures_lite::pin!(stream); while stream.next().await.is_some() { let _ = events.unbounded_send(Event::Signal { key: key.clone(), kind: $kind, }); } }, "tray-signal", )); } }; } forward!(proxy.receive_new_title(), SignalKind::Title); forward!(proxy.receive_new_icon(), SignalKind::Icon); forward!(proxy.receive_new_status(), SignalKind::Status); forward!(proxy.receive_new_tool_tip(), SignalKind::ToolTip); forward!(proxy.receive_new_icon_theme_path(), SignalKind::IconThemePath); tasks } fn update_snapshot(items: &[ActiveItem], snapshot: &Arc>>, wake: &OwnedFd) { let visible: Vec = items .iter() .filter(|item| item.status != "Passive") .map(|item| item.item.clone()) .collect(); *snapshot.lock() = visible; let result = unsafe { libc::write(wake.as_raw_fd(), [1u8].as_ptr().cast(), 1) }; if result < 0 { eprintln!( "i3bar-mango: tray: wake pipe write failed: {}", std::io::Error::last_os_error() ); } } fn parse_registration(arg: &str, sender: &str) -> (String, String) { if arg.starts_with('/') { (sender.to_string(), arg.to_string()) } else if let Some((name, path)) = arg.split_once('/') { (name.to_string(), format!("/{path}")) } else { (arg.to_string(), "/StatusNotifierItem".to_string()) } } fn best_pixmap<'a>(pixmaps: &'a [(i32, i32, Vec)]) -> Option<&'a (i32, i32, Vec)> { pixmaps .iter() .filter(|(width, height, data)| { *width > 0 && *height > 0 && data.len() >= (*width * *height * 4) as usize }) .min_by_key(|(width, height, _)| { let size = (*width).max(*height); if size >= 24 { size } else { i32::MAX - size } }) } fn pixmap_to_surface(width: i32, height: i32, data: &[u8]) -> Option { let stride = width * 4; let mut buffer = vec![0u8; (height * stride) as usize]; for (src, dst) in data.chunks_exact(4).zip(buffer.chunks_exact_mut(4)) { let a = u32::from(src[0]); let r = (u32::from(src[1]) * a + 127) / 255; let g = (u32::from(src[2]) * a + 127) / 255; let b = (u32::from(src[3]) * a + 127) / 255; dst.copy_from_slice(&((a << 24) | (r << 16) | (g << 8) | b).to_ne_bytes()); } cairo::ImageSurface::create_for_data(buffer, cairo::Format::ARgb32, width, height, stride).ok() } fn strip_html(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut in_tag = false; for ch in text.chars() { match ch { '<' => in_tag = true, '>' if in_tag => in_tag = false, _ if !in_tag => out.push(ch), _ => {} } } out.trim().to_string() }