diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 26d7968b..b65c87e4 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,5 @@ 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/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/app.rs b/crates/cardwire-gui/src/app.rs index 4c63491f..a9648bb0 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 }; @@ -6,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} }; @@ -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; @@ -537,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), ]) } @@ -571,7 +571,13 @@ impl AppState { fn open_or_focus_window(&mut self) -> Task { if let Some(id) = self.window_id { - window::gain_focus(id) + // 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), + )) } else { let (id, task) = window::open(default_window_settings()); self.window_id = Some(id); 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/helpers/dbus.rs b/crates/cardwire-gui/src/helpers/dbus.rs index b9314fc5..c5f00e4d 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,395 @@ 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. + /// + /// 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)) + // 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?; + + 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, + })); + } + 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), + } + } + } +} + +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() { + let _guard = INSTANCE_TEST_LOCK.lock().await; + 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(); + } + + #[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(); + } +} diff --git a/crates/cardwire-gui/src/helpers/mod.rs b/crates/cardwire-gui/src/helpers/mod.rs index 2274fc98..f8b1d709 100644 --- a/crates/cardwire-gui/src/helpers/mod.rs +++ b/crates/cardwire-gui/src/helpers/mod.rs @@ -2,4 +2,4 @@ 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 8f9ca01c..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; @@ -10,14 +11,20 @@ mod tray; mod ui; use app::AppState; +use args::CardwireArgs; +use clap::Parser; use env_logger::Env; +use errors::Result; +use helpers::AppInstance; -fn main() -> iced::Result { +fn main() -> Result<()> { 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 +32,26 @@ 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()?; + // 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(()); + }; + + iced::daemon( + move || AppState::new(&args), + AppState::update, + AppState::view, + ) + .title(AppState::title) + .theme(iced::Theme::Dark) + .subscription(move |state: &AppState| state.subscription(&instance)) + .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>), 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)) + }) + }) +} 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..ddf4caa7 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,17 @@ 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 --config-file=${(pkgs system).dbus}/share/dbus-1/session.conf \ + -- cargo test --release --offline --locked \ + -p cardwire-gui helpers::dbus::tests:: -- --ignored + runHook postCheck + ''; + }); pre-commit-check = git-hooks.lib.${system}.run { src = ./.; hooks = {