feat(gui): desktop notification when a viewer joins or leaves

The host screen pops a desktop notification on each viewer join/leave,
so you know someone connected while the window is in the background.

Fired on a detached thread (the D-Bus call never touches the egui
frame) and gated on the same viewer-list transitions, so stopping the
host — which drops the child and stops pumping events — doesn't spray a
notification per remaining viewer.

notify-rust's default features give the pure-Rust zbus backend, so this
adds no system libdbus dependency and no GTK event loop (gui feature
only; the headless build is untouched).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:31:48 -04:00
parent 24e0d0e799
commit f926dbea4e
3 changed files with 337 additions and 45 deletions
+28 -1
View File
@@ -78,6 +78,23 @@ fn short_id(id: &str) -> String {
}
}
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
/// can't stall the egui frame. Best-effort: with no notification daemon it
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
/// needs no system libdbus and no GTK event loop.)
fn notify(summary: &'static str, body: String) {
std::thread::spawn(move || {
if let Err(e) = notify_rust::Notification::new()
.appname("PixelPass")
.summary(summary)
.body(&body)
.show()
{
tracing::warn!("desktop notification failed: {e}");
}
});
}
/// Which screen the single window is currently showing.
#[derive(Default, PartialEq)]
enum Screen {
@@ -570,13 +587,23 @@ impl PixelPassApp {
self.host.active = active;
self.host.max = max;
if !self.host.viewers.contains(&id) {
notify(
"PixelPass — viewer connected",
format!("endpoint {} is now watching ({active}/{max})", short_id(&id)),
);
self.host.viewers.push(id);
}
}
ChildEvent::ViewerLeft { id, active, max } => {
self.host.active = active;
self.host.max = max;
self.host.viewers.retain(|v| v != &id);
if self.host.viewers.iter().any(|v| v == &id) {
notify(
"PixelPass — viewer disconnected",
format!("endpoint {} left ({active}/{max})", short_id(&id)),
);
self.host.viewers.retain(|v| v != &id);
}
}
ChildEvent::Capture { state } => {
self.host.capturing = matches!(state, child::CaptureState::Started);