diff --git a/Cargo.toml b/Cargo.toml index 77684f4..bb75f9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,9 @@ pipewire = ["ashpd?/pipewire"] # Enables keycode serialization serde-keycode = ["iced_core/serde"] # smol async runtime -smol = ["iced/smol"] +smol = ["iced/smol", "zbus?/async-io"] # Tokio async runtime -tokio = ["dep:tokio", "ashpd/tokio", "iced/tokio"] +tokio = ["dep:tokio", "ashpd/tokio", "iced/tokio", "zbus?/tokio"] # Wayland window support wayland = [ "ashpd?/wayland", @@ -40,6 +40,7 @@ winit_wgpu = ["winit", "wgpu"] xdg-portal = ["ashpd"] # XXX Use "a11y"; which is causing a panic currently applet = ["wayland", "tokio", "cosmic-panel-config", "ron"] +zbus = ["dep:zbus", "serde", "ron"] [dependencies] apply = "0.3.0" @@ -59,6 +60,8 @@ ashpd = { version = "0.5.0", default-features = false, optional = true } url = "2.4.0" unicode-segmentation = "1.6" css-color = "0.2.5" +zbus = {version = "3.14.1", default-features = false, optional = true} +serde = { version = "1.0.180", optional = true } [target.'cfg(unix)'.dependencies] freedesktop-icons = "0.2.4" diff --git a/src/app/core.rs b/src/app/core.rs index 591b79b..02e18d3 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -62,6 +62,8 @@ pub struct Core { #[cfg(feature = "applet")] pub applet: crate::applet::Context, + + pub single_instance: bool, } impl Default for Core { @@ -104,6 +106,7 @@ impl Default for Core { }, #[cfg(feature = "applet")] applet: crate::applet::Context::default(), + single_instance: false, } } } diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 040bf16..b3d0548 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -55,6 +55,8 @@ pub enum Message { /// Capabilities the window manager supports #[cfg(feature = "wayland")] WmCapabilities(window::Id, WindowManagerCapabilities), + /// Activate the application + Activate(String), } #[derive(Default)] @@ -177,6 +179,12 @@ where }) .map(super::Message::Cosmic), window_events.map(super::Message::Cosmic), + #[cfg(feature = "zbus")] + self.app + .core() + .single_instance + .then(|| super::single_instance_subscription::()) + .unwrap_or_else(Subscription::none), ]) } @@ -356,6 +364,13 @@ impl Cosmic { }); } } + Message::Activate(token) => { + #[cfg(feature = "wayland")] + return iced_sctk::commands::activation::activate( + iced::window::Id::default(), + token, + ); + } } iced::Command::none() diff --git a/src/app/mod.rs b/src/app/mod.rs index 97f017d..fcfbc7c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,8 @@ pub mod message { } } +use std::str::FromStr; + pub use self::command::Command; pub use self::core::Core; pub use self::settings::Settings; @@ -46,7 +48,15 @@ use apply::Apply; use iced::Subscription; use iced::{window, Application as IcedApplication}; pub use message::Message; - +use url::Url; +#[cfg(feature = "zbus")] +use { + iced_futures::futures::channel::mpsc::{Receiver, Sender}, + iced_futures::futures::SinkExt, + std::any::TypeId, + std::collections::HashMap, + zbus::{dbus_interface, dbus_proxy, zvariant::Value}, +}; /// Launch a COSMIC application with the given [`Settings`]. /// /// # Errors @@ -62,6 +72,7 @@ pub fn run(settings: Settings, flags: App::Flags) -> iced::Res core.set_scale_factor(settings.scale_factor); core.set_window_width(settings.size.0); core.set_window_height(settings.size.1); + core.single_instance = settings.single_instance; THEME.with(move |t| { let mut cosmic_theme = t.borrow_mut(); @@ -92,6 +103,7 @@ pub fn run(settings: Settings, flags: App::Flags) -> iced::Res size_limits: settings.size_limits, title: None, transparent: settings.transparent, + xdg_activation_token: std::env::var("XDG_ACTIVATION_TOKEN").ok(), ..SctkWindowSettings::default() }) }; @@ -110,6 +122,223 @@ pub fn run(settings: Settings, flags: App::Flags) -> iced::Res cosmic::Cosmic::::run(iced) } +#[cfg(feature = "zbus")] +#[derive(Debug, Clone)] +pub struct DbusActivationMessage> { + pub activation_token: Option, + pub desktop_startup_id: Option, + pub msg: DbusActivationDetails, +} + +#[derive(Debug, Clone)] +pub enum DbusActivationDetails> { + Activate, + Open { + url: Vec, + }, + /// action can be deserialized as Flags + ActivateAction { + action: Action, + args: Args, + }, +} +#[cfg(feature = "zbus")] +#[derive(Debug, Default)] +pub struct DbusActivation(Option>); +#[cfg(feature = "zbus")] +impl DbusActivation { + #[must_use] + pub fn new() -> Self { + Self(None) + } + + pub fn rx(&mut self) -> Receiver { + let (tx, rx) = iced_futures::futures::channel::mpsc::channel(10); + self.0 = Some(tx); + rx + } +} + +#[cfg(feature = "zbus")] +#[dbus_proxy(interface = "org.freedesktop.DbusActivation")] +pub trait DbusActivationInterface { + /// Activate the application. + fn activate(&mut self, platform_data: HashMap<&str, Value<'_>>) -> zbus::Result<()>; + + /// Open the given URIs. + fn open( + &mut self, + uris: Vec<&str>, + platform_data: HashMap<&str, Value<'_>>, + ) -> zbus::Result<()>; + + /// Activate the given action. + fn activate_action( + &mut self, + action_name: &str, + parameter: Vec<&str>, + platform_data: HashMap<&str, Value<'_>>, + ) -> zbus::Result<()>; +} + +#[cfg(feature = "zbus")] +#[dbus_interface(interface = "org.freedesktop.DbusActivation")] +impl DbusActivation { + async fn activate(&mut self, platform_data: HashMap<&str, Value<'_>>) { + if let Some(tx) = &mut self.0 { + let _ = tx + .send(DbusActivationMessage { + activation_token: platform_data.get("activation-token").and_then(|t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }), + desktop_startup_id: platform_data.get("desktop-startup-id").and_then( + |t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }, + ), + msg: DbusActivationDetails::Activate, + }) + .await; + } + } + + async fn open(&mut self, uris: Vec<&str>, platform_data: HashMap<&str, Value<'_>>) { + if let Some(tx) = &mut self.0 { + let _ = tx + .send(DbusActivationMessage { + activation_token: platform_data.get("activation-token").and_then(|t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }), + desktop_startup_id: platform_data.get("desktop-startup-id").and_then( + |t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }, + ), + msg: DbusActivationDetails::Open { + url: uris.iter().filter_map(|u| Url::parse(u).ok()).collect(), + }, + }) + .await; + } + } + + async fn activate_action( + &mut self, + action_name: &str, + parameter: Vec<&str>, + platform_data: HashMap<&str, Value<'_>>, + ) { + if let Some(tx) = &mut self.0 { + let _ = tx + .send(DbusActivationMessage { + activation_token: platform_data.get("activation-token").and_then(|t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }), + desktop_startup_id: platform_data.get("desktop-startup-id").and_then( + |t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }, + ), + msg: DbusActivationDetails::ActivateAction { + action: action_name.to_string(), + args: parameter + .iter() + .map(std::string::ToString::to_string) + .collect(), + }, + }) + .await; + } + } +} + +#[cfg(feature = "zbus")] + +/// Launch a COSMIC application with the given [`Settings`]. +/// If the application is already running, the arguments will be passed to the +/// running instance. +/// # Errors +/// Returns error on application failure. +pub fn run_single_instance( + mut settings: Settings, + flags: App::Flags, +) -> iced::Result { + let activation_token = std::env::var("XDG_ACTIVATION_TOKEN").ok(); + + let override_single = std::env::var("COSMIC_SINGLE_INSTANCE") + .map(|v| &v.to_lowercase() == "false" || &v == "0") + .unwrap_or_default(); + if override_single { + return run::(settings, flags); + } + + let path: String = format!("/{}", App::APP_ID.replace('.', "/")); + settings.single_instance = true; + + let Ok(conn) = zbus::blocking::Connection::session() else { + tracing::warn!("Failed to connect to dbus"); + return run::(settings, flags); + }; + + if DbusActivationInterfaceProxyBlocking::builder(&conn) + .destination(App::APP_ID) + .ok() + .and_then(|b| b.path(path).ok()) + .and_then(|b| b.destination(App::APP_ID).ok()) + .and_then(|b| b.build().ok()) + .is_some_and(|mut p| { + match { + let mut platform_data = HashMap::new(); + if let Some(activation_token) = activation_token { + platform_data.insert("activation-token", activation_token.into()); + } + if let Ok(startup_id) = std::env::var("DESKTOP_STARTUP_ID") { + platform_data.insert("desktop-startup-id", startup_id.into()); + } + if let Some(action) = flags.action() { + let action = action.to_string(); + p.activate_action(&action, flags.args(), platform_data) + } else { + p.activate(platform_data) + } + } { + Ok(()) => { + tracing::info!("Successfully activated another instance"); + true + } + Err(err) => { + tracing::warn!(?err, "Failed to activate another instance"); + false + } + } + }) + { + tracing::info!("Another instance is running"); + Ok(()) + } else { + run::(settings, flags) + } +} + +pub trait CosmicFlags { + type SubCommand: FromStr + ToString + std::fmt::Debug + Clone + Send + 'static; + type Args: TryFrom> + Into> + std::fmt::Debug + Clone + Send + 'static; + #[must_use] + fn action(&self) -> Option<&Self::SubCommand> { + None + } + + #[must_use] + fn args(&self) -> Vec<&str> { + Vec::new() + } +} /// An interactive cross-platform COSMIC application. #[allow(unused_variables)] @@ -120,9 +349,27 @@ where /// Default async executor to use with the app. type Executor: iced_futures::Executor; + #[cfg(feature = "zbus")] + /// Argument received [`Application::new`]. + type Flags: Clone + CosmicFlags; + + #[cfg(not(feature = "zbus"))] /// Argument received [`Application::new`]. type Flags: Clone; + #[cfg(feature = "zbus")] + /// Message type specific to our app. + type Message: Clone + + From< + DbusActivationDetails< + ::SubCommand, + ::Args, + >, + > + std::fmt::Debug + + Send + + 'static; + + #[cfg(not(feature = "zbus"))] /// Message type specific to our app. type Message: Clone + std::fmt::Debug + Send + 'static; @@ -378,3 +625,101 @@ impl ApplicationExt for App { .into() } } + +#[cfg(feature = "zbus")] +fn single_instance_subscription() -> Subscription> { + use iced_futures::futures::StreamExt; + + iced::subscription::channel( + TypeId::of::(), + 10, + |mut output| async move { + let mut single_instance: DbusActivation = DbusActivation::new(); + let mut rx = single_instance.rx(); + if let Ok(builder) = zbus::ConnectionBuilder::session() { + let path: String = format!("/{}", App::APP_ID.replace('.', "/")); + if let Ok(conn) = builder.build().await { + // XXX Setup done this way seems to be more reliable. + // + // the docs for serve_at seem to imply it will replace the + // existing interface at the requested path, but it doesn't + // seem to work that way all the time. The docs for + // object_server().at() imply it won't replace the existing + // interface. + // + // request_name is used either way, with the builder or + // with the connection, but it must be done after the + // object server is setup. + if conn.object_server().at(path, single_instance).await != Ok(true) { + tracing::error!("Failed to serve dbus"); + std::process::exit(1); + } + if conn.request_name(App::APP_ID).await.is_err() { + tracing::error!("Failed to serve dbus"); + std::process::exit(1); + } + + #[cfg(feature = "smol")] + let handle = { + std::thread::spawn(move || { + let conn_clone = _conn.clone(); + + zbus::block_on(async move { + loop { + conn_clone.executor().tick().await; + } + }) + }) + }; + while let Some(mut msg) = rx.next().await { + if let Some(token) = msg.activation_token.take() { + if let Err(err) = output + .send(Message::Cosmic(cosmic::Message::Activate(token))) + .await + { + tracing::error!(?err, "Failed to send message"); + } + } + if let Some(msg) = match msg.msg { + DbusActivationDetails::Activate => { + Some(DbusActivationDetails::Activate) + } + DbusActivationDetails::Open { url } => { + Some(DbusActivationDetails::Open { url }) + } + DbusActivationDetails::ActivateAction { action, args } => { + if let (Ok(action), Ok(args)) = ( + ::SubCommand::from_str(&action), + ::Args::try_from(args), + ) { + Some(DbusActivationDetails::ActivateAction::< + ::SubCommand, + ::Args, + > { + action, + args, + }) + } else { + tracing::error!("Invalid action or args"); + None + } + } + } { + if let Err(err) = + output.send(Message::App(App::Message::from(msg))).await + { + tracing::error!(?err, "Failed to send message"); + } + } + } + } + } else { + tracing::warn!("Failed to connect to dbus for single instance"); + } + + loop { + iced::futures::pending!(); + } + }, + ) +} diff --git a/src/app/settings.rs b/src/app/settings.rs index 10ed4dd..1e43223 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -62,6 +62,9 @@ pub struct Settings { /// Whether the application should exit when there are no open windows pub(crate) exit_on_close: bool, + + /// Only allow a single instance of the application to run + pub single_instance: bool, } impl Settings { @@ -97,6 +100,7 @@ impl Default for Settings { theme: crate::theme::system_preference(), transparent: false, exit_on_close: true, + single_instance: false, } } }