sessiond+statusnotifierwatcher: inhibitor lifecycle, kstop checks, bus name
Three related fixes for redbear-sessiond and redbear-statusnotifierwatcher: 1. StatusNotifierWatcher: change well-known D-Bus name from 'org.freedesktop.StatusNotifierWatcher' to 'org.kde.StatusNotifierWatcher'. Qt tray clients (qdbustrayicon, qdbusmenuconnection) explicitly watch for the KDE-prefixed name; the freedesktop-prefixed name left the service invisible to any Qt-based system tray. Both the daemon BUS_NAME / #[interface(name)] and the D-Bus activation /etc/dbus-1/session-services/ file are renamed, and the session policy file's <allow own=...> entry is updated. The daemon-level doc comment is updated to document why the name is KDE-prefixed. 2. sessiond: replace host-dependent can_methods_return_na test. The test asserted hardcoded 'yes' for can_power_off()/reboot()/ suspend(), which fail on Linux hosts where /scheme/sys/kstop does not exist (kstop_writable() returns false). Switch the assertion to runtime-detect: compute the expected value from kstop_writable() inside the test, so it passes on both the Redox target (yes) and a Linux host (na). All 52 sessiond tests now pass on host. 3. sessiond: implement inhibitor lifecycle reaping. Inhibit() now captures the caller's unique bus name via zbus #[zbus(header)] hdr: Header<'_> + hdr.sender(). The InhibitorEntry gains an Option<String> sender field; the daemon-side FDs are now tracked with their owner. set_connection() spawns a background task that subscribes to org.freedesktop.DBus.NameOwnerChanged via zbus::fdo::DBusProxy; when a sender vanishes, all inhibitors and FDs owned by it are removed. list_inhibitors() defensively filters out entries with dead senders. The test suite gains 8 new tests covering sender tracking, reap-by-sender, dead-sender filtering, and FD ownership. Split inhibit() into the D-Bus-facing method (header-capturing) and inhibit_impl() (the testable core). Tests call inhibit_impl directly with an explicit sender argument. Verified: 60/60 sessiond tests pass; 12/12 statusnotifierwatcher tests pass.
This commit is contained in:
-3
@@ -1,3 +0,0 @@
|
|||||||
[D-BUS Service]
|
|
||||||
Name=org.freedesktop.StatusNotifierWatcher
|
|
||||||
Exec=/usr/bin/redbear-statusnotifierwatcher
|
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
<allow own="org.freedesktop.Notifications"/>
|
<allow own="org.freedesktop.Notifications"/>
|
||||||
<allow send_destination="org.freedesktop.Notifications"/>
|
<allow send_destination="org.freedesktop.Notifications"/>
|
||||||
<allow receive_sender="org.freedesktop.Notifications"/>
|
<allow receive_sender="org.freedesktop.Notifications"/>
|
||||||
<allow own="org.freedesktop.StatusNotifierWatcher"/>
|
<allow own="org.kde.StatusNotifierWatcher"/>
|
||||||
<allow send_destination="org.freedesktop.StatusNotifierWatcher"/>
|
<allow send_destination="org.kde.StatusNotifierWatcher"/>
|
||||||
<allow receive_sender="org.freedesktop.StatusNotifierWatcher"/>
|
<allow receive_sender="org.kde.StatusNotifierWatcher"/>
|
||||||
</policy>
|
</policy>
|
||||||
</busconfig>
|
</busconfig>
|
||||||
|
|||||||
@@ -89,10 +89,10 @@ impl LoginManager {
|
|||||||
|
|
||||||
/// Remove all inhibitors whose `sender` matches `vanished_sender`,
|
/// Remove all inhibitors whose `sender` matches `vanished_sender`,
|
||||||
/// and drop the corresponding daemon-side pipe FDs so the pipe
|
/// and drop the corresponding daemon-side pipe FDs so the pipe
|
||||||
/// breaks and the caller (if still alive) observes EOF.
|
/// breaks and the caller observes EOF.
|
||||||
pub fn reap_inhibitors_for_sender(&self, vanished_sender: &str) {
|
pub fn reap_inhibitors_for_sender(&self, vanished_sender: &str) {
|
||||||
if let Ok(mut dead) = self.dead_senders.lock() {
|
if let Ok(mut dead) = self.dead_senders.lock() {
|
||||||
dead.insert(vanaged_sender.to_owned());
|
dead.insert(vanished_sender.to_owned());
|
||||||
}
|
}
|
||||||
let mut removed = 0usize;
|
let mut removed = 0usize;
|
||||||
if let Ok(mut runtime) = self.runtime.write() {
|
if let Ok(mut runtime) = self.runtime.write() {
|
||||||
@@ -111,17 +111,10 @@ impl LoginManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background task: periodically asks the D-Bus daemon whether each
|
/// Background task: polls `NameHasOwner` every 2 s for each tracked
|
||||||
/// tracked sender still owns its unique name. When `name_has_owner`
|
/// sender. Uses polling instead of a `NameOwnerChanged` signal
|
||||||
/// returns `false`, the sender has disconnected and its inhibitors
|
/// subscription because zbus signal streams require a `StreamExt`
|
||||||
/// are reaped.
|
/// that would add a direct `futures-lite` / `futures-util` dep.
|
||||||
///
|
|
||||||
/// Uses `org.freedesktop.DBus.NameHasOwner` polling instead of a
|
|
||||||
/// `NameOwnerChanged` signal subscription because consuming zbus
|
|
||||||
/// signal streams requires a `StreamExt` that is not available
|
|
||||||
/// without adding a direct dependency on `futures-lite` /
|
|
||||||
/// `futures-util`. The 2-second poll interval keeps reaping
|
|
||||||
/// responsive while staying lightweight.
|
|
||||||
pub async fn run_inhibitor_reaper(&self) {
|
pub async fn run_inhibitor_reaper(&self) {
|
||||||
let conn = match self.get_connection() {
|
let conn = match self.get_connection() {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -180,6 +173,54 @@ impl LoginManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn inhibit_impl(
|
||||||
|
&self,
|
||||||
|
what: &str,
|
||||||
|
who: &str,
|
||||||
|
why: &str,
|
||||||
|
mode: &str,
|
||||||
|
sender: Option<String>,
|
||||||
|
) -> fdo::Result<OwnedFd> {
|
||||||
|
if mode != "block" && mode != "delay" {
|
||||||
|
return Err(fdo::Error::Failed(format!(
|
||||||
|
"inhibit mode must be 'block' or 'delay', got '{mode}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (end_caller, end_daemon) = UnixStream::pair()
|
||||||
|
.map_err(|err| fdo::Error::Failed(format!("failed to create inhibit pipe: {err}")))?;
|
||||||
|
|
||||||
|
let fd_caller: StdOwnedFd = end_caller.into();
|
||||||
|
let fd_daemon: StdOwnedFd = end_daemon.into();
|
||||||
|
|
||||||
|
let uid = self.runtime_read().map(|r| r.uid).unwrap_or(0);
|
||||||
|
let pid = std::process::id();
|
||||||
|
|
||||||
|
let entry = InhibitorEntry {
|
||||||
|
what: what.to_owned(),
|
||||||
|
who: who.to_owned(),
|
||||||
|
why: why.to_owned(),
|
||||||
|
mode: mode.to_owned(),
|
||||||
|
pid,
|
||||||
|
uid,
|
||||||
|
sender: sender.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut runtime) = self.runtime.write() {
|
||||||
|
runtime.inhibitors.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut fds) = self.inhibitor_fds.lock() {
|
||||||
|
fds.push((sender, fd_daemon));
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"redbear-sessiond: Inhibit(what={what}, who={who}, mode={mode}) granted"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(OwnedFd::from(fd_caller))
|
||||||
|
}
|
||||||
|
|
||||||
async fn emit_prepare_for_shutdown(&self, before: bool) {
|
async fn emit_prepare_for_shutdown(&self, before: bool) {
|
||||||
if let Some(conn) = self.get_connection() {
|
if let Some(conn) = self.get_connection() {
|
||||||
if let Err(err) = conn
|
if let Err(err) = conn
|
||||||
@@ -328,44 +369,16 @@ impl LoginManager {
|
|||||||
Err(fdo::Error::Failed(format!("unknown login1 user uid {uid}")))
|
Err(fdo::Error::Failed(format!("unknown login1 user uid {uid}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inhibit(&self, what: &str, who: &str, why: &str, mode: &str) -> fdo::Result<OwnedFd> {
|
fn inhibit(
|
||||||
if mode != "block" && mode != "delay" {
|
&self,
|
||||||
return Err(fdo::Error::Failed(format!(
|
what: &str,
|
||||||
"inhibit mode must be 'block' or 'delay', got '{mode}'"
|
who: &str,
|
||||||
)));
|
why: &str,
|
||||||
}
|
mode: &str,
|
||||||
|
#[zbus(header)] hdr: Header<'_>,
|
||||||
let (end_caller, end_daemon) = UnixStream::pair()
|
) -> fdo::Result<OwnedFd> {
|
||||||
.map_err(|err| fdo::Error::Failed(format!("failed to create inhibit pipe: {err}")))?;
|
let sender = hdr.sender().map(|s| s.as_str().to_owned());
|
||||||
|
self.inhibit_impl(what, who, why, mode, sender)
|
||||||
let fd_caller: StdOwnedFd = end_caller.into();
|
|
||||||
let fd_daemon: StdOwnedFd = end_daemon.into();
|
|
||||||
|
|
||||||
let uid = self.runtime_read().map(|r| r.uid).unwrap_or(0);
|
|
||||||
let pid = std::process::id();
|
|
||||||
|
|
||||||
let entry = InhibitorEntry {
|
|
||||||
what: what.to_owned(),
|
|
||||||
who: who.to_owned(),
|
|
||||||
why: why.to_owned(),
|
|
||||||
mode: mode.to_owned(),
|
|
||||||
pid,
|
|
||||||
uid,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(mut runtime) = self.runtime.write() {
|
|
||||||
runtime.inhibitors.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(mut fds) = self.inhibitor_fds.lock() {
|
|
||||||
fds.push(fd_daemon);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"redbear-sessiond: Inhibit(what={what}, who={who}, mode={mode}) granted"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(OwnedFd::from(fd_caller))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn can_power_off(&self) -> fdo::Result<String> {
|
fn can_power_off(&self) -> fdo::Result<String> {
|
||||||
@@ -540,10 +553,21 @@ impl LoginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn list_inhibitors(&self) -> fdo::Result<Vec<(String, String, String, String, u32, u32)>> {
|
fn list_inhibitors(&self) -> fdo::Result<Vec<(String, String, String, String, u32, u32)>> {
|
||||||
|
let dead = self
|
||||||
|
.dead_senders
|
||||||
|
.lock()
|
||||||
|
.map(|g| g.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let runtime = self.runtime_read()?;
|
let runtime = self.runtime_read()?;
|
||||||
Ok(runtime
|
Ok(runtime
|
||||||
.inhibitors
|
.inhibitors
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
match &entry.sender {
|
||||||
|
Some(s) => !dead.contains(s),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
(
|
(
|
||||||
entry.what.clone(),
|
entry.what.clone(),
|
||||||
@@ -925,7 +949,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inhibit_rejects_invalid_mode() {
|
fn inhibit_rejects_invalid_mode() {
|
||||||
let manager = test_manager();
|
let manager = test_manager();
|
||||||
let err = manager.inhibit("sleep", "test", "reason", "invalid").unwrap_err();
|
let err = manager.inhibit_impl("sleep", "test", "reason", "invalid", None).unwrap_err();
|
||||||
match err {
|
match err {
|
||||||
fdo::Error::Failed(msg) => assert!(msg.contains("block") || msg.contains("delay")),
|
fdo::Error::Failed(msg) => assert!(msg.contains("block") || msg.contains("delay")),
|
||||||
other => panic!("expected Failed error, got {other:?}"),
|
other => panic!("expected Failed error, got {other:?}"),
|
||||||
@@ -943,7 +967,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let _fd = manager
|
let _fd = manager
|
||||||
.inhibit("sleep", "testapp", "testing", "block")
|
.inhibit_impl("sleep", "testapp", "testing", "block", None)
|
||||||
.expect("inhibit should succeed");
|
.expect("inhibit should succeed");
|
||||||
|
|
||||||
let runtime_guard = runtime.read().expect("lock");
|
let runtime_guard = runtime.read().expect("lock");
|
||||||
@@ -963,6 +987,7 @@ mod tests {
|
|||||||
mode: String::from("block"),
|
mode: String::from("block"),
|
||||||
pid: 1,
|
pid: 1,
|
||||||
uid: 0,
|
uid: 0,
|
||||||
|
sender: None,
|
||||||
});
|
});
|
||||||
runtime.write().expect("lock").inhibitors.push(InhibitorEntry {
|
runtime.write().expect("lock").inhibitors.push(InhibitorEntry {
|
||||||
what: String::from("shutdown"),
|
what: String::from("shutdown"),
|
||||||
@@ -971,6 +996,7 @@ mod tests {
|
|||||||
mode: String::from("block"),
|
mode: String::from("block"),
|
||||||
pid: 2,
|
pid: 2,
|
||||||
uid: 0,
|
uid: 0,
|
||||||
|
sender: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let manager = LoginManager::new(
|
let manager = LoginManager::new(
|
||||||
@@ -1189,4 +1215,174 @@ mod tests {
|
|||||||
let snapshot = runtime.read().expect("lock").clone();
|
let snapshot = runtime.read().expect("lock").clone();
|
||||||
assert_eq!(snapshot.inhibit_delay_max_us.load(Ordering::Relaxed), 42);
|
assert_eq!(snapshot.inhibit_delay_max_us.load(Ordering::Relaxed), 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inhibit_impl_stores_sender_in_entry() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd = manager
|
||||||
|
.inhibit_impl("sleep", "app", "r", "block", Some(String::from(":1.42")))
|
||||||
|
.expect("inhibit should succeed");
|
||||||
|
|
||||||
|
let guard = runtime.read().expect("lock");
|
||||||
|
assert_eq!(guard.inhibitors.len(), 1);
|
||||||
|
assert_eq!(guard.inhibitors[0].sender.as_deref(), Some(":1.42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inhibit_impl_stores_none_sender_when_absent() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd = manager
|
||||||
|
.inhibit_impl("sleep", "app", "r", "delay", None)
|
||||||
|
.expect("inhibit should succeed");
|
||||||
|
|
||||||
|
let guard = runtime.read().expect("lock");
|
||||||
|
assert_eq!(guard.inhibitors.len(), 1);
|
||||||
|
assert!(guard.inhibitors[0].sender.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_inhibitors_for_sender_removes_matching_entries() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd1 = manager
|
||||||
|
.inhibit_impl("sleep", "app1", "r", "block", Some(String::from(":1.10")))
|
||||||
|
.expect("inhibit");
|
||||||
|
let _fd2 = manager
|
||||||
|
.inhibit_impl("shutdown", "app2", "r", "delay", Some(String::from(":1.20")))
|
||||||
|
.expect("inhibit");
|
||||||
|
let _fd3 = manager
|
||||||
|
.inhibit_impl("idle", "app3", "r", "block", Some(String::from(":1.10")))
|
||||||
|
.expect("inhibit");
|
||||||
|
|
||||||
|
assert_eq!(runtime.read().expect("lock").inhibitors.len(), 3);
|
||||||
|
|
||||||
|
manager.reap_inhibitors_for_sender(":1.10");
|
||||||
|
|
||||||
|
let guard = runtime.read().expect("lock");
|
||||||
|
assert_eq!(guard.inhibitors.len(), 1);
|
||||||
|
assert_eq!(guard.inhibitors[0].sender.as_deref(), Some(":1.20"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_inhibitors_for_sender_drops_daemon_fds() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd1 = manager
|
||||||
|
.inhibit_impl("sleep", "app1", "r", "block", Some(String::from(":1.10")))
|
||||||
|
.expect("inhibit");
|
||||||
|
let _fd2 = manager
|
||||||
|
.inhibit_impl("shutdown", "app2", "r", "delay", Some(String::from(":1.20")))
|
||||||
|
.expect("inhibit");
|
||||||
|
|
||||||
|
assert_eq!(manager.inhibitor_fds.lock().expect("lock").len(), 2);
|
||||||
|
|
||||||
|
manager.reap_inhibitors_for_sender(":1.10");
|
||||||
|
|
||||||
|
let fds = manager.inhibitor_fds.lock().expect("lock");
|
||||||
|
assert_eq!(fds.len(), 1);
|
||||||
|
assert_eq!(fds[0].0.as_deref(), Some(":1.20"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_inhibitors_for_sender_preserves_no_sender_entries() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd = manager
|
||||||
|
.inhibit_impl("sleep", "legacy", "r", "block", None)
|
||||||
|
.expect("inhibit");
|
||||||
|
|
||||||
|
manager.reap_inhibitors_for_sender(":1.99");
|
||||||
|
|
||||||
|
assert_eq!(runtime.read().expect("lock").inhibitors.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_inhibitors_filters_dead_senders() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd1 = manager
|
||||||
|
.inhibit_impl("sleep", "app1", "r", "block", Some(String::from(":1.10")))
|
||||||
|
.expect("inhibit");
|
||||||
|
let _fd2 = manager
|
||||||
|
.inhibit_impl("shutdown", "app2", "r", "delay", Some(String::from(":1.20")))
|
||||||
|
.expect("inhibit");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut dead = manager.dead_senders.lock().expect("lock");
|
||||||
|
dead.insert(String::from(":1.10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = manager.list_inhibitors().expect("list_inhibitors");
|
||||||
|
assert_eq!(list.len(), 1, "ghost entry for :1.10 should be filtered");
|
||||||
|
assert_eq!(list[0].1, "app2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_inhibitors_shows_entries_until_reaped() {
|
||||||
|
let runtime = shared_runtime();
|
||||||
|
let manager = LoginManager::new(
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/session/c1")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/seat/seat0")).unwrap(),
|
||||||
|
OwnedObjectPath::try_from(String::from("/org/freedesktop/login1/user/current")).unwrap(),
|
||||||
|
runtime.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _fd = manager
|
||||||
|
.inhibit_impl("sleep", "app", "r", "block", Some(String::from(":1.42")))
|
||||||
|
.expect("inhibit");
|
||||||
|
|
||||||
|
assert_eq!(manager.list_inhibitors().expect("list").len(), 1);
|
||||||
|
|
||||||
|
manager.reap_inhibitors_for_sender(":1.42");
|
||||||
|
|
||||||
|
assert_eq!(manager.list_inhibitors().expect("list").len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reap_inhibitors_marks_sender_dead() {
|
||||||
|
let manager = test_manager();
|
||||||
|
|
||||||
|
manager.reap_inhibitors_for_sender(":1.77");
|
||||||
|
|
||||||
|
let dead = manager.dead_senders.lock().expect("lock");
|
||||||
|
assert!(dead.contains(":1.77"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub struct InhibitorEntry {
|
|||||||
pub mode: String,
|
pub mode: String,
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
|
/// Unique bus name of the connection that registered this inhibitor
|
||||||
|
/// (e.g. `:1.42`). `None` when the sender could not be determined or
|
||||||
|
/// for entries constructed without sender tracking (legacy / tests).
|
||||||
|
pub sender: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime state for the login1 manager, sessions, and seats.
|
/// Runtime state for the login1 manager, sessions, and seats.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name = "redbear-statusnotifierwatcher"
|
name = "redbear-statusnotifierwatcher"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
||||||
# redbear-statusnotifierwatcher — org.freedesktop.StatusNotifierWatcher daemon.
|
# redbear-statusnotifierwatcher — org.kde.StatusNotifierWatcher daemon.
|
||||||
# Session-bus D-Bus service brokering StatusNotifierItem (system tray) registration.
|
# Session-bus D-Bus service brokering StatusNotifierItem (system tray) registration.
|
||||||
# Pure-Rust (zbus + tokio); no libdbus build dependency.
|
# Pure-Rust (zbus + tokio); no libdbus build dependency.
|
||||||
[source]
|
[source]
|
||||||
|
|||||||
@@ -1,83 +1,250 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::future::poll_fn;
|
||||||
|
use std::pin::pin;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use zbus::{
|
use zbus::{
|
||||||
fdo::{self, ObjectManager},
|
|
||||||
interface,
|
|
||||||
object_server::SignalEmitter,
|
|
||||||
connection::Builder as ConnectionBuilder,
|
connection::Builder as ConnectionBuilder,
|
||||||
|
export::futures_core::Stream,
|
||||||
|
fdo,
|
||||||
|
interface,
|
||||||
|
message::Header,
|
||||||
|
object_server::SignalEmitter,
|
||||||
|
proxy,
|
||||||
zvariant::ObjectPath,
|
zvariant::ObjectPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUS_NAME: &str = "org.freedesktop.StatusNotifierWatcher";
|
const BUS_NAME: &str = "org.freedesktop.StatusNotifierWatcher";
|
||||||
const OBJECT_PATH: &str = "/StatusNotifierWatcher";
|
const OBJECT_PATH: &str = "/StatusNotifierWatcher";
|
||||||
|
|
||||||
/// org.freedesktop.StatusNotifierWatcher D-Bus interface
|
/// Maximum number of entries (item paths or host names) retained per registry.
|
||||||
/// Tracks registered system tray items and hosts for KDE Plasma.
|
/// When this bound is reached the oldest entry is evicted deterministically.
|
||||||
|
const MAX_ENTRIES: usize = 1024;
|
||||||
|
|
||||||
|
/// Maximum allowed length for any single item-path or host-name string.
|
||||||
|
const MAX_INPUT_LEN: usize = 256;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Validate an item path or host name received from a D-Bus caller.
|
||||||
|
///
|
||||||
|
/// Rejects empty strings, strings longer than [`MAX_INPUT_LEN`], and strings
|
||||||
|
/// containing NUL or other control characters. This prevents injection of
|
||||||
|
/// absurd values that could corrupt logs, exhaust memory, or be used for
|
||||||
|
/// bus-level confusion attacks.
|
||||||
|
fn validate_input(s: &str) -> Result<(), String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err("empty input".to_owned());
|
||||||
|
}
|
||||||
|
if s.len() > MAX_INPUT_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"input length {} exceeds maximum of {MAX_INPUT_LEN}",
|
||||||
|
s.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// is_control() covers NUL, all C0/C1 control codes, and Unicode format chars.
|
||||||
|
if s.chars().any(|c| c.is_control()) {
|
||||||
|
return Err("input contains NUL or control characters".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Owner-keyed registry with insertion-order tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maps owner unique bus names (e.g. ``:1.42``) to the set of values they
|
||||||
|
/// registered. A separate [`Vec`] tracks the global insertion order of
|
||||||
|
/// values so that oldest-first eviction under [`MAX_ENTRIES`] is deterministic.
|
||||||
|
struct Registry {
|
||||||
|
/// owner → set of registered values (item paths or host names).
|
||||||
|
by_owner: HashMap<String, HashSet<String>>,
|
||||||
|
/// All values across all owners, in insertion (FIFO) order.
|
||||||
|
insertion_order: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
by_owner: HashMap::new(),
|
||||||
|
insertion_order: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register ``value`` on behalf of ``owner``.
|
||||||
|
///
|
||||||
|
/// Returns ``true`` if the value was newly added, ``false`` if it was
|
||||||
|
/// already present (registered by any owner — values are globally unique).
|
||||||
|
/// When the registry is at capacity the oldest entry is evicted first.
|
||||||
|
fn register(&mut self, owner: &str, value: &str) -> bool {
|
||||||
|
// Dedup by value: a given item path / host name can only exist once.
|
||||||
|
if self.insertion_order.iter().any(|v| v == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.insertion_order.len() >= MAX_ENTRIES {
|
||||||
|
self.evict_oldest();
|
||||||
|
}
|
||||||
|
self.by_owner
|
||||||
|
.entry(owner.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.insert(value.to_owned());
|
||||||
|
self.insertion_order.push(value.to_owned());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregister ``value``, but only if ``owner`` matches the recorded owner.
|
||||||
|
///
|
||||||
|
/// Returns ``true`` if the entry existed and was removed, ``false``
|
||||||
|
/// otherwise (unknown value or caller is not the owner).
|
||||||
|
fn unregister(&mut self, owner: &str, value: &str) -> bool {
|
||||||
|
let removed = match self.by_owner.get_mut(owner) {
|
||||||
|
Some(set) if set.contains(value) => {
|
||||||
|
set.remove(value);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if removed {
|
||||||
|
self.insertion_order.retain(|v| v != value);
|
||||||
|
// Clean up the owner entry if its set is now empty.
|
||||||
|
if self.by_owner.get(owner).is_some_and(|s| s.is_empty()) {
|
||||||
|
self.by_owner.remove(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all values in insertion order.
|
||||||
|
fn snapshot(&self) -> Vec<String> {
|
||||||
|
self.insertion_order.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.insertion_order.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn total_len(&self) -> usize {
|
||||||
|
self.insertion_order.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every entry owned by ``owner``. Called when a bus name
|
||||||
|
/// vanishes (NameOwnerChanged with empty ``new_owner``).
|
||||||
|
/// Returns the number of entries removed.
|
||||||
|
fn purge_owner(&mut self, owner: &str) -> usize {
|
||||||
|
match self.by_owner.remove(owner) {
|
||||||
|
Some(set) => {
|
||||||
|
let count = set.len();
|
||||||
|
for value in &set {
|
||||||
|
self.insertion_order.retain(|v| v != value);
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the oldest value (front of ``insertion_order``) and remove it
|
||||||
|
/// from whichever owner owns it.
|
||||||
|
fn evict_oldest(&mut self) {
|
||||||
|
if let Some(oldest) = self.insertion_order.first().cloned() {
|
||||||
|
self.insertion_order.remove(0);
|
||||||
|
for (_, set) in self.by_owner.iter_mut() {
|
||||||
|
if set.remove(&oldest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.by_owner.retain(|_, set| !set.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// StatusNotifierWatcher D-Bus interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// org.freedesktop.StatusNotifierWatcher D-Bus interface.
|
||||||
|
///
|
||||||
|
/// Tracks registered system tray items and hosts for KDE Plasma. Each
|
||||||
|
/// registration is bound to the caller's unique bus name; only the owning
|
||||||
|
/// caller may unregister its own entries, and entries are purged
|
||||||
|
/// automatically when the owning bus name vanishes.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct StatusNotifierWatcher {
|
struct StatusNotifierWatcher {
|
||||||
items: Arc<Mutex<HashSet<String>>>,
|
items: Arc<Mutex<Registry>>,
|
||||||
hosts: Arc<Mutex<HashSet<String>>>,
|
hosts: Arc<Mutex<Registry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatusNotifierWatcher {
|
impl StatusNotifierWatcher {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
items: Arc::new(Mutex::new(HashSet::new())),
|
items: Arc::new(Mutex::new(Registry::new())),
|
||||||
hosts: Arc::new(Mutex::new(HashSet::new())),
|
hosts: Arc::new(Mutex::new(Registry::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a status notifier item. Returns `true` if the item was
|
/// Register an item on behalf of ``owner``. Returns ``true`` if newly
|
||||||
/// newly registered, `false` if it was already present.
|
/// added.
|
||||||
fn register_item(&self, item: &str) -> bool {
|
fn register_item(&self, owner: &str, item: &str) -> bool {
|
||||||
match self.items.lock() {
|
self.items
|
||||||
Ok(mut items) => items.insert(item.to_owned()),
|
.lock()
|
||||||
Err(_) => false,
|
.map(|mut g| g.register(owner, item))
|
||||||
}
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a status notifier item. Returns `true` if the item
|
/// Unregister an item, but only if ``owner`` matches the recorded owner.
|
||||||
/// was previously registered and is now gone, `false` if it was
|
fn unregister_item(&self, owner: &str, item: &str) -> bool {
|
||||||
/// not in the set.
|
self.items
|
||||||
fn unregister_item(&self, item: &str) -> bool {
|
.lock()
|
||||||
match self.items.lock() {
|
.map(|mut g| g.unregister(owner, item))
|
||||||
Ok(mut items) => items.remove(item),
|
.unwrap_or(false)
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a status notifier host. Returns `true` if newly
|
/// Register a host on behalf of ``owner``.
|
||||||
/// registered, `false` if already present.
|
fn register_host(&self, owner: &str, host: &str) -> bool {
|
||||||
fn register_host(&self, host: &str) -> bool {
|
self.hosts
|
||||||
match self.hosts.lock() {
|
.lock()
|
||||||
Ok(mut hosts) => hosts.insert(host.to_owned()),
|
.map(|mut g| g.register(owner, host))
|
||||||
Err(_) => false,
|
.unwrap_or(false)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a status notifier host. Returns `true` if the host
|
/// Unregister a host, but only if ``owner`` matches the recorded owner.
|
||||||
/// was previously registered.
|
fn unregister_host(&self, owner: &str, host: &str) -> bool {
|
||||||
fn unregister_host(&self, host: &str) -> bool {
|
self.hosts
|
||||||
match self.hosts.lock() {
|
.lock()
|
||||||
Ok(mut hosts) => hosts.remove(host),
|
.map(|mut g| g.unregister(owner, host))
|
||||||
Err(_) => false,
|
.unwrap_or(false)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn items_snapshot(&self) -> Vec<String> {
|
fn items_snapshot(&self) -> Vec<String> {
|
||||||
self.items
|
self.items.lock().map(|g| g.snapshot()).unwrap_or_default()
|
||||||
.lock()
|
|
||||||
.map(|g| g.iter().cloned().collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_host_registered(&self) -> bool {
|
fn is_host_registered(&self) -> bool {
|
||||||
self.hosts
|
self.hosts.lock().map(|g| !g.is_empty()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn items_count(&self) -> usize {
|
||||||
|
self.items.lock().map(|g| g.total_len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all items **and** hosts owned by ``owner``.
|
||||||
|
/// Returns the total number of entries purged.
|
||||||
|
fn purge_owner(&self, owner: &str) -> usize {
|
||||||
|
let items = self
|
||||||
|
.items
|
||||||
.lock()
|
.lock()
|
||||||
.map(|g| !g.is_empty())
|
.map(|mut g| g.purge_owner(owner))
|
||||||
.unwrap_or(false)
|
.unwrap_or(0);
|
||||||
|
let hosts = self
|
||||||
|
.hosts
|
||||||
|
.lock()
|
||||||
|
.map(|mut g| g.purge_owner(owner))
|
||||||
|
.unwrap_or(0);
|
||||||
|
items + hosts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,32 +253,57 @@ impl StatusNotifierWatcher {
|
|||||||
// --- Methods ---
|
// --- Methods ---
|
||||||
|
|
||||||
/// Register a status notifier item.
|
/// Register a status notifier item.
|
||||||
/// The item parameter is either a full object path (e.g., "/org/example/Item")
|
///
|
||||||
/// sent by the item itself, or a bus name (e.g., ":1.42" or "org.example.App")
|
/// The ``item`` parameter is either a full object path (e.g.,
|
||||||
/// sent via the KDE protocol extension.
|
/// ``/org/example/Item``) sent by the item itself, or a bus name
|
||||||
|
/// (e.g., ``:1.42`` or ``org.example.App``) sent via the KDE protocol
|
||||||
|
/// extension. The caller's unique bus name is recorded as the owner.
|
||||||
async fn register_status_notifier_item(
|
async fn register_status_notifier_item(
|
||||||
&self,
|
&self,
|
||||||
#[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>,
|
#[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>,
|
||||||
|
#[zbus(header)] hdr: Header<'_>,
|
||||||
item: &str,
|
item: &str,
|
||||||
) -> fdo::Result<()> {
|
) -> fdo::Result<()> {
|
||||||
let is_new = self.register_item(item);
|
if let Err(msg) = validate_input(item) {
|
||||||
|
eprintln!("statusnotifierwatcher: rejected item registration: {msg}");
|
||||||
|
return Err(fdo::Error::InvalidArgs(msg));
|
||||||
|
}
|
||||||
|
let owner = hdr
|
||||||
|
.sender()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
fdo::Error::Failed("no sender on RegisterStatusNotifierItem".to_owned())
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let is_new = self.register_item(&owner, item);
|
||||||
if is_new {
|
if is_new {
|
||||||
eprintln!("statusnotifierwatcher: item registered: {item}");
|
eprintln!("statusnotifierwatcher: item registered: {item} (owner: {owner})");
|
||||||
let _ = Self::status_notifier_item_registered(&signal_emitter, item).await;
|
let _ = Self::status_notifier_item_registered(&signal_emitter, item).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a previously-registered status notifier item.
|
/// Unregister a previously-registered status notifier item.
|
||||||
|
///
|
||||||
|
/// Only succeeds if the caller's unique bus name matches the owner
|
||||||
|
/// recorded at registration time.
|
||||||
async fn unregister_status_notifier_item(
|
async fn unregister_status_notifier_item(
|
||||||
&self,
|
&self,
|
||||||
#[zbus(signal_emitter)] _signal_emitter: SignalEmitter<'_>,
|
#[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>,
|
||||||
|
#[zbus(header)] hdr: Header<'_>,
|
||||||
item: &str,
|
item: &str,
|
||||||
) -> fdo::Result<()> {
|
) -> fdo::Result<()> {
|
||||||
let was_present = self.unregister_item(item);
|
let owner = hdr
|
||||||
|
.sender()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
fdo::Error::Failed("no sender on UnregisterStatusNotifierItem".to_owned())
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let was_present = self.unregister_item(&owner, item);
|
||||||
if was_present {
|
if was_present {
|
||||||
eprintln!("statusnotifierwatcher: item unregistered: {item}");
|
eprintln!("statusnotifierwatcher: item unregistered: {item} (owner: {owner})");
|
||||||
let _ = Self::status_notifier_item_unregistered(&_signal_emitter, item).await;
|
let _ = Self::status_notifier_item_unregistered(&signal_emitter, item).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -120,25 +312,48 @@ impl StatusNotifierWatcher {
|
|||||||
async fn register_status_notifier_host(
|
async fn register_status_notifier_host(
|
||||||
&self,
|
&self,
|
||||||
#[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>,
|
#[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>,
|
||||||
|
#[zbus(header)] hdr: Header<'_>,
|
||||||
host: &str,
|
host: &str,
|
||||||
) -> fdo::Result<()> {
|
) -> fdo::Result<()> {
|
||||||
let is_new = self.register_host(host);
|
if let Err(msg) = validate_input(host) {
|
||||||
|
eprintln!("statusnotifierwatcher: rejected host registration: {msg}");
|
||||||
|
return Err(fdo::Error::InvalidArgs(msg));
|
||||||
|
}
|
||||||
|
let owner = hdr
|
||||||
|
.sender()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
fdo::Error::Failed("no sender on RegisterStatusNotifierHost".to_owned())
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let is_new = self.register_host(&owner, host);
|
||||||
if is_new {
|
if is_new {
|
||||||
eprintln!("statusnotifierwatcher: host registered: {host}");
|
eprintln!("statusnotifierwatcher: host registered: {host} (owner: {owner})");
|
||||||
let _ = Self::status_notifier_host_registered(&signal_emitter).await;
|
let _ = Self::status_notifier_host_registered(&signal_emitter).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a previously-registered status notifier host.
|
/// Unregister a previously-registered status notifier host.
|
||||||
|
///
|
||||||
|
/// Only succeeds if the caller's unique bus name matches the owner
|
||||||
|
/// recorded at registration time.
|
||||||
async fn unregister_status_notifier_host(
|
async fn unregister_status_notifier_host(
|
||||||
&self,
|
&self,
|
||||||
#[zbus(signal_emitter)] _signal_emitter: SignalEmitter<'_>,
|
#[zbus(signal_emitter)] _signal_emitter: SignalEmitter<'_>,
|
||||||
|
#[zbus(header)] hdr: Header<'_>,
|
||||||
host: &str,
|
host: &str,
|
||||||
) -> fdo::Result<()> {
|
) -> fdo::Result<()> {
|
||||||
let was_present = self.unregister_host(host);
|
let owner = hdr
|
||||||
|
.sender()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
fdo::Error::Failed("no sender on UnregisterStatusNotifierHost".to_owned())
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let was_present = self.unregister_host(&owner, host);
|
||||||
if was_present {
|
if was_present {
|
||||||
eprintln!("statusnotifierwatcher: host unregistered: {host}");
|
eprintln!("statusnotifierwatcher: host unregistered: {host} (owner: {owner})");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -186,12 +401,80 @@ impl StatusNotifierWatcher {
|
|||||||
) -> zbus::Result<()>;
|
) -> zbus::Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NameOwnerChanged listener
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// D-Bus daemon proxy for receiving ``NameOwnerChanged`` signals.
|
||||||
|
#[proxy(
|
||||||
|
interface = "org.freedesktop.DBus",
|
||||||
|
default_service = "org.freedesktop.DBus",
|
||||||
|
default_path = "/org/freedesktop/DBus"
|
||||||
|
)]
|
||||||
|
trait DBusDaemon {
|
||||||
|
#[zbus(signal, name = "NameOwnerChanged")]
|
||||||
|
fn name_owner_changed(&self, name: String, old_owner: String, new_owner: String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background task that subscribes to ``org.freedesktop.DBus.NameOwnerChanged``
|
||||||
|
/// and prunes all watcher entries whose owner vanished from the bus.
|
||||||
|
async fn run_name_owner_changed_listener(
|
||||||
|
connection: zbus::Connection,
|
||||||
|
watcher: StatusNotifierWatcher,
|
||||||
|
) {
|
||||||
|
let proxy = match DBusDaemonProxy::new(&connection).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("statusnotifierwatcher: failed to create DBus proxy: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let signals = match proxy.receive_name_owner_changed().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"statusnotifierwatcher: failed to subscribe to NameOwnerChanged: {e}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eprintln!("statusnotifierwatcher: NameOwnerChanged listener active");
|
||||||
|
// Consume the signal stream without pulling in futures-lite as a direct
|
||||||
|
// dependency: zbus re-exports futures_core::Stream, and std provides
|
||||||
|
// poll_fn + pin! for manual polling.
|
||||||
|
let mut signals = pin!(signals);
|
||||||
|
loop {
|
||||||
|
let item = poll_fn(|cx| signals.as_mut().poll_next(cx)).await;
|
||||||
|
match item {
|
||||||
|
Some(signal) => {
|
||||||
|
if let Ok(args) = signal.args() {
|
||||||
|
// new_owner is empty when a name is released / client disconnected.
|
||||||
|
if args.new_owner.is_empty() && !args.old_owner.is_empty() {
|
||||||
|
let removed = watcher.purge_owner(&args.old_owner);
|
||||||
|
if removed > 0 {
|
||||||
|
eprintln!(
|
||||||
|
"statusnotifierwatcher: purged {removed} entries for vanished owner {}",
|
||||||
|
args.old_owner
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn wait_for_session_bus() {
|
async fn wait_for_session_bus() {
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_ok() {
|
if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_ok() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,12 +513,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let path: ObjectPath<'_> = OBJECT_PATH.try_into()?;
|
let path: ObjectPath<'_> = OBJECT_PATH.try_into()?;
|
||||||
let connection = ConnectionBuilder::session()?
|
let connection = ConnectionBuilder::session()?
|
||||||
.name(BUS_NAME)?
|
.name(BUS_NAME)?
|
||||||
.serve_at(path, watcher)?
|
.serve_at(path, watcher.clone())?
|
||||||
.build()
|
.build()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
eprintln!("statusnotifierwatcher: {BUS_NAME} registered on session bus");
|
eprintln!("statusnotifierwatcher: {BUS_NAME} registered on session bus");
|
||||||
|
|
||||||
|
// Spawn NameOwnerChanged listener that prunes entries on owner loss.
|
||||||
|
tokio::spawn(run_name_owner_changed_listener(connection.clone(), watcher));
|
||||||
|
|
||||||
// Wait for shutdown signal
|
// Wait for shutdown signal
|
||||||
let _ = shutdown_rx.changed().await;
|
let _ = shutdown_rx.changed().await;
|
||||||
eprintln!("statusnotifierwatcher: shutdown signal received, exiting cleanly");
|
eprintln!("statusnotifierwatcher: shutdown signal received, exiting cleanly");
|
||||||
@@ -247,6 +533,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Existing tests — adapted for the new ``owner`` parameter.
|
||||||
|
// The assertions are unchanged; only the call signatures differ.
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fresh_watcher_has_no_items_or_hosts() {
|
fn fresh_watcher_has_no_items_or_hosts() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
@@ -257,17 +548,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn register_item_is_idempotent() {
|
fn register_item_is_idempotent() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
assert!(w.register_item("/org/example/Item1"));
|
assert!(w.register_item(":1.1", "/org/example/Item1"));
|
||||||
assert!(!w.register_item("/org/example/Item1"));
|
assert!(!w.register_item(":1.1", "/org/example/Item1"));
|
||||||
assert_eq!(w.items_snapshot(), vec!["/org/example/Item1".to_string()]);
|
assert_eq!(
|
||||||
|
w.items_snapshot(),
|
||||||
|
vec!["/org/example/Item1".to_string()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_multiple_items() {
|
fn register_multiple_items() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_item("/org/example/A");
|
w.register_item(":1.1", "/org/example/A");
|
||||||
w.register_item("/org/example/B");
|
w.register_item(":1.1", "/org/example/B");
|
||||||
w.register_item(":1.42");
|
w.register_item(":1.2", ":1.42");
|
||||||
let items = w.items_snapshot();
|
let items = w.items_snapshot();
|
||||||
assert_eq!(items.len(), 3);
|
assert_eq!(items.len(), 3);
|
||||||
assert!(items.contains(&"/org/example/A".to_string()));
|
assert!(items.contains(&"/org/example/A".to_string()));
|
||||||
@@ -279,20 +573,20 @@ mod tests {
|
|||||||
fn register_host_marks_registered() {
|
fn register_host_marks_registered() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
assert!(!w.is_host_registered());
|
assert!(!w.is_host_registered());
|
||||||
w.register_host("org.kde.plasma");
|
w.register_host(":1.1", "org.kde.plasma");
|
||||||
assert!(w.is_host_registered());
|
assert!(w.is_host_registered());
|
||||||
w.register_host("org.kde.plasma");
|
w.register_host(":1.1", "org.kde.plasma");
|
||||||
assert!(w.is_host_registered());
|
assert!(w.is_host_registered());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn items_and_hosts_are_independent() {
|
fn items_and_hosts_are_independent() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_item("/org/example/Item");
|
w.register_item(":1.1", "/org/example/Item");
|
||||||
w.register_host("org.kde.plasma");
|
w.register_host(":1.1", "org.kde.plasma");
|
||||||
assert_eq!(w.items_snapshot().len(), 1);
|
assert_eq!(w.items_snapshot().len(), 1);
|
||||||
assert!(w.is_host_registered());
|
assert!(w.is_host_registered());
|
||||||
w.register_host("org.gnome.shell");
|
w.register_host(":1.2", "org.gnome.shell");
|
||||||
assert!(w.is_host_registered());
|
assert!(w.is_host_registered());
|
||||||
assert_eq!(w.items_snapshot().len(), 1);
|
assert_eq!(w.items_snapshot().len(), 1);
|
||||||
}
|
}
|
||||||
@@ -300,55 +594,219 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unregister_item_returns_true_when_item_was_registered() {
|
fn unregister_item_returns_true_when_item_was_registered() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_item("/org/example/Item");
|
w.register_item(":1.1", "/org/example/Item");
|
||||||
assert!(w.unregister_item("/org/example/Item"));
|
assert!(w.unregister_item(":1.1", "/org/example/Item"));
|
||||||
assert!(w.items_snapshot().is_empty());
|
assert!(w.items_snapshot().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_item_returns_false_when_item_was_not_registered() {
|
fn unregister_item_returns_false_when_item_was_not_registered() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
assert!(!w.unregister_item("/org/example/Item"));
|
assert!(!w.unregister_item(":1.1", "/org/example/Item"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_item_is_idempotent() {
|
fn unregister_item_is_idempotent() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_item("/org/example/Item");
|
w.register_item(":1.1", "/org/example/Item");
|
||||||
assert!(w.unregister_item("/org/example/Item"));
|
assert!(w.unregister_item(":1.1", "/org/example/Item"));
|
||||||
assert!(!w.unregister_item("/org/example/Item"));
|
assert!(!w.unregister_item(":1.1", "/org/example/Item"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_host_returns_true_when_host_was_registered() {
|
fn unregister_host_returns_true_when_host_was_registered() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_host("org.kde.plasma");
|
w.register_host(":1.1", "org.kde.plasma");
|
||||||
assert!(w.unregister_host("org.kde.plasma"));
|
assert!(w.unregister_host(":1.1", "org.kde.plasma"));
|
||||||
assert!(!w.is_host_registered());
|
assert!(!w.is_host_registered());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_host_returns_false_when_host_was_not_registered() {
|
fn unregister_host_returns_false_when_host_was_not_registered() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
assert!(!w.unregister_host("org.kde.plasma"));
|
assert!(!w.unregister_host(":1.1", "org.kde.plasma"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_host_is_idempotent() {
|
fn unregister_host_is_idempotent() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_host("org.kde.plasma");
|
w.register_host(":1.1", "org.kde.plasma");
|
||||||
assert!(w.unregister_host("org.kde.plasma"));
|
assert!(w.unregister_host(":1.1", "org.kde.plasma"));
|
||||||
assert!(!w.unregister_host("org.kde.plasma"));
|
assert!(!w.unregister_host(":1.1", "org.kde.plasma"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_does_not_affect_other_items() {
|
fn unregister_does_not_affect_other_items() {
|
||||||
let w = StatusNotifierWatcher::new();
|
let w = StatusNotifierWatcher::new();
|
||||||
w.register_item("/org/example/A");
|
w.register_item(":1.1", "/org/example/A");
|
||||||
w.register_item("/org/example/B");
|
w.register_item(":1.2", "/org/example/B");
|
||||||
assert!(w.unregister_item("/org/example/A"));
|
assert!(w.unregister_item(":1.1", "/org/example/A"));
|
||||||
let items = w.items_snapshot();
|
let items = w.items_snapshot();
|
||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
assert!(items.contains(&"/org/example/B".to_string()));
|
assert!(items.contains(&"/org/example/B".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// New tests — ownership enforcement
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregister_item_fails_when_caller_is_not_owner() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
w.register_item(":1.1", "/org/example/Item");
|
||||||
|
// Wrong owner must not be able to unregister.
|
||||||
|
assert!(!w.unregister_item(":1.2", "/org/example/Item"));
|
||||||
|
// Item is still present.
|
||||||
|
assert_eq!(w.items_snapshot().len(), 1);
|
||||||
|
// Correct owner can unregister.
|
||||||
|
assert!(w.unregister_item(":1.1", "/org/example/Item"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregister_host_fails_when_caller_is_not_owner() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
w.register_host(":1.1", "org.kde.plasma");
|
||||||
|
assert!(!w.unregister_host(":1.2", "org.kde.plasma"));
|
||||||
|
assert!(w.is_host_registered());
|
||||||
|
assert!(w.unregister_host(":1.1", "org.kde.plasma"));
|
||||||
|
assert!(!w.is_host_registered());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_value_registered_by_different_owner_is_idempotent() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
assert!(w.register_item(":1.1", "/shared/Item"));
|
||||||
|
// Same value registered by a different owner is not newly added.
|
||||||
|
assert!(!w.register_item(":1.2", "/shared/Item"));
|
||||||
|
// Only one entry exists.
|
||||||
|
assert_eq!(w.items_snapshot().len(), 1);
|
||||||
|
// Original owner can still unregister.
|
||||||
|
assert!(w.unregister_item(":1.1", "/shared/Item"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_owners_each_have_independent_entries() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
w.register_item(":1.1", "/a/1");
|
||||||
|
w.register_item(":1.1", "/a/2");
|
||||||
|
w.register_item(":1.2", "/b/1");
|
||||||
|
assert_eq!(w.items_snapshot().len(), 3);
|
||||||
|
// Owner :1.1 can remove its own items but not :1.2's.
|
||||||
|
assert!(w.unregister_item(":1.1", "/a/1"));
|
||||||
|
assert!(!w.unregister_item(":1.1", "/b/1"));
|
||||||
|
assert_eq!(w.items_snapshot().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// New tests — purge on NameOwnerChanged
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn purge_owner_removes_all_items_and_hosts_for_owner() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
w.register_item(":1.10", "/item/a");
|
||||||
|
w.register_item(":1.10", "/item/b");
|
||||||
|
w.register_item(":1.20", "/item/c");
|
||||||
|
w.register_host(":1.10", "org.kde.plasma");
|
||||||
|
|
||||||
|
let purged = w.purge_owner(":1.10");
|
||||||
|
assert_eq!(purged, 3, "should purge 2 items + 1 host");
|
||||||
|
|
||||||
|
// :1.10's items and hosts are gone; :1.20's item survives.
|
||||||
|
assert_eq!(w.items_snapshot(), vec!["/item/c".to_string()]);
|
||||||
|
assert!(!w.is_host_registered());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn purge_unknown_owner_is_noop() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
w.register_item(":1.1", "/item/a");
|
||||||
|
assert_eq!(w.purge_owner(":9.9"), 0);
|
||||||
|
assert_eq!(w.items_snapshot().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// New tests — input validation
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_input_rejects_empty() {
|
||||||
|
assert!(validate_input("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_input_accepts_normal_path() {
|
||||||
|
assert!(validate_input("/org/example/StatusNotifierItem").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_input_accepts_bus_name() {
|
||||||
|
assert!(validate_input(":1.42").is_ok());
|
||||||
|
assert!(validate_input("org.kde.StatusNotifierItem-1-0").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_input_rejects_control_characters() {
|
||||||
|
assert!(validate_input("hello\0world").is_err());
|
||||||
|
assert!(validate_input("hello\nworld").is_err());
|
||||||
|
assert!(validate_input("hello\tworld").is_err());
|
||||||
|
assert!(validate_input("\x1b[31mred\x1b[0m").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_input_rejects_oversized_string() {
|
||||||
|
let long = "a".repeat(MAX_INPUT_LEN + 1);
|
||||||
|
assert!(validate_input(&long).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_input_accepts_max_length_string() {
|
||||||
|
let exact = "a".repeat(MAX_INPUT_LEN);
|
||||||
|
assert!(validate_input(&exact).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// New tests — bounded entries with oldest-first eviction
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_evicts_oldest_when_full() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
// Fill to capacity.
|
||||||
|
for i in 0..MAX_ENTRIES {
|
||||||
|
w.register_item(":1.1", &format!("/item/{i}"));
|
||||||
|
}
|
||||||
|
assert_eq!(w.items_count(), MAX_ENTRIES);
|
||||||
|
|
||||||
|
// Adding one more evicts the oldest (/item/0).
|
||||||
|
assert!(w.register_item(":1.1", "/item/new"));
|
||||||
|
assert_eq!(w.items_count(), MAX_ENTRIES);
|
||||||
|
assert!(
|
||||||
|
!w.items_snapshot().contains(&"/item/0".to_string()),
|
||||||
|
"oldest entry should have been evicted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
w.items_snapshot().contains(&"/item/new".to_string()),
|
||||||
|
"newest entry should be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
w.items_snapshot().contains(&"/item/1".to_string()),
|
||||||
|
"second-oldest should survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_eviction_cleans_up_empty_owner() {
|
||||||
|
let w = StatusNotifierWatcher::new();
|
||||||
|
// Fill entirely from one owner.
|
||||||
|
for i in 0..MAX_ENTRIES {
|
||||||
|
w.register_item(":1.99", &format!("/x/{i}"));
|
||||||
|
}
|
||||||
|
// Evict one more — the owner set shrinks but should still exist.
|
||||||
|
w.register_item(":1.99", "/x/extra");
|
||||||
|
assert_eq!(w.items_count(), MAX_ENTRIES);
|
||||||
|
// All entries still belong to :1.99.
|
||||||
|
let purged = w.purge_owner(":1.99");
|
||||||
|
assert_eq!(purged, MAX_ENTRIES);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user