diff --git a/.gitignore b/.gitignore index 5c0031bd..e178ac1a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ debian/* !debian/links !debian/rules !debian/source + +cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service +cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service diff --git a/cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service.in b/cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service.in new file mode 100644 index 00000000..4f2067ee --- /dev/null +++ b/cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service.in @@ -0,0 +1,7 @@ +[Unit] +Description=COSMIC Status Notifier Watcher backend + +[Service] +Type=dbus +BusName=com.system76.CosmicStatusNotifierWatcher +ExecStart=@bindir@/cosmic-applet-status-area --status-notifier-watcher diff --git a/cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service.in b/cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service.in new file mode 100644 index 00000000..0c3345d5 --- /dev/null +++ b/cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service.in @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=com.system76.CosmicStatusNotifierWatcher +Exec=@bindir@/cosmic-applet-status-area --status-notifier-watcher +SystemdService=com.system76.CosmicStatusNotifierWatcher.service diff --git a/cosmic-applet-status-area/data/org.freedesktop.impl.portal.desktop.cosmic.service.in b/cosmic-applet-status-area/data/org.freedesktop.impl.portal.desktop.cosmic.service.in new file mode 100644 index 00000000..4f2067ee --- /dev/null +++ b/cosmic-applet-status-area/data/org.freedesktop.impl.portal.desktop.cosmic.service.in @@ -0,0 +1,7 @@ +[Unit] +Description=COSMIC Status Notifier Watcher backend + +[Service] +Type=dbus +BusName=com.system76.CosmicStatusNotifierWatcher +ExecStart=@bindir@/cosmic-applet-status-area --status-notifier-watcher diff --git a/cosmic-applet-status-area/src/lib.rs b/cosmic-applet-status-area/src/lib.rs index 371b8aff..17166cbc 100644 --- a/cosmic-applet-status-area/src/lib.rs +++ b/cosmic-applet-status-area/src/lib.rs @@ -1,9 +1,23 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only +use std::{env, process}; + mod components; mod subscriptions; +pub mod status_notifier_watcher; +mod unique_names; + pub fn run() -> cosmic::iced::Result { - components::app::main() + if let Some(arg) = env::args().nth(1) { + if arg == "--status-notifier-watcher" { + status_notifier_watcher::run() + } else { + tracing::error!("Invalid argument `{arg}` for status-area applet`"); + process::exit(1); + } + } else { + components::app::main() + } } diff --git a/cosmic-applet-status-area/src/status_notifier_watcher.rs b/cosmic-applet-status-area/src/status_notifier_watcher.rs new file mode 100644 index 00000000..1bb75d79 --- /dev/null +++ b/cosmic-applet-status-area/src/status_notifier_watcher.rs @@ -0,0 +1,155 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +//! A seperate DBus socket-activated daemon to serve as the status notifier watcher +//! +//! By socket-activating this daemon, panel configuration changes do not end up terminating +//! the daemon and having different applet instances race to start it. +//! +//! This provides a seperate interface from the standard one, with a single register method, so it +//! can be socket-activated and not conflict with anything else running as a status notifier +//! watcher. +//! +//! The daemon runs as long as as there is at least one client still connected. Which it checks +//! for every `REFRESH_INTERVAL`. + +use crate::subscriptions::status_notifier_watcher::server::create_service; +use crate::unique_names::UniqueNames; + +use futures::StreamExt; +use std::{collections::HashSet, time::Duration}; +use zbus::fdo; +use zbus::message::Header; + +const DBUS_NAME: &str = "com.system76.CosmicStatusNotifierWatcher"; +const OBJECT_PATH: &str = "/CosmicStatusNotifierWatcher"; +const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +/// Run daemon +pub fn run() -> cosmic::iced::Result { + if let Err(err) = run_inner() { + eprintln!("Zbus error running status notifier watcher: {}", err); + std::process::exit(1); + } + Ok(()) +} + +/// Register client with daemon +pub async fn cosmic_register(conn: &zbus::Connection) -> zbus::Result<()> { + let cosmic_watcher = CosmicAppletStatusNotifierWatcherProxy::new(conn).await?; + cosmic_watcher.register_applet().await?; + let mut stream = cosmic_watcher.0.receive_owner_changed().await?; + tokio::spawn(async move { + while let Some(value) = stream.next().await { + if let Some(_unique_name) = value { + /// Register with new owner + let _ = cosmic_watcher.register_applet().await; + } + } + }); + Ok(()) +} + +#[zbus::proxy( + interface = "com.system76.CosmicStatusNotifierWatcher", + default_service = "com.system76.CosmicStatusNotifierWatcher", + default_path = "/CosmicStatusNotifierWatcher" +)] +trait CosmicAppletStatusNotifierWatcher { + async fn register_applet(&self) -> zbus::Result<()>; +} + +struct CosmicAppletStatusNotifierWatcher { + applets: HashSet>, + unique_names: UniqueNames, +} + +#[zbus::interface(name = "com.system76.CosmicStatusNotifierWatcher")] +impl CosmicAppletStatusNotifierWatcher { + fn register_applet(&mut self, #[zbus(header)] hdr: Header<'_>) { + if let Some(sender) = hdr.sender() { + if self.unique_names.has_unique_name(sender) { + self.applets.insert(sender.to_owned()); + } + } + } +} + +impl CosmicAppletStatusNotifierWatcher { + fn has_client(&self) -> bool { + !self.applets.is_empty() + } + + /// Purge registered clients that no longer exist on bus + fn refresh(&mut self) { + self.applets + .retain(|n| self.unique_names.has_unique_name(n)); + } +} + +#[tokio::main] +pub async fn run_inner() -> zbus::Result<()> { + let (running, abort_handle) = futures::future::abortable(std::future::pending::<()>()); + + let conn = zbus::Connection::session().await?; + create_service(&conn).await?; + let dbus = zbus::fdo::DBusProxy::new(&conn).await?; + conn.object_server() + .at( + OBJECT_PATH, + CosmicAppletStatusNotifierWatcher { + applets: HashSet::new(), + unique_names: UniqueNames::new(&conn).await?, + }, + ) + .await?; + let interface = conn + .object_server() + .interface::<_, CosmicAppletStatusNotifierWatcher>(OBJECT_PATH) + .await?; + tokio::spawn(refresh_task(interface.clone(), abort_handle.clone())); + let name_lost_stream = dbus.receive_name_lost().await?; + tokio::spawn(name_lost_task(name_lost_stream, abort_handle)); + conn.request_name(DBUS_NAME).await?; + + let _ = running.await; + Ok(()) +} + +// Task to terminate daemon with the owned name is lost. +// (If a different instance of this daemon is manually started.) +async fn name_lost_task( + mut name_lost_stream: fdo::NameLostStream, + abort_handle: futures::future::AbortHandle, +) { + while let Some(name_lost) = name_lost_stream.next().await { + let Ok(args) = name_lost.args() else { + return; + }; + if args.name == DBUS_NAME { + eprintln!("'{}' name on bus lost. Exiting.", DBUS_NAME); + abort_handle.abort(); + return; + } + } +} + +async fn refresh_task( + interface: zbus::object_server::InterfaceRef, + abort_handle: futures::future::AbortHandle, +) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Initial tick, waiting for first client to connect + interval.tick().await; + loop { + interval.tick().await; + let mut watcher = interface.get_mut().await; + if !watcher.has_client() { + // No clients since last refresh; exit + abort_handle.abort(); + return; + } + watcher.refresh(); + } +} diff --git a/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs index af0f74ae..9f9c7d14 100644 --- a/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs +++ b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs @@ -9,7 +9,7 @@ use futures::{StreamExt, stream}; use crate::subscriptions::status_notifier_item::StatusNotifierItem; mod client; -mod server; +pub(crate) mod server; #[derive(Clone, Debug)] pub enum Event { @@ -51,7 +51,9 @@ async fn connect() -> zbus::Result<(zbus::Connection, client::EventStream)> { let connection = zbus::Connection::session().await?; // Start `StatusNotifierWatcher` service, if there isn't one running already - server::create_service(&connection).await?; + if let Err(err) = crate::status_notifier_watcher::cosmic_register(&connection).await { + eprintln!("Failed to start status notifier watcher: {}", err); + } // Connect client and listen for registered/unregistered let stream = client::watch(&connection).await?; diff --git a/cosmic-applet-status-area/src/unique_names.rs b/cosmic-applet-status-area/src/unique_names.rs new file mode 100644 index 00000000..cab6483e --- /dev/null +++ b/cosmic-applet-status-area/src/unique_names.rs @@ -0,0 +1,107 @@ +// Copyright 2026 System76 +// SPDX-License-Identifier: GPL-3.0-only + +// Based on https://github.com/pop-os/cosmic-comp/blob/master/src/dbus/name_owners.rs, +// but only tracking unique names, and using tokio executor. + +use futures::StreamExt; +use std::{ + collections::HashSet, + future::{Future, poll_fn}, + sync::{Arc, Mutex, Weak}, + task::{Context, Poll, Waker}, +}; +use zbus::{ + fdo, + names::{BusName, UniqueName}, +}; + +#[derive(Debug)] +struct Inner { + unique_names: HashSet>, + stream: fdo::NameOwnerChangedStream, + // Waker from `update_task` is stored, so that task will still be woken after + // polling elsewhere. + waker: Waker, +} + +impl Drop for Inner { + fn drop(&mut self) { + // Wake `update_task` so it can terminate + self.waker.wake_by_ref(); + } +} + +impl Inner { + /// Process all events so far on `stream`, and update `unique_names`. + fn update_if_needed(&mut self) { + let mut context = Context::from_waker(&self.waker); + while let Poll::Ready(val) = self.stream.poll_next_unpin(&mut context) { + let val = val.unwrap(); + let args = val.args().unwrap(); + match args.name { + BusName::Unique(name) => { + if args.new_owner.is_some() { + self.unique_names.insert(name.to_owned()); + } else { + self.unique_names.remove(&name.to_owned()); + } + } + BusName::WellKnown(_) => {} + } + } + } +} + +/// This task polls the steam regularly, to make sure events on the stream aren't just +/// buffered indefinitely. +fn update_task(inner: Weak>) -> impl Future { + poll_fn(move |context| { + if let Some(inner) = inner.upgrade() { + let mut inner = inner.lock().unwrap(); + inner.waker = context.waker().clone(); + inner.update_if_needed(); + // Nothing to do now until waker is invoked + Poll::Pending + } else { + // All strong references have been dropped, so task has nothing left to do. + Poll::Ready(()) + } + }) +} + +#[derive(Clone, Debug)] +pub struct UniqueNames(Arc>); + +impl UniqueNames { + pub async fn new(connection: &zbus::Connection) -> zbus::Result { + let dbus = fdo::DBusProxy::new(connection).await?; + let stream = dbus.receive_name_owner_changed().await?; + + let names = dbus.list_names().await?; + let unique_names = names + .iter() + .filter_map(|n| match n.inner() { + BusName::Unique(name) => Some(name.to_owned()), + BusName::WellKnown(_) => None, + }) + .collect(); + + let inner = Arc::new(Mutex::new(Inner { + unique_names, + stream, + waker: Waker::noop().clone(), + })); + + tokio::spawn(update_task(Arc::downgrade(&inner))); + + Ok(UniqueNames(inner)) + } + + #[allow(dead_code)] + pub fn has_unique_name(&self, name: &UniqueName<'_>) -> bool { + let mut inner = self.0.lock().unwrap(); + inner.update_if_needed(); + inner.unique_names.contains(name) + } +} diff --git a/justfile b/justfile index a1ea651c..6c5033d3 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,7 @@ sharedir := rootdir + prefix + '/share' iconsdir := sharedir + '/icons/hicolor' prefixdir := prefix + '/bin' bindir := rootdir + prefixdir +libdir := rootdir + prefix + '/lib' default-schema-target := sharedir / 'cosmic' cosmic-applets-bin := prefixdir / 'cosmic-applets' @@ -57,8 +58,14 @@ _install_button id name: (_install_icons name) (_install_desktop name + '/data/' _install_metainfo: install -Dm0644 {{metainfo-src}} {{metainfo-dst}} +_install_status_notifier_watcher: + sed "s|@bindir@|{{prefixdir}}|" cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service.in > cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service + install -Dm0644 cosmic-applet-status-area/data/dbus-1/com.system76.CosmicStatusNotifierWatcher.service {{sharedir}}/dbus-1/services/com.system76.CosmicStatusNotifierWatcher.service + sed "s|@bindir@|{{prefixdir}}|" cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service.in > cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service + install -Dm0644 cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service {{libdir}}/systemd/user/com.system76.CosmicStatusNotifierWatcher.service + # Installs files into the system -install: (_install_bin 'cosmic-applets') (_link_applet 'cosmic-panel-button') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletA11y' 'cosmic-applet-a11y') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_install_applet 'com.system76.CosmicAppletInputSources' 'cosmic-applet-input-sources') (_install_applet 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') (_install_applet 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') (_install_applet 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') (_install_applet 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') (_install_applet 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') (_install_applet 'com.system76.CosmicAppletPower' 'cosmic-applet-power') (_install_applet 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') (_install_applet 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling') (_install_applet 'com.system76.CosmicAppletTime' 'cosmic-applet-time') (_install_applet 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelLauncherButton' 'cosmic-panel-launcher-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') (_install_metainfo) +install: (_install_bin 'cosmic-applets') (_link_applet 'cosmic-panel-button') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletA11y' 'cosmic-applet-a11y') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_install_applet 'com.system76.CosmicAppletInputSources' 'cosmic-applet-input-sources') (_install_applet 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') (_install_applet 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') (_install_applet 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') (_install_applet 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') (_install_applet 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') (_install_applet 'com.system76.CosmicAppletPower' 'cosmic-applet-power') (_install_applet 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') (_install_applet 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling') (_install_applet 'com.system76.CosmicAppletTime' 'cosmic-applet-time') (_install_applet 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelLauncherButton' 'cosmic-panel-launcher-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') (_install_metainfo) (_install_status_notifier_watcher) # Vendor Cargo dependencies locally vendor: