From 9e5afa59cae922626e37968f567a90531b471bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:55:17 +0200 Subject: [PATCH 01/11] impl single instance activation for the gui --- crates/cardwire-gui/src/app.rs | 14 ++- .../cardwire-gui/src/helpers/app_instance.rs | 113 ++++++++++++++++++ crates/cardwire-gui/src/helpers/mod.rs | 1 + crates/cardwire-gui/src/main.rs | 37 ++++-- crates/cardwire-gui/src/message.rs | 1 + 5 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 crates/cardwire-gui/src/helpers/app_instance.rs diff --git a/crates/cardwire-gui/src/app.rs b/crates/cardwire-gui/src/app.rs index 4c63491f..f58425ac 100644 --- a/crates/cardwire-gui/src/app.rs +++ b/crates/cardwire-gui/src/app.rs @@ -1,4 +1,3 @@ -use clap::Parser; use iced::{ Alignment, Element, Length::{Fill, Fixed}, Subscription, Task, widget::{column, container, row, stack}, window }; @@ -37,7 +36,7 @@ fn default_window_settings() -> window::Settings { } impl AppState { - pub fn new() -> (Self, Task) { + pub fn new(args: &CardwireArgs) -> (Self, Task) { let (gui_config, error) = match GuiConfig::load() { Ok(config) => (config, None), Err(error) => ( @@ -45,7 +44,6 @@ impl AppState { Some(format!("Could not load GUI settings: {error}")), ), }; - let args = CardwireArgs::parse(); // Hide the GUI if launched with --background, or if the start_in_tray setting is set let (window_id, open_window) = if args.background.is_some_and(|b| b) || (args.background.is_none() && gui_config.start_in_tray) @@ -89,6 +87,7 @@ impl AppState { | Message::GpuBlockResult(_) ); match message { + Message::Activate => return self.open_or_focus_window(), // Switch to a new page, clearing the pop-ups at the same time Message::SwitchPage(page) => { self.current_tab = page; @@ -571,7 +570,14 @@ impl AppState { fn open_or_focus_window(&mut self) -> Task { if let Some(id) = self.window_id { - window::gain_focus(id) + window::minimize(id, false) + .chain(window::gain_focus(id)) + // Wayland does not implement gain_focus; request activation + // through the compositor's attention protocol as well. + .chain(window::request_user_attention( + id, + Some(window::UserAttention::Informational), + )) } else { let (id, task) = window::open(default_window_settings()); self.window_id = Some(id); diff --git a/crates/cardwire-gui/src/helpers/app_instance.rs b/crates/cardwire-gui/src/helpers/app_instance.rs new file mode 100644 index 00000000..10c27841 --- /dev/null +++ b/crates/cardwire-gui/src/helpers/app_instance.rs @@ -0,0 +1,113 @@ +use std::{ + hash::{Hash, Hasher}, + sync::Arc, + time::Duration, +}; + +use iced::{Subscription, futures::stream}; +use tokio::sync::Notify; +use zbus::{Connection, connection::Builder, fdo::RequestNameFlags}; + +const BUS_NAME: &str = "org.opengamingcollective.cardwire.Gui"; +const OBJECT_PATH: &str = "/org/opengamingcollective/cardwire/Gui"; + +struct ActivationInterface(Arc); + +#[zbus::interface(name = "org.opengamingcollective.cardwire.Gui")] +impl ActivationInterface { + fn activate(&self) { + // Keep a permit if the GUI has not started listening yet. Repeated + // requests can be coalesced because they all open the same window. + self.0.notify_one(); + } +} + +/// Owns the session bus name for as long as the GUI is running. +#[derive(Debug, Clone)] +pub struct AppInstance { + connection: Connection, + activation: Arc, +} + +impl Hash for AppInstance { + fn hash(&self, state: &mut H) { + self.connection.unique_name().hash(state); + } +} + +impl AppInstance { + /// Returns `None` when another instance owns the name. Explicit background + /// launches leave that instance hidden; normal launches ask it to open. + pub async fn acquire(activate_existing: bool) -> zbus::Result> { + let activation = Arc::new(Notify::new()); + let connection = Builder::session()? + .method_timeout(Duration::from_secs(5)) + // Export before claiming the name so simultaneous launches can + // immediately call Activate on the winner. + .serve_at(OBJECT_PATH, ActivationInterface(activation.clone()))? + .build() + .await?; + + match connection + .request_name_with_flags(BUS_NAME, RequestNameFlags::DoNotQueue.into()) + .await + { + Ok(_) => Ok(Some(Self { + connection, + activation, + })), + Err(zbus::Error::NameTaken) => { + if activate_existing { + connection + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(BUS_NAME), "Activate", &()) + .await?; + } + Ok(None) + } + Err(error) => Err(error), + } + } + + pub fn subscription(&self) -> Subscription<()> { + Subscription::run_with(self.clone(), |instance| { + stream::unfold(instance.activation.clone(), |activation| async move { + activation.notified().await; + Some(((), activation)) + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iced::futures::FutureExt; + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn exclusive_ownership_activation_and_release() { + let primary = AppInstance::acquire(true).await.unwrap().unwrap(); + + assert!(AppInstance::acquire(false).await.unwrap().is_none()); + assert!(primary.activation.notified().now_or_never().is_none()); + + // Activation must survive arrival before the GUI subscribes. + assert!(AppInstance::acquire(true).await.unwrap().is_none()); + tokio::time::timeout(Duration::from_secs(1), primary.activation.notified()) + .await + .unwrap(); + + primary.connection.close().await.unwrap(); + + // Concurrent startups elect exactly one owner, without stale locks. + let (first, second) = tokio::join!(AppInstance::acquire(true), AppInstance::acquire(true)); + let first = first.unwrap(); + let second = second.unwrap(); + assert_ne!(first.is_some(), second.is_some()); + let winner = first.or(second).unwrap(); + tokio::time::timeout(Duration::from_secs(1), winner.activation.notified()) + .await + .unwrap(); + winner.connection.close().await.unwrap(); + } +} diff --git a/crates/cardwire-gui/src/helpers/mod.rs b/crates/cardwire-gui/src/helpers/mod.rs index 2274fc98..f41faf95 100644 --- a/crates/cardwire-gui/src/helpers/mod.rs +++ b/crates/cardwire-gui/src/helpers/mod.rs @@ -1,3 +1,4 @@ +pub mod app_instance; pub mod app_resolver; mod dbus; diff --git a/crates/cardwire-gui/src/main.rs b/crates/cardwire-gui/src/main.rs index 8f9ca01c..4f41a105 100644 --- a/crates/cardwire-gui/src/main.rs +++ b/crates/cardwire-gui/src/main.rs @@ -10,14 +10,20 @@ mod tray; mod ui; use app::AppState; +use args::CardwireArgs; +use clap::Parser; use env_logger::Env; +use helpers::app_instance::AppInstance; +use message::Message; -fn main() -> iced::Result { +fn main() -> Result<(), Box> { env_logger::Builder::from_env(Env::default().default_filter_or("info")) .format_target(false) .format_timestamp(None) .init(); + let args = CardwireArgs::parse(); + unsafe { // Vulkan wakes the dGPU std::env::set_var("WGPU_BACKEND", "gl"); @@ -25,10 +31,27 @@ fn main() -> iced::Result { std::env::set_var("WGPU_POWER_PREF", "low"); } - iced::daemon(AppState::new, AppState::update, AppState::view) - .title(AppState::title) - .theme(iced::Theme::Dark) - .subscription(AppState::subscription) - .default_font(gtk_font::default_font()) - .run() + // Keep D-Bus processing alive while Iced runs its event loop on this thread. + let runtime = tokio::runtime::Runtime::new()?; + let Some(instance) = runtime.block_on(AppInstance::acquire(args.background != Some(true)))? + else { + return Ok(()); + }; + + iced::daemon( + move || AppState::new(&args), + AppState::update, + AppState::view, + ) + .title(AppState::title) + .theme(iced::Theme::Dark) + .subscription(move |state: &AppState| { + iced::Subscription::batch([ + state.subscription(), + instance.subscription().map(|()| Message::Activate), + ]) + }) + .default_font(gtk_font::default_font()) + .run()?; + Ok(()) } diff --git a/crates/cardwire-gui/src/message.rs b/crates/cardwire-gui/src/message.rs index ede0f41a..8b2bbfbb 100644 --- a/crates/cardwire-gui/src/message.rs +++ b/crates/cardwire-gui/src/message.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, VecDeque}; #[derive(Debug, Clone)] pub enum Message { + Activate, SwitchPage(Page), FetchedMode(Result), FetchedAvailableModes(Result, String>), From 446cfe703b017f3aba0262b6622bf03e28945427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:24:49 +0200 Subject: [PATCH 02/11] separate dbus logic from iced subscription --- crates/cardwire-gui/src/app.rs | 5 +- .../cardwire-gui/src/helpers/app_instance.rs | 113 ------------------ crates/cardwire-gui/src/helpers/dbus.rs | 110 ++++++++++++++++- crates/cardwire-gui/src/helpers/mod.rs | 3 +- crates/cardwire-gui/src/main.rs | 10 +- crates/cardwire-gui/src/subscription.rs | 11 +- 6 files changed, 124 insertions(+), 128 deletions(-) delete mode 100644 crates/cardwire-gui/src/helpers/app_instance.rs diff --git a/crates/cardwire-gui/src/app.rs b/crates/cardwire-gui/src/app.rs index f58425ac..4ce1bb50 100644 --- a/crates/cardwire-gui/src/app.rs +++ b/crates/cardwire-gui/src/app.rs @@ -5,7 +5,7 @@ use log::error; use std::collections::BTreeMap; use crate::{ - args::CardwireArgs, gui_config::{GuiConfig, PrimaryClickAction}, helpers::{CardwireDbus, GpuDevice}, message::Message, models::{ + args::CardwireArgs, gui_config::{GuiConfig, PrimaryClickAction}, helpers::{AppInstance, CardwireDbus, GpuDevice}, message::Message, models::{ DaemonSettings, LogState, MainState, Mode, Page, PciDevice, SettingState, SmartState }, tray::{self, TrayAction, TrayHandle}, ui::{self, daemon_setting_page, error_bar, info_bar, pci_page} }; @@ -536,10 +536,11 @@ impl AppState { } } - pub fn subscription(&self) -> Subscription { + pub fn subscription(&self, instance: &AppInstance) -> Subscription { Subscription::batch([ crate::subscription::dbus_sub(), crate::subscription::tray_sub(), + crate::subscription::activation_sub(instance), window::close_events().map(Message::WindowClosed), ]) } diff --git a/crates/cardwire-gui/src/helpers/app_instance.rs b/crates/cardwire-gui/src/helpers/app_instance.rs deleted file mode 100644 index 10c27841..00000000 --- a/crates/cardwire-gui/src/helpers/app_instance.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::{ - hash::{Hash, Hasher}, - sync::Arc, - time::Duration, -}; - -use iced::{Subscription, futures::stream}; -use tokio::sync::Notify; -use zbus::{Connection, connection::Builder, fdo::RequestNameFlags}; - -const BUS_NAME: &str = "org.opengamingcollective.cardwire.Gui"; -const OBJECT_PATH: &str = "/org/opengamingcollective/cardwire/Gui"; - -struct ActivationInterface(Arc); - -#[zbus::interface(name = "org.opengamingcollective.cardwire.Gui")] -impl ActivationInterface { - fn activate(&self) { - // Keep a permit if the GUI has not started listening yet. Repeated - // requests can be coalesced because they all open the same window. - self.0.notify_one(); - } -} - -/// Owns the session bus name for as long as the GUI is running. -#[derive(Debug, Clone)] -pub struct AppInstance { - connection: Connection, - activation: Arc, -} - -impl Hash for AppInstance { - fn hash(&self, state: &mut H) { - self.connection.unique_name().hash(state); - } -} - -impl AppInstance { - /// Returns `None` when another instance owns the name. Explicit background - /// launches leave that instance hidden; normal launches ask it to open. - pub async fn acquire(activate_existing: bool) -> zbus::Result> { - let activation = Arc::new(Notify::new()); - let connection = Builder::session()? - .method_timeout(Duration::from_secs(5)) - // Export before claiming the name so simultaneous launches can - // immediately call Activate on the winner. - .serve_at(OBJECT_PATH, ActivationInterface(activation.clone()))? - .build() - .await?; - - match connection - .request_name_with_flags(BUS_NAME, RequestNameFlags::DoNotQueue.into()) - .await - { - Ok(_) => Ok(Some(Self { - connection, - activation, - })), - Err(zbus::Error::NameTaken) => { - if activate_existing { - connection - .call_method(Some(BUS_NAME), OBJECT_PATH, Some(BUS_NAME), "Activate", &()) - .await?; - } - Ok(None) - } - Err(error) => Err(error), - } - } - - pub fn subscription(&self) -> Subscription<()> { - Subscription::run_with(self.clone(), |instance| { - stream::unfold(instance.activation.clone(), |activation| async move { - activation.notified().await; - Some(((), activation)) - }) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use iced::futures::FutureExt; - - #[tokio::test] - #[ignore = "run under dbus-run-session to isolate the GUI bus name"] - async fn exclusive_ownership_activation_and_release() { - let primary = AppInstance::acquire(true).await.unwrap().unwrap(); - - assert!(AppInstance::acquire(false).await.unwrap().is_none()); - assert!(primary.activation.notified().now_or_never().is_none()); - - // Activation must survive arrival before the GUI subscribes. - assert!(AppInstance::acquire(true).await.unwrap().is_none()); - tokio::time::timeout(Duration::from_secs(1), primary.activation.notified()) - .await - .unwrap(); - - primary.connection.close().await.unwrap(); - - // Concurrent startups elect exactly one owner, without stale locks. - let (first, second) = tokio::join!(AppInstance::acquire(true), AppInstance::acquire(true)); - let first = first.unwrap(); - let second = second.unwrap(); - assert_ne!(first.is_some(), second.is_some()); - let winner = first.or(second).unwrap(); - tokio::time::timeout(Duration::from_secs(1), winner.activation.notified()) - .await - .unwrap(); - winner.connection.close().await.unwrap(); - } -} diff --git a/crates/cardwire-gui/src/helpers/dbus.rs b/crates/cardwire-gui/src/helpers/dbus.rs index b9314fc5..e5a7431b 100644 --- a/crates/cardwire-gui/src/helpers/dbus.rs +++ b/crates/cardwire-gui/src/helpers/dbus.rs @@ -1,7 +1,10 @@ -use std::collections::{BTreeMap, HashMap}; +use std::{ + collections::{BTreeMap, HashMap}, hash::{Hash, Hasher}, sync::Arc, time::Duration +}; +use tokio::sync::Notify; use zbus::{ - self, Connection, fdo, names::OwnedInterfaceName, zvariant::{OwnedObjectPath, OwnedValue} + self, Connection, connection::Builder, fdo::{self, RequestNameFlags}, names::OwnedInterfaceName, zvariant::{OwnedObjectPath, OwnedValue} }; use crate::models::{DaemonSettings, DbusAppMetadata, LsofData, Mode}; @@ -244,3 +247,106 @@ impl CardwireDbus { proxy.call("SetAppPolicy", &(app_id, policy)).await } } + +const BUS_NAME: &str = "org.opengamingcollective.cardwire.Gui"; +const OBJECT_PATH: &str = "/org/opengamingcollective/cardwire/Gui"; + +struct ActivationInterface(Arc); + +#[zbus::interface(name = "org.opengamingcollective.cardwire.Gui")] +impl ActivationInterface { + fn activate(&self) { + // Keep a permit if the GUI has not started listening yet. Repeated + // requests can be coalesced because they all open the same window. + self.0.notify_one(); + } +} + +/// Owns the session bus name for as long as the GUI is running. +#[derive(Debug, Clone)] +pub struct AppInstance { + connection: Connection, + activation: Arc, +} + +impl Hash for AppInstance { + fn hash(&self, state: &mut H) { + self.connection.unique_name().hash(state); + } +} + +impl AppInstance { + /// Returns the shared notification source for activation requests. + pub fn activation(&self) -> Arc { + Arc::clone(&self.activation) + } + + /// Returns `None` when another instance owns the name. Explicit background + /// launches leave that instance hidden; normal launches ask it to open. + pub async fn acquire(activate_existing: bool) -> zbus::Result> { + let activation = Arc::new(Notify::new()); + let connection = Builder::session()? + .method_timeout(Duration::from_secs(5)) + // Export before claiming the name so simultaneous launches can + // immediately call Activate on the winner. + .serve_at(OBJECT_PATH, ActivationInterface(Arc::clone(&activation)))? + .build() + .await?; + + match connection + .request_name_with_flags(BUS_NAME, RequestNameFlags::DoNotQueue.into()) + .await + { + Ok(_) => Ok(Some(Self { + connection, + activation, + })), + Err(zbus::Error::NameTaken) => { + if activate_existing { + connection + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(BUS_NAME), "Activate", &()) + .await?; + } + Ok(None) + } + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn exclusive_ownership_activation_and_release() { + let primary = AppInstance::acquire(true).await.unwrap().unwrap(); + + assert!(AppInstance::acquire(false).await.unwrap().is_none()); + tokio::select! { + biased; + _ = primary.activation.notified() => panic!("background launch requested activation"), + _ = std::future::ready(()) => {} + } + + // Activation must survive arrival before the GUI subscribes. + assert!(AppInstance::acquire(true).await.unwrap().is_none()); + tokio::time::timeout(Duration::from_secs(1), primary.activation.notified()) + .await + .unwrap(); + + primary.connection.close().await.unwrap(); + + // Concurrent startups elect exactly one owner, without stale locks. + let (first, second) = tokio::join!(AppInstance::acquire(true), AppInstance::acquire(true)); + let first = first.unwrap(); + let second = second.unwrap(); + assert_ne!(first.is_some(), second.is_some()); + let winner = first.or(second).unwrap(); + tokio::time::timeout(Duration::from_secs(1), winner.activation.notified()) + .await + .unwrap(); + winner.connection.close().await.unwrap(); + } +} diff --git a/crates/cardwire-gui/src/helpers/mod.rs b/crates/cardwire-gui/src/helpers/mod.rs index f41faf95..f8b1d709 100644 --- a/crates/cardwire-gui/src/helpers/mod.rs +++ b/crates/cardwire-gui/src/helpers/mod.rs @@ -1,6 +1,5 @@ -pub mod app_instance; pub mod app_resolver; mod dbus; pub use app_resolver::resolve_app_metadata; -pub use dbus::{CardwireDbus, GpuDevice}; +pub use dbus::{AppInstance, CardwireDbus, GpuDevice}; diff --git a/crates/cardwire-gui/src/main.rs b/crates/cardwire-gui/src/main.rs index 4f41a105..761596ce 100644 --- a/crates/cardwire-gui/src/main.rs +++ b/crates/cardwire-gui/src/main.rs @@ -13,8 +13,7 @@ use app::AppState; use args::CardwireArgs; use clap::Parser; use env_logger::Env; -use helpers::app_instance::AppInstance; -use message::Message; +use helpers::AppInstance; fn main() -> Result<(), Box> { env_logger::Builder::from_env(Env::default().default_filter_or("info")) @@ -45,12 +44,7 @@ fn main() -> Result<(), Box> { ) .title(AppState::title) .theme(iced::Theme::Dark) - .subscription(move |state: &AppState| { - iced::Subscription::batch([ - state.subscription(), - instance.subscription().map(|()| Message::Activate), - ]) - }) + .subscription(move |state: &AppState| state.subscription(&instance)) .default_font(gtk_font::default_font()) .run()?; Ok(()) diff --git a/crates/cardwire-gui/src/subscription.rs b/crates/cardwire-gui/src/subscription.rs index 4d346c32..3a6b8873 100644 --- a/crates/cardwire-gui/src/subscription.rs +++ b/crates/cardwire-gui/src/subscription.rs @@ -9,7 +9,7 @@ use tokio::select; use tokio_stream::StreamMap; use crate::{ - helpers::CardwireDbus, message::Message, models::{DaemonSettings, LogEntry, Mode, PciDevice}, tray + helpers::{AppInstance, CardwireDbus}, message::Message, models::{DaemonSettings, LogEntry, Mode, PciDevice}, tray }; use zbus::{ Connection, Proxy, names::OwnedInterfaceName, proxy, zvariant::{OwnedObjectPath, OwnedValue} @@ -730,3 +730,12 @@ pub fn dbus_sub() -> Subscription { smart_sub(), ]) } + +pub fn activation_sub(instance: &AppInstance) -> Subscription { + Subscription::run_with(instance.clone(), |instance| { + iced::futures::stream::unfold(instance.activation(), |activation| async move { + activation.notified().await; + Some((Message::Activate, activation)) + }) + }) +} From 6669022bdd5bcc03227fbac71c71243b21cea5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:37:32 +0200 Subject: [PATCH 03/11] clear up open_or_focus_window modifications --- crates/cardwire-gui/src/app.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/cardwire-gui/src/app.rs b/crates/cardwire-gui/src/app.rs index 4ce1bb50..3fc14ae5 100644 --- a/crates/cardwire-gui/src/app.rs +++ b/crates/cardwire-gui/src/app.rs @@ -571,14 +571,14 @@ impl AppState { fn open_or_focus_window(&mut self) -> Task { if let Some(id) = self.window_id { - window::minimize(id, false) - .chain(window::gain_focus(id)) - // Wayland does not implement gain_focus; request activation - // through the compositor's attention protocol as well. - .chain(window::request_user_attention( - id, - Some(window::UserAttention::Informational), - )) + // Wayland does not implement gain_focus; request activation + // through the compositor's attention protocol as well. + // source: iced_runtime::window::Action::GainFocus(Id) + window::minimize(id, false).chain(window::request_user_attention( + id, + Some(window::UserAttention::Informational), + )) + // window::gain_focus(id) } else { let (id, task) = window::open(default_window_settings()); self.window_id = Some(id); From c7780d51e63b22cb1c9a699fad97c3e67ac26239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:32:31 +0200 Subject: [PATCH 04/11] update gui dbus tests --- crates/cardwire-gui/src/helpers/dbus.rs | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/cardwire-gui/src/helpers/dbus.rs b/crates/cardwire-gui/src/helpers/dbus.rs index e5a7431b..1d49025b 100644 --- a/crates/cardwire-gui/src/helpers/dbus.rs +++ b/crates/cardwire-gui/src/helpers/dbus.rs @@ -318,9 +318,14 @@ impl AppInstance { mod tests { use super::*; + // These tests deliberately contend for the real application name. Serialize + // them within the private bus created by dbus-run-session. + static INSTANCE_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[tokio::test] #[ignore = "run under dbus-run-session to isolate the GUI bus name"] async fn exclusive_ownership_activation_and_release() { + let _guard = INSTANCE_TEST_LOCK.lock().await; let primary = AppInstance::acquire(true).await.unwrap().unwrap(); assert!(AppInstance::acquire(false).await.unwrap().is_none()); @@ -349,4 +354,92 @@ mod tests { .unwrap(); winner.connection.close().await.unwrap(); } + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn repeated_activations_coalesce_and_remain_usable() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + let primary = AppInstance::acquire(true).await.unwrap().unwrap(); + let activation = primary.activation(); + + for _ in 0..3 { + assert!(AppInstance::acquire(true).await.unwrap().is_none()); + } + tokio::time::timeout(Duration::from_secs(1), activation.notified()) + .await + .unwrap(); + tokio::select! { + biased; + _ = activation.notified() => panic!("activation requests were not coalesced"), + _ = std::future::ready(()) => {} + } + + // Consuming one request must not stop subsequent launches from working. + assert!(AppInstance::acquire(true).await.unwrap().is_none()); + tokio::time::timeout(Duration::from_secs(1), activation.notified()) + .await + .unwrap(); + primary.connection.close().await.unwrap(); + } + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn ownership_lasts_until_the_last_clone_is_dropped() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + let primary = AppInstance::acquire(true).await.unwrap().unwrap(); + let retained = primary.clone(); + let activation = primary.activation(); + drop(primary); + + assert!(AppInstance::acquire(true).await.unwrap().is_none()); + tokio::time::timeout(Duration::from_secs(1), activation.notified()) + .await + .unwrap(); + drop(retained); + + // Socket closure is asynchronous. Wait for the bus to observe it before + // checking that a fresh launch can own the name, without explicit close(). + let observer = Connection::session().await.unwrap(); + let bus = zbus::fdo::DBusProxy::new(&observer).await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while bus + .name_has_owner(BUS_NAME.try_into().unwrap()) + .await + .unwrap() + { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + let replacement = AppInstance::acquire(true).await.unwrap().unwrap(); + replacement.connection.close().await.unwrap(); + } + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn activation_failure_does_not_start_another_instance() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + // Simulate an owner that cannot handle Activate (e.g. an incompatible + // implementation). A failed call must be reported, not bypass the lock. + let owner = Builder::session() + .unwrap() + .serve_at(OBJECT_PATH, zbus::fdo::ObjectManager) + .unwrap() + .build() + .await + .unwrap(); + owner + .request_name_with_flags(BUS_NAME, RequestNameFlags::DoNotQueue.into()) + .await + .unwrap(); + + assert!(matches!( + AppInstance::acquire(true).await, + Err(zbus::Error::MethodError(..)) + )); + assert!(AppInstance::acquire(false).await.unwrap().is_none()); + owner.close().await.unwrap(); + } } From c679511db009e60bdbc7fb12520f506ece27af61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:33:05 +0200 Subject: [PATCH 05/11] update gui dbus documentation and cicd --- .github/workflows/cicd.yml | 18 ++++++++++++++++-- CONTRIBUTING.md | 1 + docs/development/build-dev.md | 20 ++++++++++++++++++++ flake.nix | 11 +++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 26d7968b..a426e05d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -102,7 +102,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y clang libbpf-dev libudev-dev pkg-config gcc-multilib libegl1-mesa-dev libvulkan-dev libglvnd-dev libwayland-dev libxkbcommon-dev libx11-dev libxcb1-dev libx11-xcb-dev + run: sudo apt-get update && sudo apt-get install -y clang libbpf-dev libudev-dev pkg-config gcc-multilib libegl1-mesa-dev libvulkan-dev libglvnd-dev libwayland-dev libxkbcommon-dev libx11-dev libxcb1-dev libx11-xcb-dev dbus-daemon - name: Install Nightly Rust (for aya-ebpf) uses: dtolnay/rust-toolchain@nightly with: @@ -117,6 +117,9 @@ jobs: run: cargo binstall bpf-linker - name: Test code run: cargo test + - name: Test GUI instance ownership on an isolated bus + run: >- + dbus-run-session -- cargo test --locked -p cardwire-gui helpers::dbus::tests:: -- --ignored rust-format: name: Rust Format runs-on: ubuntu-latest @@ -151,6 +154,17 @@ jobs: persist-credentials: false - uses: determinateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 - run: nix build .#checks.x86_64-linux.${{ matrix.vm }} + gui-dbus-test: + name: GUI D-Bus Test + runs-on: ubuntu-latest + needs: [prepare] + if: needs.prepare.outputs.run_rust_checks == 'true' || needs.prepare.outputs.run_nix_vm == 'true' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: determinateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + - run: nix build .#checks.x86_64-linux.gui-dbus mdbook-test: runs-on: ubuntu-latest needs: [prepare] @@ -169,4 +183,4 @@ jobs: id: pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Test with mdBook - run: mdbook test \ No newline at end of file + run: mdbook test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22f1da2e..43420089 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ CIs must pass before merging, this includes: - Nix VM 2 GPUs (Laptop conf) - Nix VM 3 GPUs - Nix VM 15 GPUs +- Nix GUI D-Bus Test If none of these CIs passes, the PR won't be merged diff --git a/docs/development/build-dev.md b/docs/development/build-dev.md index b0de6c24..ef9b98f4 100644 --- a/docs/development/build-dev.md +++ b/docs/development/build-dev.md @@ -19,6 +19,9 @@ nix build .#checks.x86_64-linux.vm-ci-2gpu nix build .#checks.x86_64-linux.vm-ci-3gpu nix build .#checks.x86_64-linux.vm-ci-15gpu +# Run GUI D-Bus tests on a private session bus +nix build .#checks.x86_64-linux.gui-dbus + # Build the vm and enter nix run .#nixosConfigurations.x86_64-linux.config.system.build.vm ``` @@ -34,6 +37,23 @@ rustup toolchain install nightly --component rustfmt cargo +nightly fmt --all --check ``` +Run the regular Rust tests from the repository root: + +```sh +cargo test --locked +``` + +The GUI instance D-Bus tests are ignored by default because they claim the real +`org.opengamingcollective.cardwire.Gui` session bus name. Run them on a private bus +so they cannot interact with a running Cardwire GUI: + +```sh +dbus-run-session -- cargo test --locked -p cardwire-gui helpers::dbus::tests:: -- --ignored +``` + +These tests need `dbus-run-session` and `dbus-daemon` (the `dbus-daemon` package on +Ubuntu, or `dbus` in the Nix development shell). + ```bash # Build the project make diff --git a/flake.nix b/flake.nix index ecf014d7..6b35bb80 100644 --- a/flake.nix +++ b/flake.nix @@ -67,6 +67,7 @@ (pkgs system).bpftools (pkgs system).udev (pkgs system).pkg-config + (pkgs system).dbus (pkgs system).mdbook (pkgs system).mdbook-mermaid (pkgs system).wayland @@ -119,6 +120,16 @@ inherit pkgs system self; lib = nixpkgs.lib; }; + gui-dbus = self.packages.${system}.default.overrideAttrs (old: { + doCheck = true; + nativeCheckInputs = (old.nativeCheckInputs or [ ]) ++ [ (pkgs system).dbus ]; + checkPhase = '' + runHook preCheck + dbus-run-session -- cargo test --release --offline --locked \ + -p cardwire-gui helpers::dbus::tests:: -- --ignored + runHook postCheck + ''; + }); pre-commit-check = git-hooks.lib.${system}.run { src = ./.; hooks = { From 30d654cdabbe55ac33f5d583bbe72c68636e8062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:14:23 +0200 Subject: [PATCH 06/11] add comments for how AppInstance is acquired in main --- crates/cardwire-gui/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cardwire-gui/src/main.rs b/crates/cardwire-gui/src/main.rs index 761596ce..1f0c572e 100644 --- a/crates/cardwire-gui/src/main.rs +++ b/crates/cardwire-gui/src/main.rs @@ -32,6 +32,10 @@ fn main() -> Result<(), Box> { // Keep D-Bus processing alive while Iced runs its event loop on this thread. let runtime = tokio::runtime::Runtime::new()?; + // Claim the exclusive session D-Bus name before starting the GUI. If another + // instance owns it, ask that instance to open (unless --background=true), + // then exit successfully. Some(instance) keeps our ownership alive while the + // GUI runs; D-Bus or activation errors propagate via `?` and prevent startup. let Some(instance) = runtime.block_on(AppInstance::acquire(args.background != Some(true)))? else { return Ok(()); From d0bae9b4f9b89aea85d4a3a1261ea734687e0998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:17:36 +0200 Subject: [PATCH 07/11] add CardwireGuiError with variants to use in cardwire-gui main --- Cargo.lock | 1 + crates/cardwire-gui/Cargo.toml | 1 + crates/cardwire-gui/src/errors.rs | 15 +++++++++++++++ crates/cardwire-gui/src/main.rs | 4 +++- 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 crates/cardwire-gui/src/errors.rs diff --git a/Cargo.lock b/Cargo.lock index 055c2c5c..bb5e1a26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,7 @@ dependencies = [ "serde", "serde_json", "strum", + "thiserror 2.0.20", "tokio", "tokio-stream", "toml", diff --git a/crates/cardwire-gui/Cargo.toml b/crates/cardwire-gui/Cargo.toml index ac720a6f..d4ccfd55 100644 --- a/crates/cardwire-gui/Cargo.toml +++ b/crates/cardwire-gui/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "GUI for cardwire GPU management" [dependencies] +thiserror.workspace = true tokio.workspace = true tokio-stream.workspace = true zbus.workspace = true diff --git a/crates/cardwire-gui/src/errors.rs b/crates/cardwire-gui/src/errors.rs new file mode 100644 index 00000000..e4fb648e --- /dev/null +++ b/crates/cardwire-gui/src/errors.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CardwireGuiError { + #[error("iced error: {0}")] + Iced(#[from] iced::Error), + + #[error("zbus error: {0}")] + Zbus(#[from] zbus::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/cardwire-gui/src/main.rs b/crates/cardwire-gui/src/main.rs index 1f0c572e..9a4dfb6f 100644 --- a/crates/cardwire-gui/src/main.rs +++ b/crates/cardwire-gui/src/main.rs @@ -1,5 +1,6 @@ mod app; mod args; +mod errors; mod gtk_font; mod gui_config; mod helpers; @@ -13,9 +14,10 @@ use app::AppState; use args::CardwireArgs; use clap::Parser; use env_logger::Env; +use errors::Result; use helpers::AppInstance; -fn main() -> Result<(), Box> { +fn main() -> Result<()> { env_logger::Builder::from_env(Env::default().default_filter_or("info")) .format_target(false) .format_timestamp(None) From 3d214196c67bd1ea24cace1e6f986c97fe1da756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:20:14 +0200 Subject: [PATCH 08/11] remove commented out code in open_or_focus_window --- crates/cardwire-gui/src/app.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/cardwire-gui/src/app.rs b/crates/cardwire-gui/src/app.rs index 3fc14ae5..a9648bb0 100644 --- a/crates/cardwire-gui/src/app.rs +++ b/crates/cardwire-gui/src/app.rs @@ -578,7 +578,6 @@ impl AppState { id, Some(window::UserAttention::Informational), )) - // window::gain_focus(id) } else { let (id, task) = window::open(default_window_settings()); self.window_id = Some(id); From 8658ba0cf61685ed06c051ad6f774d4c8c81296e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:25:06 +0200 Subject: [PATCH 09/11] format cicd.yml --- .github/workflows/cicd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a426e05d..b65c87e4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -184,3 +184,4 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Test with mdBook run: mdbook test + From 33fcac46f87ae82e4646d9962332336089ae1bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:19:17 +0200 Subject: [PATCH 10/11] fix dbus config file path inside gui-dbus nix flake --- flake.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6b35bb80..ddf4caa7 100644 --- a/flake.nix +++ b/flake.nix @@ -125,7 +125,8 @@ nativeCheckInputs = (old.nativeCheckInputs or [ ]) ++ [ (pkgs system).dbus ]; checkPhase = '' runHook preCheck - dbus-run-session -- cargo test --release --offline --locked \ + dbus-run-session --config-file=${(pkgs system).dbus}/share/dbus-1/session.conf \ + -- cargo test --release --offline --locked \ -p cardwire-gui helpers::dbus::tests:: -- --ignored runHook postCheck ''; From eaafd67c7dfb0efd60d2e5b6d4e1c432eca52a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bendeg=C3=BAz=20T=C3=B6r=C3=B6k?= <32034793+benditorok@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:23:27 +0200 Subject: [PATCH 11/11] retry dbus name acquisition on NameHasNoOwner error --- crates/cardwire-gui/src/helpers/dbus.rs | 226 ++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 15 deletions(-) diff --git a/crates/cardwire-gui/src/helpers/dbus.rs b/crates/cardwire-gui/src/helpers/dbus.rs index 1d49025b..c5f00e4d 100644 --- a/crates/cardwire-gui/src/helpers/dbus.rs +++ b/crates/cardwire-gui/src/helpers/dbus.rs @@ -283,7 +283,23 @@ impl AppInstance { /// Returns `None` when another instance owns the name. Explicit background /// launches leave that instance hidden; normal launches ask it to open. + /// + /// If the bus reports that the owner disappeared during activation, retries + /// name acquisition once. pub async fn acquire(activate_existing: bool) -> zbus::Result> { + Self::acquire_with_activation(activate_existing, activate_instance).await + } + + // Keep activation injectable so tests can reproduce an owner exiting between + // RequestName and Activate without depending on scheduling or sleeps. + async fn acquire_with_activation( + activate_existing: bool, + mut activate: F, + ) -> zbus::Result> + where + F: FnMut(Connection) -> Fut, + Fut: std::future::Future>, + { let activation = Arc::new(Notify::new()); let connection = Builder::session()? .method_timeout(Duration::from_secs(5)) @@ -293,35 +309,215 @@ impl AppInstance { .build() .await?; - match connection - .request_name_with_flags(BUS_NAME, RequestNameFlags::DoNotQueue.into()) - .await - { - Ok(_) => Ok(Some(Self { - connection, - activation, - })), - Err(zbus::Error::NameTaken) => { - if activate_existing { - connection - .call_method(Some(BUS_NAME), OBJECT_PATH, Some(BUS_NAME), "Activate", &()) - .await?; + let mut retry_available = true; + loop { + match connection + .request_name_with_flags(BUS_NAME, RequestNameFlags::DoNotQueue.into()) + .await + { + Ok(_) => { + return Ok(Some(Self { + connection, + activation, + })); } - Ok(None) + Err(zbus::Error::NameTaken) => { + if activate_existing { + match activate(connection.clone()).await { + Ok(()) => {} + Err(error) if retry_available && owner_disappeared(&error) => { + // The owner exited after RequestName. Try claiming + // the name again, or activate its replacement, once. + retry_available = false; + continue; + } + Err(error) => return Err(error), + } + } + return Ok(None); + } + Err(error) => return Err(error), } - Err(error) => Err(error), } } } +async fn activate_instance(connection: Connection) -> zbus::Result<()> { + connection + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(BUS_NAME), "Activate", &()) + .await?; + Ok(()) +} + +fn owner_disappeared(error: &zbus::Error) -> bool { + let zbus::Error::MethodError(_, _, reply) = error else { + return false; + }; + if reply.header().sender().map(|sender| sender.as_str()) != Some("org.freedesktop.DBus") { + return false; + } + matches!( + fdo::Error::from(error.clone()), + fdo::Error::NameHasNoOwner(_) | fdo::Error::ServiceUnknown(_) + ) +} + #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; // These tests deliberately contend for the real application name. Serialize // them within the private bus created by dbus-run-session. static INSTANCE_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn missing_owner_errors_retry_acquisition() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + for query_owner in [false, true] { + let owner = AppInstance::acquire(false).await.unwrap().unwrap(); + let mut attempts = 0; + let recovered = AppInstance::acquire_with_activation(true, |connection| { + attempts += 1; + let owner_connection = owner.connection.clone(); + async move { + // RequestName has already found this owner. Remove it before + // the activation call to deterministically reproduce the race. + owner_connection.close().await.unwrap(); + let result = if query_owner { + // GetNameOwner produces the other standard missing-owner + // reply, with a real bus sender and message header. + connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "GetNameOwner", + &(BUS_NAME,), + ) + .await + .map(|_| ()) + } else { + activate_instance(connection).await + }; + let error = result.unwrap_err(); + match fdo::Error::from(error.clone()) { + fdo::Error::NameHasNoOwner(_) => assert!(query_owner), + fdo::Error::ServiceUnknown(_) => assert!(!query_owner), + other => panic!("unexpected missing-owner error: {other}"), + } + Err(error) + } + }) + .await + .unwrap() + .unwrap(); + assert_eq!(attempts, 1); + recovered.connection.close().await.unwrap(); + } + } + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn retry_handles_a_replacement_but_stops_after_another_failure() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + for fail_again in [false, true] { + let owner = Arc::new(tokio::sync::Mutex::new( + AppInstance::acquire(false).await.unwrap(), + )); + let mut attempts = 0; + let result = AppInstance::acquire_with_activation(true, |connection| { + attempts += 1; + assert!(attempts <= 2, "acquisition retried more than once"); + let disappear = attempts == 1 || fail_again; + let owner = Arc::clone(&owner); + async move { + if !disappear { + return activate_instance(connection).await; + } + let mut owner = owner.lock().await; + owner.take().unwrap().connection.close().await.unwrap(); + let error = activate_instance(connection).await.unwrap_err(); + // A replacement claims the name before acquisition retries. + *owner = AppInstance::acquire(false).await.unwrap(); + Err(error) + } + }) + .await; + assert_eq!(attempts, 2); + let owner = owner.lock().await.take().unwrap(); + if fail_again { + let zbus::Error::MethodError(name, _, reply) = result.unwrap_err() else { + panic!("the second activation error was not preserved"); + }; + assert_eq!(name.as_str(), "org.freedesktop.DBus.Error.ServiceUnknown"); + assert_eq!( + reply.header().sender().unwrap().as_str(), + "org.freedesktop.DBus" + ); + } else { + assert!(result.unwrap().is_none()); + tokio::time::timeout(Duration::from_secs(1), owner.activation.notified()) + .await + .unwrap(); + } + owner.connection.close().await.unwrap(); + } + } + + struct RefusingActivation { + error: fdo::Error, + attempts: Arc, + } + + #[zbus::interface(name = "org.opengamingcollective.cardwire.Gui")] + impl RefusingActivation { + fn activate(&self) -> fdo::Result<()> { + self.attempts.fetch_add(1, Ordering::SeqCst); + Err(self.error.clone()) + } + } + + #[tokio::test] + #[ignore = "run under dbus-run-session to isolate the GUI bus name"] + async fn application_errors_are_preserved_without_retry() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + for expected in [ + fdo::Error::NameHasNoOwner("application error".into()), + fdo::Error::ServiceUnknown("application error".into()), + fdo::Error::Failed("application error".into()), + ] { + let attempts = Arc::new(AtomicUsize::new(0)); + let owner = Builder::session() + .unwrap() + .serve_at( + OBJECT_PATH, + RefusingActivation { + error: expected.clone(), + attempts: Arc::clone(&attempts), + }, + ) + .unwrap() + .name(BUS_NAME) + .unwrap() + .build() + .await + .unwrap(); + let error = AppInstance::acquire(true).await.unwrap_err(); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + let zbus::Error::MethodError(_, _, reply) = &error else { + panic!("application error was not preserved"); + }; + assert_eq!( + reply.header().sender().unwrap().as_str(), + owner.unique_name().unwrap().as_str() + ); + assert_eq!(fdo::Error::from(error), expected); + owner.close().await.unwrap(); + } + } + #[tokio::test] #[ignore = "run under dbus-run-session to isolate the GUI bus name"] async fn exclusive_ownership_activation_and_release() {