From 9da2501f281bbbefebf6c10285a3d63d68158e9a Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 26 Oct 2023 10:26:32 -0400 Subject: [PATCH] feat: enable limiting applications to a single instance --- Cargo.toml | 5 +- src/app/core.rs | 3 + src/app/cosmic.rs | 8 +++ src/app/mod.rs | 160 ++++++++++++++++++++++++++++++++++++++++++++ src/app/settings.rs | 4 ++ 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a74098bf..d39d4549 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,9 @@ debug = ["iced/debug"] # Enables pipewire support in ashpd, if ashpd is enabled pipewire = ["ashpd?/pipewire"] # 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", @@ -57,6 +57,7 @@ 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} [target.'cfg(unix)'.dependencies] freedesktop-icons = "0.2.4" diff --git a/src/app/core.rs b/src/app/core.rs index 7cab04cc..9b06e72b 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -61,6 +61,8 @@ pub struct Core { #[cfg(feature = "applet")] pub applet: crate::applet::Context, + + pub single_instance: bool, } impl Default for Core { @@ -102,6 +104,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 edda1a6e..8a144bb5 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -150,6 +150,13 @@ where None }); + let single_instance = self + .app + .core() + .single_instance + .then(|| super::single_instance_subscription::()) + .map_or_else(Subscription::none, |s| s.map(super::Message::App)); + Subscription::batch(vec![ self.app.subscription().map(super::Message::App), keyboard_nav::subscription() @@ -174,6 +181,7 @@ where }) .map(super::Message::Cosmic), window_events.map(super::Message::Cosmic), + single_instance, ]) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 01b7a59d..23527e72 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,9 @@ pub mod message { } } +use std::any::TypeId; +use std::env::args; + pub use self::command::Command; pub use self::core::Core; pub use self::settings::Settings; @@ -45,7 +48,10 @@ use crate::widget::{context_drawer, nav_bar}; use apply::Apply; use iced::Subscription; use iced::{window, Application as IcedApplication}; +use iced_futures::futures::channel::mpsc::{Receiver, Sender}; +use iced_futures::futures::{SinkExt, StreamExt}; pub use message::Message; +use zbus::{dbus_interface, dbus_proxy}; /// Launch a COSMIC application with the given [`Settings`]. /// @@ -63,6 +69,8 @@ pub fn run(settings: Settings, flags: App::Flags) -> iced::Res 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(); cosmic_theme.set_theme(settings.theme.theme_type); @@ -111,6 +119,90 @@ pub fn run(settings: Settings, flags: App::Flags) -> iced::Res cosmic::Cosmic::::run(iced) } +#[derive(Debug, Default)] +pub struct SingleInstance(Option>>); + +impl SingleInstance { + #[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 + } +} + +#[dbus_interface(name = "com.system76.SingleInstance")] +impl SingleInstance { + async fn activate(&mut self, args: Vec) { + if let Some(tx) = &mut self.0 { + let _ = tx.send(args).await; + } + } +} + +#[dbus_proxy(interface = "com.system76.SingleInstance")] +pub trait SingleInstanceClient { + fn activate(&mut self, args: Vec) -> zbus::Result<()>; +} +/// 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 { + // try to claim the dbus name with our app id + settings.single_instance = true; + let path: String = format!("/{}", App::APP_ID.replace('.', "/")); + + let override_single = std::env::var("COSMIC_SINGLE_INSTANCE") + .map(|v| &v.to_lowercase() == "false") + .unwrap_or_default(); + + if override_single { + return run::(settings, flags); + } + + let Ok(conn) = zbus::blocking::Connection::session() else { + tracing::warn!("Failed to connect to dbus"); + return run::(settings, flags); + }; + + if SingleInstanceClientProxyBlocking::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 args = args().collect::>(); + p.activate(args) + } { + 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) + } +} + /// An interactive cross-platform COSMIC application. #[allow(unused_variables)] pub trait Application @@ -196,6 +288,12 @@ where Subscription::none() } + /// Another instance of the application received these arguments. + #[must_use] + fn update_args(args: Vec) -> Option { + None + } + /// Respond to an application-specific message. fn update(&mut self, message: Self::Message) -> iced::Command> { iced::Command::none() @@ -373,3 +471,65 @@ impl ApplicationExt for App { .into() } } + +fn single_instance_subscription() -> Subscription { + iced::subscription::channel( + TypeId::of::(), + 10, + |mut output| async move { + let mut single_instance: SingleInstance = SingleInstance::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(msg) = rx.next().await { + if let Some(msg) = App::update_args(msg) { + let _ = output.send(msg).await; + } else { + tracing::warn!("Failed to parse arguments from another instance"); + } + } + } + } 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 10ed4dd3..1e432235 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, } } }