status bar
git clone https://git.lucas.co/cce-status-interface.git
fix(tray): drop an item when the well-known name it registered under goes
The watcher removed items only when a vanished name matched the unique
name an item was addressed by. An app that registers under a well-known
name (org.kde.StatusNotifierItem-<pid>-<n>) is addressed by that name,
and the NameOwnerChanged for it carries it as `name`, never as the old
owner -- so its icon stayed in the tray after it exited.
vanished_items() now matches either. In a shadow on a private bus, an
item registered by well-known name and exiting is gone from
RegisteredStatusNotifierItems; before, it stayed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 2 +-
src/tray.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 59 insertions(+), 18 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 564c944..b62c09b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,7 +19,7 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks).
```sh
cargo build --release # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test # 56 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
+cargo test # 57 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
make install # release build, then `ccebuild install --no-build cce-status-interface`
```
diff --git a/src/tray.rs b/src/tray.rs
index a65bddb..8006ccf 100644
--- a/src/tray.rs
+++ b/src/tray.rs
@@ -404,6 +404,27 @@ impl StatusInterface {
}
}
+/// The registered items to drop when bus name `name` loses its owner
+/// (`old_owner`, the unique name that held it). An item is addressed by
+/// whatever it registered with: its connection's unique name (an item that
+/// registered by object path, or a unique-name service string) or a
+/// well-known name like `org.kde.StatusNotifierItem-<pid>-<n>`. Until
+/// 2026-09-26 only the unique-name form was matched, so an app that
+/// registered under a well-known name left a dead icon in the tray when it
+/// exited: the signal for the well-known name carries it as `name`, never as
+/// `old_owner`.
+pub(crate) fn vanished_items(
+ items: &HashMap<String, NotifierAddress>,
+ name: &str,
+ old_owner: Option<&str>,
+) -> Vec<String> {
+ items
+ .iter()
+ .filter(|(_, addr)| addr.destination == name || Some(addr.destination.as_str()) == old_owner)
+ .map(|(key, _)| key.clone())
+ .collect()
+}
+
pub(crate) async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEvent>) {
let registered_items = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
let tokio_handle = tokio::runtime::Handle::current();
@@ -476,23 +497,14 @@ pub(crate) async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEve
use tokio_stream::StreamExt;
while let Some(signal) = owner_changes.next().await {
if let Ok(args) = signal.args() {
- let old = args.old_owner;
- let new = args.new_owner;
- let old_opt: &Option<_> = &*old;
- if let Some(ref old_owner) = old_opt {
- if new.is_none() {
- let mut items = registered_items_clone.lock().await;
- let mut to_remove = Vec::new();
- for (key, addr) in items.iter() {
- if addr.destination == old_owner.as_str() {
- to_remove.push(key.clone());
- }
- }
- for key in to_remove {
- items.remove(&key);
- let _ = sender_clone.send(CustomEvent::TrayRemoved(key));
- }
- }
+ if args.new_owner.is_some() {
+ continue;
+ }
+ let old_owner = args.old_owner.as_ref().map(|o| o.as_str());
+ let mut items = registered_items_clone.lock().await;
+ for key in vanished_items(&items, args.name.as_str(), old_owner) {
+ items.remove(&key);
+ let _ = sender_clone.send(CustomEvent::TrayRemoved(key));
}
}
}
@@ -507,6 +519,35 @@ pub(crate) async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEve
#[cfg(test)]
mod tests {
use super::resolve_icon_path;
+ use super::{vanished_items, NotifierAddress};
+ use std::collections::HashMap;
+
+ fn items() -> HashMap<String, NotifierAddress> {
+ let mut m = HashMap::new();
+ for (dest, path) in [
+ (":1.42", "/StatusNotifierItem"),
+ ("org.kde.StatusNotifierItem-777-1", "/StatusNotifierItem"),
+ (":1.50", "/org/ayatana/NotificationItem/dropbox"),
+ ] {
+ let addr = NotifierAddress { destination: dest.to_string(), path: path.to_string() };
+ m.insert(format!("{dest}{path}"), addr);
+ }
+ m
+ }
+
+ #[test]
+ fn an_item_goes_with_the_name_it_registered_under() {
+ let m = items();
+ // A path-registered item: its unique name vanishing drops it.
+ assert_eq!(vanished_items(&m, ":1.42", Some(":1.42")), vec![":1.42/StatusNotifierItem"]);
+ // A well-known-name item: the signal for that name drops it.
+ assert_eq!(
+ vanished_items(&m, "org.kde.StatusNotifierItem-777-1", Some(":1.99")),
+ vec!["org.kde.StatusNotifierItem-777-1/StatusNotifierItem"]
+ );
+ // Anyone else's name going away leaves both alone.
+ assert!(vanished_items(&m, ":1.77", Some(":1.77")).is_empty());
+ }
/// The two tray-specific pieces kept local when the theme search moved to
/// `cce_ui::icon::lookup_in`: the dropbox alias, and the SNI IconThemePath