From 86aa51b35fe02065e7cf64784de42f085026dabe Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Sun, 28 Jul 2024 00:37:35 -0400 Subject: [PATCH 1/6] feat: Background portal I based my implementation on the official specs as well as code from GNOME, KDE, Xapp (Xfce), and Pantheon (elementary). KDE's imminently readable codebase served as this implementation's primary inspiration. Autostart is deprecated but seemingly still used, so this implementation will still support it for compatibility. The Background portal depends on a working Access portal as `xdg-desktop-portal` calls it to show the initial warning. References: * https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.impl.portal.Background.html * https://gitlab.gnome.org/GNOME/xdg-desktop-portal-gnome/-/blob/main/src/background.c * https://invent.kde.org/plasma/xdg-desktop-portal-kde/-/blob/master/src/background.cpp * https://github.com/linuxmint/xdg-desktop-portal-xapp/blob/f1c24244f90571209c56b7f45802b70e80da4922/src/background.c * https://github.com/elementary/portals/blob/d868cfa854c731e0f37615e225d5db07cc3f4604/src/Background/Portal.vala * https://github.com/flatpak/xdg-desktop-portal/discussions/1188 --- cosmic-portal-config/src/background.rs | 21 ++ cosmic-portal-config/src/lib.rs | 4 + data/cosmic.portal | 2 +- examples/background.rs | 124 +++++++++ i18n/en/xdg_desktop_portal_cosmic.ftl | 6 + src/app.rs | 85 +++--- src/background.rs | 350 +++++++++++++++++++++++++ src/main.rs | 1 + src/screenshot.rs | 36 ++- src/subscription.rs | 45 +++- src/wayland/mod.rs | 13 + 11 files changed, 638 insertions(+), 49 deletions(-) create mode 100644 cosmic-portal-config/src/background.rs create mode 100644 examples/background.rs create mode 100644 src/background.rs diff --git a/cosmic-portal-config/src/background.rs b/cosmic-portal-config/src/background.rs new file mode 100644 index 0000000..38d07d1 --- /dev/null +++ b/cosmic-portal-config/src/background.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Background { + /// Default preference for NotifyBackground's dialog + pub default_perm: PermissionDialog, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +pub enum PermissionDialog { + /// Grant apps permission to run in the background + Allow, + /// Deny apps permission to run in the background + Deny, + /// Always ask if new apps should be granted background permissions + #[default] + Ask, +} diff --git a/cosmic-portal-config/src/lib.rs b/cosmic-portal-config/src/lib.rs index d2eac54..b8aab57 100644 --- a/cosmic-portal-config/src/lib.rs +++ b/cosmic-portal-config/src/lib.rs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-only +pub mod background; pub mod screenshot; use cosmic_config::{cosmic_config_derive::CosmicConfigEntry, CosmicConfigEntry}; use serde::{Deserialize, Serialize}; +use background::Background; use screenshot::Screenshot; pub const APP_ID: &str = "com.system76.CosmicPortal"; @@ -17,6 +19,8 @@ pub const CONFIG_VERSION: u64 = 1; pub struct Config { /// Interactive screenshot settings pub screenshot: Screenshot, + /// Background portal settings + pub background: Background, } impl Config { diff --git a/data/cosmic.portal b/data/cosmic.portal index 01bdd79..f1174a7 100644 --- a/data/cosmic.portal +++ b/data/cosmic.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.cosmic -Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.ScreenCast +Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.ScreenCast UseIn=COSMIC diff --git a/examples/background.rs b/examples/background.rs new file mode 100644 index 0000000..683100c --- /dev/null +++ b/examples/background.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use ashpd::desktop::background::Background; +use cosmic::{ + app::{self, message, Core}, + executor, + iced::{Length, Size}, + widget, Command, +}; + +#[derive(Clone, Debug)] +pub enum Message { + BackgroundResponse(bool), + RequestBackground, +} + +pub struct App { + core: Core, + executable: String, + background_allowed: bool, +} + +impl App { + async fn request_background(executable: String) -> ashpd::Result { + log::info!("Requesting permission to run in the background for: {executable}"); + // Based off of the ashpd docs + // https://docs.rs/ashpd/latest/ashpd/desktop/background/index.html + Background::request() + .reason("Testing the background portal") + .auto_start(false) + .dbus_activatable(false) + .command(&[executable]) + .send() + .await? + .response() + } +} + +impl cosmic::Application for App { + type Executor = executor::single::Executor; + type Flags = (); + type Message = Message; + const APP_ID: &'static str = "org.cosmic.BackgroundPortalExample"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init(core: Core, _: Self::Flags) -> (Self, app::Command) { + ( + Self { + core, + executable: std::env::args().next().unwrap(), + background_allowed: false, + }, + Command::none(), + ) + } + + fn view(&self) -> cosmic::Element { + widget::row::with_children(vec![ + widget::text::title3(if self.background_allowed { + "Running in background" + } else { + "Not running in background" + }) + .width(Length::Fill) + .into(), + widget::button("Run in background") + .on_press(Message::RequestBackground) + .padding(8.0) + .into(), + ]) + .width(Length::Fill) + .height(Length::Fixed(64.0)) + .padding(16.0) + .into() + } + + fn update(&mut self, message: Self::Message) -> app::Command { + match message { + Message::BackgroundResponse(background_allowed) => { + log::info!("Permission to run in the background: {background_allowed}"); + self.background_allowed = background_allowed; + Command::none() + } + Message::RequestBackground => { + let executable = self.executable.clone(); + Command::perform(Self::request_background(executable), |result| { + let background_allowed = match result { + Ok(response) => { + assert!( + !response.auto_start(), + "Auto start shouldn't have been enabled" + ); + response.run_in_background() + } + Err(e) => { + log::error!("Background portal request failed: {e:?}"); + false + } + }; + + message::app(Message::BackgroundResponse(background_allowed)) + }) + } + } + } +} + +// TODO: Write a small flatpak manifest in order to test this better +#[tokio::main] +async fn main() -> cosmic::iced::Result { + env_logger::Builder::from_default_env().init(); + let settings = app::Settings::default() + .resizable(None) + .size(Size::new(512.0, 128.0)) + .exit_on_close(false); + app::run::(settings, ()) +} diff --git a/i18n/en/xdg_desktop_portal_cosmic.ftl b/i18n/en/xdg_desktop_portal_cosmic.ftl index 5e6fe12..3c1efbf 100644 --- a/i18n/en/xdg_desktop_portal_cosmic.ftl +++ b/i18n/en/xdg_desktop_portal_cosmic.ftl @@ -13,3 +13,9 @@ share-screen = Share your screen unknown-application = Unknown Application output = Output window = Window + +# Background portal +allow-once = Allow once +deny = Deny +bg-dialog-title = Background +bg-dialog-body = {$appname} requests to run in the background. This will allow it to run without any open windows. diff --git a/src/app.rs b/src/app.rs index e3bc50b..62e9fce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,6 @@ -use crate::{access, config, file_chooser, screencast_dialog, screenshot, subscription}; +use crate::{ + access, background, config, file_chooser, fl, screencast_dialog, screenshot, subscription, +}; use cosmic::iced_core::event::wayland::OutputEvent; use cosmic::widget; use cosmic::Task; @@ -14,12 +16,7 @@ pub(crate) fn run() -> cosmic::iced::Result { let settings = cosmic::app::Settings::default() .no_main_window(true) .exit_on_close(false); - let (config, config_handler) = config::Config::load(); - let flags = Flags { - config, - config_handler, - }; - cosmic::app::run::(settings, flags) + cosmic::app::run::(settings, ()) } // run iced app with no main surface @@ -28,7 +25,7 @@ pub struct CosmicPortal { pub tx: Option>, pub config_handler: Option, - pub config: config::Config, + pub tx_conf: Option>, pub access_args: Option, @@ -42,6 +39,8 @@ pub struct CosmicPortal { pub prev_rectangle: Option, pub wayland_helper: crate::wayland::WaylandHelper, + pub background_prompts: HashMap, + pub outputs: Vec, pub active_output: Option, } @@ -63,6 +62,7 @@ pub enum Msg { FileChooser(window::Id, file_chooser::Msg), Screenshot(screenshot::Msg), Screencast(screencast_dialog::Msg), + Background(background::Msg), Portal(subscription::Event), Output(OutputEvent, WlOutput), ConfigSetScreenshot(config::screenshot::Screenshot), @@ -70,16 +70,10 @@ pub enum Msg { ConfigSubUpdate(config::Config), } -#[derive(Clone, Debug)] -pub struct Flags { - pub config_handler: Option, - pub config: config::Config, -} - impl cosmic::Application for CosmicPortal { type Executor = cosmic::executor::Default; - type Flags = Flags; + type Flags = (); type Message = Msg; @@ -95,18 +89,15 @@ impl cosmic::Application for CosmicPortal { fn init( core: app::Core, - Flags { - config_handler, - config, - }: Self::Flags, + _: Self::Flags, ) -> (Self, cosmic::iced::Task>) { let wayland_conn = crate::wayland::connect_to_wayland(); let wayland_helper = crate::wayland::WaylandHelper::new(wayland_conn); ( Self { core, - config_handler, - config, + config_handler: None, + tx_conf: None, access_args: Default::default(), file_choosers: Default::default(), screenshot_args: Default::default(), @@ -114,6 +105,7 @@ impl cosmic::Application for CosmicPortal { screencast_tab_model: Default::default(), location_options: Vec::new(), prev_rectangle: Default::default(), + background_prompts: Default::default(), outputs: Default::default(), active_output: Default::default(), wayland_helper, @@ -134,6 +126,8 @@ impl cosmic::Application for CosmicPortal { screencast_dialog::view(self).map(Msg::Screencast) } else if self.outputs.iter().any(|o| o.id == id) { screenshot::view(self, id).map(Msg::Screenshot) + } else if self.background_prompts.contains_key(&id) { + background::view(self, id).map(Msg::Background) } else { file_chooser::view(self, id) } @@ -160,17 +154,29 @@ impl cosmic::Application for CosmicPortal { subscription::Event::CancelScreencast(handle) => { screencast_dialog::cancel(self, handle).map(cosmic::Action::App) } + subscription::Event::Background(args) => { + background::update_args(self, args).map(cosmic::app::Message::App) + } subscription::Event::Config(config) => self.update(Msg::ConfigSubUpdate(config)), subscription::Event::Accent(_) | subscription::Event::IsDark(_) - | subscription::Event::HighContrast(_) => cosmic::iced::Task::none(), - subscription::Event::Init(tx) => { + | subscription::Event::HighContrast(_) + | subscription::Event::BackgroundToplevels => cosmic::iced::Task::none(), + subscription::Event::Init { + tx, + tx_conf, + handler, + } => { + let config = tx_conf.borrow().clone(); self.tx = Some(tx); - Task::none() + self.tx_conf = Some(tx_conf); + self.config_handler = handler; + self.update(Msg::ConfigSubUpdate(config)) } }, Msg::Screenshot(m) => screenshot::update_msg(self, m).map(cosmic::Action::App), Msg::Screencast(m) => screencast_dialog::update_msg(self, m).map(cosmic::Action::App), + Msg::Background(m) => background::update_msg(self, m).map(cosmic::Action::App), Msg::Output(o_event, wl_output) => { match o_event { OutputEvent::Created(Some(info)) @@ -244,19 +250,36 @@ impl cosmic::Application for CosmicPortal { cosmic::iced::Task::none() } Msg::ConfigSetScreenshot(screenshot) => { - match &mut self.config_handler { - Some(handler) => { - if let Err(e) = self.config.set_screenshot(handler, screenshot) { - log::error!("Failed to save screenshot config: {e}") - } + match (self.tx_conf.as_mut(), &mut self.config_handler) { + (Some(tx), Some(handler)) => { + tx.send_if_modified(|config| { + if screenshot != config.screenshot { + if let Err(e) = config.set_screenshot(handler, screenshot) { + log::error!("Failed to save screenshot config: {e}"); + } + true + } else { + false + } + }); } - None => log::error!("Failed to save config: No config handler"), + _ => log::error!("Failed to save config: No config handler"), } cosmic::iced::Task::none() } Msg::ConfigSubUpdate(config) => { - self.config = config; + if let Some(tx) = self.tx_conf.as_ref() { + tx.send_if_modified(|current| { + if config != *current { + *current = config; + true + } else { + false + } + }); + } + cosmic::iced::Task::none() } } diff --git a/src/background.rs b/src/background.rs new file mode 100644 index 0000000..0db32a2 --- /dev/null +++ b/src/background.rs @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use std::sync::{Arc, Condvar, Mutex}; + +// use ashpd::enumflags2::{bitflags, BitFlag, BitFlags}; +use cosmic::{iced::window, widget}; +use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1; +use futures::{FutureExt, TryFutureExt}; +use tokio::sync::{mpsc, watch}; +use zbus::{fdo, object_server::SignalContext, zvariant}; + +use crate::{ + app::CosmicPortal, + config::{self, background::PermissionDialog}, + fl, subscription, + wayland::WaylandHelper, + PortalResponse, +}; + +/// Background portal backend +/// +/// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.impl.portal.Background.html +pub struct Background { + wayland_helper: WaylandHelper, + tx: mpsc::Sender, + rx_conf: watch::Receiver, +} + +impl Background { + pub fn new( + wayland_helper: WaylandHelper, + tx: mpsc::Sender, + rx_conf: watch::Receiver, + ) -> Self { + let toplevel_signal = wayland_helper.toplevel_signal(); + let toplevel_tx = tx.clone(); + std::thread::Builder::new() + .name("background-toplevel-updates".into()) + .spawn(move || Background::toplevel_signal(toplevel_signal, toplevel_tx)) + .expect("Spawning toplevels update thread should succeed"); + + Self { + wayland_helper, + tx, + rx_conf, + } + } + + /// Trigger [`Background::running_applications_changed`] on toplevel updates + fn toplevel_signal(signal: Arc<(Mutex, Condvar)>, tx: mpsc::Sender) { + loop { + let (lock, cvar) = &*signal; + let mut updated = lock.lock().unwrap(); + + log::debug!("Waiting for toplevel updates"); + while !*updated { + updated = cvar.wait(updated).unwrap(); + } + + log::debug!("Emitting RunningApplicationsChanged in response to toplevel updates"); + debug_assert!(*updated); + *updated = false; + if let Err(e) = tx.blocking_send(subscription::Event::BackgroundToplevels) { + log::warn!("Failed sending event to trigger RunningApplicationsChanged: {e:?}"); + } + } + } +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Background")] +impl Background { + /// Status on running apps (active, running, or background) + async fn get_app_state(&self) -> fdo::Result> { + let toplevels: Vec<_> = self + .wayland_helper + .toplevels() + .into_iter() + .map(|(_, info)| { + let status = if info + .state + .contains(&zcosmic_toplevel_handle_v1::State::Activated) + { + AppStatus::Active + } else if !info.state.is_empty() { + AppStatus::Running + } else { + // xxx Is this the correct way to determine if a program is running in the + // background? If a toplevel exists but isn't running, activated, et cetera, + // then it logically must be in the background (?) + AppStatus::Background + }; + + AppState { + app_id: info.app_id, + status, + } + }) + .collect(); + + log::debug!("GetAppState returning {} toplevels", toplevels.len()); + #[cfg(debug_assertions)] + log::trace!("App status: {toplevels:#?}"); + + Ok(toplevels) + } + + /// Notifies the user that an app is running in the background + async fn notify_background( + &self, + handle: zvariant::ObjectPath<'_>, + app_id: String, + name: String, + ) -> PortalResponse { + log::debug!("Request handle: {handle:?}"); + + // Request a copy of the config from the main app instance + // This is also cleaner than storing the config because it's difficult to keep it + // updated without synch primitives and we also avoid &mut self + let config = self.rx_conf.borrow().background; + + match config.default_perm { + // Skip dialog based on default response set in configs + PermissionDialog::Allow => { + log::debug!("AUTO ALLOW {name} based on default permission"); + PortalResponse::Success(NotifyBackgroundResult { + result: PermissionResponse::Allow, + }) + } + PermissionDialog::Deny => { + log::debug!("AUTO DENY {name} based on default permission"); + PortalResponse::Success(NotifyBackgroundResult { + result: PermissionResponse::Deny, + }) + } + // Dialog + PermissionDialog::Ask => { + log::debug!("Requesting user permission for {app_id} ({name})",); + + let handle = handle.to_owned(); + let id = window::Id::unique(); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + self.tx + .send(subscription::Event::Background(Args { + handle, + id, + app_id, + tx, + })) + .inspect_err(|e| { + log::error!("Failed to send message to register permissions dialog: {e:?}") + }) + .map_ok(|_| PortalResponse::::Other) + .map_err(|_| ()) + .and_then(|_| rx.recv().map(|out| out.ok_or(()))) + .unwrap_or_else(|_| PortalResponse::Other) + .await + } + } + } + + /// Enable or disable autostart for an application + /// + /// Deprecated but seemingly still in use + pub async fn enable_autostart( + &self, + app_id: String, + enable: bool, + commandline: Vec, + flags: u32, + ) -> fdo::Result { + log::warn!("Autostart not implemented"); + Ok(enable) + } + + /// Emitted when running applications change their state + #[zbus(signal)] + pub async fn running_applications_changed(context: &SignalContext<'_>) -> zbus::Result<()>; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, zvariant::Type)] +#[zvariant(signature = "u")] +pub enum AppStatus { + /// No open windows + Background = 0, + /// At least one opened window + Running, + /// In the foreground + Active, +} + +#[derive(Clone, Debug, serde::Serialize, zvariant::Type)] +#[zvariant(signature = "{sv}")] +struct AppState { + app_id: String, + status: AppStatus, +} + +/// Result vardict for [`Background::notify_background`] +#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] +#[zvariant(signature = "a{sv}")] +pub struct NotifyBackgroundResult { + result: PermissionResponse, +} + +/// Response for apps requesting to run in the background for [`Background::notify_background`] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, zvariant::Type)] +#[zvariant(signature = "u")] +pub enum PermissionResponse { + /// Background permission denied + Deny = 0, + /// Background permission allowed whenever asked + Allow, + /// Background permission allowed for a single instance + AllowOnce, +} + +/// Background permissions dialog state +#[derive(Clone, Debug)] +pub struct Args { + pub handle: zvariant::ObjectPath<'static>, + pub id: window::Id, + pub app_id: String, + tx: mpsc::Sender>, +} + +/// Background permissions dialog response +#[derive(Debug, Clone)] +pub enum Msg { + Response { + id: window::Id, + choice: PermissionResponse, + }, + Cancel(window::Id), +} + +// #[bitflags] +// #[repr(u32)] +// #[derive(Clone, Copy, Debug, PartialEq)] +// enum AutostartFlags { +// DBus = 0x01, +// } + +/// Permissions dialog +pub(crate) fn view(portal: &CosmicPortal, id: window::Id) -> cosmic::Element { + let name = portal + .background_prompts + .get(&id) + .map(|args| args.app_id.as_str()) + // xxx What do I do here? + .unwrap_or("Invalid window id"); + + // TODO: Add cancel + widget::dialog(fl!("bg-dialog-title")) + .body(fl!("bg-dialog-body", appname = name)) + .icon(widget::icon::from_name("dialog-warning-symbolic").size(64)) + .primary_action( + widget::button::suggested(fl!("allow")).on_press(Msg::Response { + id, + choice: PermissionResponse::Allow, + }), + ) + .secondary_action( + widget::button::suggested(fl!("allow-once")).on_press(Msg::Response { + id, + choice: PermissionResponse::AllowOnce, + }), + ) + .tertiary_action( + widget::button::destructive(fl!("deny")).on_press(Msg::Response { + id, + choice: PermissionResponse::Deny, + }), + ) + .into() +} + +/// Update Background dialog args for a specific window +pub fn update_args(portal: &mut CosmicPortal, args: Args) -> cosmic::Command { + if let Some(old) = portal.background_prompts.insert(args.id, args) { + // xxx Can this even happen? + log::trace!( + "Replaced old dialog args for (window: {:?}) (app: {}) (handle: {})", + old.id, + old.app_id, + old.handle + ) + } + + cosmic::Command::none() +} + +pub fn update_msg(portal: &mut CosmicPortal, msg: Msg) -> cosmic::Command { + match msg { + Msg::Response { id, choice } => { + let Some(Args { + handle, + id, + app_id, + tx, + }) = portal.background_prompts.remove(&id) + else { + log::warn!("Window {id:?} doesn't exist for some reason"); + return cosmic::Command::none(); + }; + + log::trace!( + "User selected {choice:?} for (app: {app_id}) (handle: {handle}) on window {id:?}" + ); + // Return result to portal handler and update the config + tokio::spawn(async move { + if let Err(e) = tx + .send(PortalResponse::Success(NotifyBackgroundResult { + result: choice, + })) + .await + { + log::error!( + "Failed to send response from user to the background handler: {e:?}" + ); + } + }); + } + Msg::Cancel(id) => { + let Some(Args { + handle, + id, + app_id, + tx, + }) = portal.background_prompts.remove(&id) + else { + log::warn!("Window {id:?} doesn't exist for some reason"); + return cosmic::Command::none(); + }; + + log::trace!( + "User cancelled dialog for (window: {:?}) (app: {}) (handle: {})", + id, + app_id, + handle + ); + tokio::spawn(async move { + if let Err(e) = tx.send(PortalResponse::Cancelled).await { + log::error!("Failed to send cancellation response to background handler {e:?}"); + } + }); + } + } + + cosmic::Command::none() +} diff --git a/src/main.rs b/src/main.rs index 6a00461..f829a2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ pub use cosmic_portal_config as config; mod access; mod app; +mod background; mod buffer; mod documents; mod file_chooser; diff --git a/src/screenshot.rs b/src/screenshot.rs index bc77262..7608bc1 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -19,6 +19,7 @@ use std::borrow::Cow; use std::num::NonZeroU32; use std::{collections::HashMap, io, path::PathBuf}; use tokio::sync::mpsc::Sender; +use tokio::sync::watch; use wayland_client::protocol::wl_output::WlOutput; use zbus::zvariant; @@ -150,11 +151,20 @@ pub struct RectDimension { pub struct Screenshot { wayland_helper: WaylandHelper, tx: Sender, + rx_conf: watch::Receiver, } impl Screenshot { - pub fn new(wayland_helper: WaylandHelper, tx: Sender) -> Self { - Self { wayland_helper, tx } + pub fn new( + wayland_helper: WaylandHelper, + tx: Sender, + rx_conf: watch::Receiver, + ) -> Self { + Self { + wayland_helper, + tx, + rx_conf, + } } async fn interactive_toplevel_images( @@ -398,12 +408,9 @@ impl Screenshot { ) -> PortalResponse { // connection.object_server().at(&handle, Request); - // The screenshot handler is created when the portal is launched, but requests are - // handled on demand. The handler does not store extra state such as a reference to the - // portal. Storing a copy of the config is unideal because it would remain out of date. - // - // The most straightforward solution is to load the screenshot config here - let config = config::Config::load().0.screenshot; + // borrow() is simpler here as we don't need &mut self and reading a possibly out of + // date config isn't a major issue + let config = self.rx_conf.borrow().screenshot.clone(); // TODO create handle, show dialog let mut outputs = Vec::new(); @@ -734,7 +741,11 @@ pub fn update_msg(portal: &mut CosmicPortal, msg: Msg) -> cosmic::Task cosmic::Task), + Background(crate::background::Args), + BackgroundToplevels, Accent(Srgba), IsDark(bool), HighContrast(bool), Config(config::Config), - Init(tokio::sync::mpsc::Sender), + Init { + tx: tokio::sync::mpsc::Sender, + tx_conf: tokio::sync::watch::Sender, + handler: Option, + }, } pub enum State { @@ -73,14 +79,20 @@ pub(crate) async fn process_changes( match state { State::Init => { let (tx, rx) = tokio::sync::mpsc::channel(10); + let (config, handler) = config::Config::load(); + let (tx_conf, rx_conf) = tokio::sync::watch::channel(config); let connection = zbus::connection::Builder::session()? .name(DBUS_NAME)? .serve_at(DBUS_PATH, Access::new(wayland_helper.clone(), tx.clone()))? + .serve_at( + DBUS_PATH, + Background::new(wayland_helper.clone(), tx.clone(), rx_conf.clone()), + )? .serve_at(DBUS_PATH, FileChooser::new(tx.clone()))? .serve_at( DBUS_PATH, - Screenshot::new(wayland_helper.clone(), tx.clone()), + Screenshot::new(wayland_helper.clone(), tx.clone(), rx_conf.clone()), )? .serve_at( DBUS_PATH, @@ -89,7 +101,13 @@ pub(crate) async fn process_changes( .serve_at(DBUS_PATH, Settings::new())? .build() .await?; - _ = output.send(Event::Init(tx)).await; + _ = output + .send(Event::Init { + tx, + tx_conf, + handler, + }) + .await; *state = State::Waiting(connection, rx); } State::Waiting(conn, rx) => { @@ -120,6 +138,19 @@ pub(crate) async fn process_changes( log::error!("Error sending screencast cancel: {:?}", err); }; } + Event::Background(args) => { + if let Err(err) = output.send(Event::Background(args)).await { + log::error!("Error sending background event: {:?}", err); + } + } + Event::BackgroundToplevels => { + let background = conn + .object_server() + .interface::<_, Background>(DBUS_PATH) + .await?; + Background::running_applications_changed(background.signal_context()) + .await?; + } Event::Accent(a) => { let object_server = conn.object_server(); let iface_ref = object_server.interface::<_, Settings>(DBUS_PATH).await?; @@ -180,7 +211,7 @@ pub(crate) async fn process_changes( log::error!("Error sending config update: {:?}", err) } } - Event::Init(_) => {} + Event::Init { .. } => {} } } } diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index bf6a9c5..c5d8fbe 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -93,6 +93,7 @@ struct WaylandHelperInner { output_infos: Mutex>, output_toplevels: Mutex>>, toplevels: Mutex>, + toplevel_update: Arc<(Mutex, Condvar)>, qh: QueueHandle, capturer: Capturer, wl_shm: wl_shm::WlShm, @@ -160,6 +161,12 @@ impl AppData { *self.wayland_helper.inner.toplevels.lock().unwrap() = self.toplevel_info_state.toplevels().cloned().collect(); + + // Signal that toplevels were updated; the actual updates are unimportant here + let (lock, cvar) = &*self.wayland_helper.inner.toplevel_update; + let mut updated = lock.lock().unwrap(); + *updated = true; + cvar.notify_all(); } } @@ -246,6 +253,7 @@ impl WaylandHelper { let screencopy_state = ScreencopyState::new(&globals, &qh); let shm_state = Shm::bind(&globals, &qh).unwrap(); let zwp_dmabuf = globals.bind(&qh, 4..=4, sctk::globals::GlobalData).unwrap(); + let toplevel_update = Arc::new((Mutex::new(false), Condvar::new())); let wayland_helper = WaylandHelper { inner: Arc::new(WaylandHelperInner { conn, @@ -253,6 +261,7 @@ impl WaylandHelper { output_infos: Mutex::new(HashMap::new()), output_toplevels: Mutex::new(HashMap::new()), toplevels: Mutex::new(Vec::new()), + toplevel_update, qh: qh.clone(), capturer: screencopy_state.capturer().clone(), wl_shm: shm_state.wl_shm().clone(), @@ -298,6 +307,10 @@ impl WaylandHelper { self.inner.toplevels.lock().unwrap().clone() } + pub fn toplevel_signal(&self) -> Arc<(Mutex, Condvar)> { + Arc::clone(&self.inner.toplevel_update) + } + pub fn output_info(&self, output: &wl_output::WlOutput) -> Option { self.inner.output_infos.lock().unwrap().get(output).cloned() } From bfb8bcdc8e6e706b67d97c9daa92f56708ce97de Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Sun, 8 Sep 2024 03:17:39 -0400 Subject: [PATCH 2/6] feat(background): Autostart method --- Cargo.lock | 1 + Cargo.toml | 1 + src/background.rs | 154 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac9d6e8..d6ddd1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8631,6 +8631,7 @@ dependencies = [ "rust-embed", "rustix 0.38.44", "serde", + "shlex", "tempfile", "tokio", "url", diff --git a/Cargo.toml b/Cargo.toml index 8c1ad8c..7865c94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ env_logger = "0.11.6" dirs = "6.0.0" chrono = "0.4" url = "2.5" +shlex = "1" # i18n i18n-embed = { version = "0.15.3", features = [ "fluent-system", diff --git a/src/background.rs b/src/background.rs index 0db32a2..3c89a9d 100644 --- a/src/background.rs +++ b/src/background.rs @@ -1,12 +1,21 @@ // SPDX-License-Identifier: GPL-3.0-only -use std::sync::{Arc, Condvar, Mutex}; +use std::{ + borrow::Cow, + io, + path::Path, + sync::{Arc, Condvar, Mutex}, +}; // use ashpd::enumflags2::{bitflags, BitFlag, BitFlags}; use cosmic::{iced::window, widget}; use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1; use futures::{FutureExt, TryFutureExt}; -use tokio::sync::{mpsc, watch}; +use tokio::{ + fs, + io::AsyncWriteExt, + sync::{mpsc, watch}, +}; use zbus::{fdo, object_server::SignalContext, zvariant}; use crate::{ @@ -65,13 +74,34 @@ impl Background { } } } + + /// Write `desktop_entry` to path `launch_entry`. + /// + /// The primary purpose of this function is to ease error handling. + async fn write_autostart( + autostart_entry: &Path, + desktop_entry: &freedesktop_desktop_entry::DesktopEntry<'_>, + ) -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o644) + .open(&autostart_entry) + .map_ok(tokio::io::BufWriter::new) + .await?; + + file.write_all(desktop_entry.to_string().as_bytes()).await?; + /// Shouldn't be needed, but the file never seemed to flush to disk until I did it manually + file.flush().await + } } #[zbus::interface(name = "org.freedesktop.impl.portal.Background")] impl Background { /// Status on running apps (active, running, or background) - async fn get_app_state(&self) -> fdo::Result> { - let toplevels: Vec<_> = self + async fn get_app_state(&self) -> fdo::Result { + let apps: Vec<_> = self .wayland_helper .toplevels() .into_iter() @@ -97,11 +127,11 @@ impl Background { }) .collect(); - log::debug!("GetAppState returning {} toplevels", toplevels.len()); + log::debug!("GetAppState returning {} toplevels", apps.len()); #[cfg(debug_assertions)] - log::trace!("App status: {toplevels:#?}"); + log::trace!("App status: {apps:#?}"); - Ok(toplevels) + Ok(AppStates { apps }) } /// Notifies the user that an app is running in the background @@ -134,7 +164,7 @@ impl Background { } // Dialog PermissionDialog::Ask => { - log::debug!("Requesting user permission for {app_id} ({name})",); + log::debug!("Requesting background permission for running app {app_id} ({name})",); let handle = handle.to_owned(); let id = window::Id::unique(); @@ -160,16 +190,106 @@ impl Background { /// Enable or disable autostart for an application /// - /// Deprecated but seemingly still in use + /// Deprecated in terms of the portal but seemingly still in use + /// Spec: https://specifications.freedesktop.org/autostart-spec/latest/ pub async fn enable_autostart( &self, - app_id: String, + appid: String, enable: bool, - commandline: Vec, + exec: Vec, flags: u32, ) -> fdo::Result { - log::warn!("Autostart not implemented"); - Ok(enable) + log::info!( + "{} autostart for {appid}", + if enable { "Enabling" } else { "Disabling" } + ); + + let Some((autostart_dir, launch_entry)) = dirs::config_dir().map(|config| { + let autostart = config.join("autostart"); + ( + autostart.clone(), + autostart.join(format!("{appid}.desktop")), + ) + }) else { + return Err(fdo::Error::FileNotFound("XDG_CONFIG_HOME".into())); + }; + + if !enable { + log::debug!("Removing autostart entry {}", launch_entry.display()); + match fs::remove_file(&launch_entry).await { + Ok(()) => Ok(false), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + log::warn!("Service asked to disable autostart for {appid} but the entry doesn't exist"); + Ok(false) + } + Err(e) => { + log::error!( + "Error removing autostart entry for {appid}\n\tPath: {}\n\tError: {e}", + launch_entry.display() + ); + Err(fdo::Error::FileNotFound(format!( + "{e}: ({})", + launch_entry.display() + ))) + } + } + } else { + match fs::create_dir(&autostart_dir).await { + Ok(()) => log::debug!("Created autostart directory at {}", autostart_dir.display()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => (), + Err(e) => { + log::error!( + "Error creating autostart directory: {e} (app: {appid}) (dir: {})", + autostart_dir.display() + ); + return Err(fdo::Error::IOError(format!( + "{e}: ({})", + autostart_dir.display() + ))); + } + } + + let mut autostart_fde = freedesktop_desktop_entry::DesktopEntry { + appid: Cow::Borrowed(&appid), + path: Default::default(), + groups: Default::default(), + ubuntu_gettext_domain: None, + }; + autostart_fde.add_desktop_entry("Type", "Application"); + autostart_fde.add_desktop_entry("Name", &appid); + + log::debug!("{appid} autostart command line: {exec:?}"); + let exec = match shlex::try_join(exec.iter().map(|term| term.as_str())) { + Ok(exec) => exec, + Err(e) => { + log::error!("Failed to sanitize command line for {appid}\n\tCommand: {exec:?}\n\tError: {e}"); + return Err(fdo::Error::InvalidArgs(format!("{e}: {exec:?}"))); + } + }; + log::debug!("{appid} sanitized autostart command line: {exec}"); + autostart_fde.add_desktop_entry("Exec", &exec); + + /// xxx Replace with enumflags later when it's added as a dependency instead of adding + /// it now for one bit (literally) + let dbus_activation = flags & 0x1 == 1; + if dbus_activation { + autostart_fde.add_desktop_entry("DBusActivatable", "true"); + } + + // GNOME and KDE both set this key + autostart_fde.add_desktop_entry("X-Flatpak", &appid); + + Self::write_autostart(&launch_entry, &autostart_fde) + .inspect_err(|e| { + log::error!( + "Failed to write autostart entry for {appid} to `{}`: {e}", + launch_entry.display() + ); + }) + .map_err(|e| fdo::Error::IOError(format!("{e}: {}", launch_entry.display()))) + .map_ok(|()| true) + .await + } } /// Emitted when running applications change their state @@ -188,7 +308,13 @@ pub enum AppStatus { Active, } -#[derive(Clone, Debug, serde::Serialize, zvariant::Type)] +#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] +#[zvariant(signature = "a{sv}")] +struct AppStates { + apps: Vec, +} + +#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] #[zvariant(signature = "{sv}")] struct AppState { app_id: String, From 967b091b5e8999929e5ab74bd194d94147cead8d Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Thu, 3 Oct 2024 23:23:43 -0400 Subject: [PATCH 3/6] Evaluate background apps using systemd Squashed: * Rebase on the latest zbus * Fix types for get_app_state to not return an error nor a nested dict * Fix get_app_state so that apps default to running in the background unless they have open toplevels --- examples/background.rs | 2 +- src/app.rs | 2 +- src/background.rs | 161 +++++++++++++++++++---------------- src/main.rs | 1 + src/subscription.rs | 2 +- src/systemd.rs | 188 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 75 deletions(-) create mode 100644 src/systemd.rs diff --git a/examples/background.rs b/examples/background.rs index 683100c..68b9d62 100644 --- a/examples/background.rs +++ b/examples/background.rs @@ -70,7 +70,7 @@ impl cosmic::Application for App { }) .width(Length::Fill) .into(), - widget::button("Run in background") + widget::button::standard("Run in background") .on_press(Message::RequestBackground) .padding(8.0) .into(), diff --git a/src/app.rs b/src/app.rs index 62e9fce..9cf92df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -155,7 +155,7 @@ impl cosmic::Application for CosmicPortal { screencast_dialog::cancel(self, handle).map(cosmic::Action::App) } subscription::Event::Background(args) => { - background::update_args(self, args).map(cosmic::app::Message::App) + background::update_args(self, args).map(cosmic::Action::App) } subscription::Event::Config(config) => self.update(Msg::ConfigSubUpdate(config)), subscription::Event::Accent(_) diff --git a/src/background.rs b/src/background.rs index 3c89a9d..69ae474 100644 --- a/src/background.rs +++ b/src/background.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-only use std::{ - borrow::Cow, + collections::HashMap, + hash::Hash, io, path::Path, sync::{Arc, Condvar, Mutex}, @@ -16,12 +17,12 @@ use tokio::{ io::AsyncWriteExt, sync::{mpsc, watch}, }; -use zbus::{fdo, object_server::SignalContext, zvariant}; +use zbus::{fdo, object_server::SignalEmitter, zvariant}; use crate::{ app::CosmicPortal, config::{self, background::PermissionDialog}, - fl, subscription, + fl, subscription, systemd, wayland::WaylandHelper, PortalResponse, }; @@ -80,7 +81,7 @@ impl Background { /// The primary purpose of this function is to ease error handling. async fn write_autostart( autostart_entry: &Path, - desktop_entry: &freedesktop_desktop_entry::DesktopEntry<'_>, + desktop_entry: &freedesktop_desktop_entry::DesktopEntry, ) -> io::Result<()> { let mut file = fs::OpenOptions::new() .create(true) @@ -92,7 +93,7 @@ impl Background { .await?; file.write_all(desktop_entry.to_string().as_bytes()).await?; - /// Shouldn't be needed, but the file never seemed to flush to disk until I did it manually + // Shouldn't be needed, but the file never seemed to flush to disk until I did it manually file.flush().await } } @@ -100,38 +101,14 @@ impl Background { #[zbus::interface(name = "org.freedesktop.impl.portal.Background")] impl Background { /// Status on running apps (active, running, or background) - async fn get_app_state(&self) -> fdo::Result { - let apps: Vec<_> = self - .wayland_helper - .toplevels() - .into_iter() - .map(|(_, info)| { - let status = if info - .state - .contains(&zcosmic_toplevel_handle_v1::State::Activated) - { - AppStatus::Active - } else if !info.state.is_empty() { - AppStatus::Running - } else { - // xxx Is this the correct way to determine if a program is running in the - // background? If a toplevel exists but isn't running, activated, et cetera, - // then it logically must be in the background (?) - AppStatus::Background - }; - - AppState { - app_id: info.app_id, - status, - } - }) - .collect(); - - log::debug!("GetAppState returning {} toplevels", apps.len()); - #[cfg(debug_assertions)] - log::trace!("App status: {apps:#?}"); - - Ok(AppStates { apps }) + async fn get_app_state( + &self, + #[zbus(connection)] connection: &zbus::Connection, + ) -> HashMap { + get_app_state_impl(connection, self.wayland_helper.clone()) + .await + .inspect_err(|_| log::error!("Failed to enumerate running apps")) + .unwrap_or_default() } /// Notifies the user that an app is running in the background @@ -145,7 +122,10 @@ impl Background { // Request a copy of the config from the main app instance // This is also cleaner than storing the config because it's difficult to keep it - // updated without synch primitives and we also avoid &mut self + // updated without synch primitives and we also avoid &mut self. + // + // &mut self with Zbus can lead to deadlocks. + // See: https://dbus2.github.io/zbus/faq.html#1-a-interface-method-that-takes-a-mut-self-argument-is-taking-too-long let config = self.rx_conf.borrow().background; match config.default_perm { @@ -192,7 +172,7 @@ impl Background { /// /// Deprecated in terms of the portal but seemingly still in use /// Spec: https://specifications.freedesktop.org/autostart-spec/latest/ - pub async fn enable_autostart( + async fn enable_autostart( &self, appid: String, enable: bool, @@ -250,13 +230,13 @@ impl Background { } let mut autostart_fde = freedesktop_desktop_entry::DesktopEntry { - appid: Cow::Borrowed(&appid), + appid: appid.clone(), path: Default::default(), groups: Default::default(), ubuntu_gettext_domain: None, }; - autostart_fde.add_desktop_entry("Type", "Application"); - autostart_fde.add_desktop_entry("Name", &appid); + autostart_fde.add_desktop_entry("Type".into(), "Application".into()); + autostart_fde.add_desktop_entry("Name".into(), appid.clone()); log::debug!("{appid} autostart command line: {exec:?}"); let exec = match shlex::try_join(exec.iter().map(|term| term.as_str())) { @@ -267,17 +247,17 @@ impl Background { } }; log::debug!("{appid} sanitized autostart command line: {exec}"); - autostart_fde.add_desktop_entry("Exec", &exec); + autostart_fde.add_desktop_entry("Exec".into(), exec); - /// xxx Replace with enumflags later when it's added as a dependency instead of adding - /// it now for one bit (literally) + // TODO: Replace with enumflags later when it's added as a dependency instead of adding + // it now for one bit (literally) let dbus_activation = flags & 0x1 == 1; if dbus_activation { - autostart_fde.add_desktop_entry("DBusActivatable", "true"); + autostart_fde.add_desktop_entry("DBusActivatable".into(), "true".into()); } // GNOME and KDE both set this key - autostart_fde.add_desktop_entry("X-Flatpak", &appid); + autostart_fde.add_desktop_entry("X-Flatpak".into(), appid.clone()); Self::write_autostart(&launch_entry, &autostart_fde) .inspect_err(|e| { @@ -294,12 +274,61 @@ impl Background { /// Emitted when running applications change their state #[zbus(signal)] - pub async fn running_applications_changed(context: &SignalContext<'_>) -> zbus::Result<()>; + pub async fn running_applications_changed(context: &SignalEmitter<'_>) -> zbus::Result<()>; } -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, zvariant::Type)] +/// Internal implementation of [`Background::get_app_state`]. +async fn get_app_state_impl( + connection: &zbus::Connection, + wayland_helper: WaylandHelper, +) -> fdo::Result> { + let apps: HashMap<_, _> = systemd::Systemd1Proxy::new(connection) + .await + .inspect_err(|e| log::error!("Error connecting to systemd proxy: {e}"))? + .list_units() + .await + .inspect_err(|e| log::error!("Error fetching units from systemd: {e}"))? + .into_iter() + // Apps launched by COSMIC/Flatpak are considered to be running in the + // background by default as they don't have open top levels. + .filter_map(|unit| { + unit.cosmic_flatpak_name() + .map(|app_id| (app_id.to_owned(), AppStatus::Background)) + }) + .chain( + wayland_helper + .toplevels() + .into_iter() + // Evaluate apps with open top levels next; overwrite any background app + // statuses if an app has open top levels. + .map(|info| { + let status = if info + .state + .contains(&zcosmic_toplevel_handle_v1::State::Activated) + { + // Focused top levels + AppStatus::Active + } else { + // Unfocused top levels + AppStatus::Running + }; + + (info.app_id, status) + }), + ) + .collect(); + + log::debug!("GetAppState is returning {} open apps", apps.len()); + #[cfg(debug_assertions)] + log::trace!("App statuses: {apps:#?}"); + + Ok(apps) +} + +/// Status of running apps for [`Background::get_app_state`] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, zvariant::Type)] #[zvariant(signature = "u")] -pub enum AppStatus { +enum AppStatus { /// No open windows Background = 0, /// At least one opened window @@ -308,23 +337,10 @@ pub enum AppStatus { Active, } -#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] -#[zvariant(signature = "a{sv}")] -struct AppStates { - apps: Vec, -} - -#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] -#[zvariant(signature = "{sv}")] -struct AppState { - app_id: String, - status: AppStatus, -} - /// Result vardict for [`Background::notify_background`] -#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] +#[derive(Clone, Copy, Debug, zvariant::SerializeDict, zvariant::Type)] #[zvariant(signature = "a{sv}")] -pub struct NotifyBackgroundResult { +struct NotifyBackgroundResult { result: PermissionResponse, } @@ -376,7 +392,8 @@ pub(crate) fn view(portal: &CosmicPortal, id: window::Id) -> cosmic::Element cosmic::Element cosmic::Command { +pub fn update_args(portal: &mut CosmicPortal, args: Args) -> cosmic::Task { if let Some(old) = portal.background_prompts.insert(args.id, args) { // xxx Can this even happen? log::trace!( @@ -412,10 +429,10 @@ pub fn update_args(portal: &mut CosmicPortal, args: Args) -> cosmic::Command cosmic::Command { +pub fn update_msg(portal: &mut CosmicPortal, msg: Msg) -> cosmic::Task { match msg { Msg::Response { id, choice } => { let Some(Args { @@ -426,7 +443,7 @@ pub fn update_msg(portal: &mut CosmicPortal, msg: Msg) -> cosmic::Command cosmic::Command cosmic::Command(DBUS_PATH) .await?; - Background::running_applications_changed(background.signal_context()) + Background::running_applications_changed(background.signal_emitter()) .await?; } Event::Accent(a) => { diff --git a/src/systemd.rs b/src/systemd.rs new file mode 100644 index 0000000..f933cae --- /dev/null +++ b/src/systemd.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use serde::Deserialize; +use zbus::{zvariant, Result}; + +static COSMIC_SCOPE: &str = "app-cosmic-"; +static FLATPAK_SCOPE: &str = "app-flatpak-"; + +/// Proxy for the `org.freedesktop.systemd1.Manager` interface +#[zbus::proxy( + default_service = "org.freedesktop.systemd1", + default_path = "/org/freedesktop/systemd1", + interface = "org.freedesktop.systemd1.Manager" +)] +pub trait Systemd1 { + fn list_units(&self) -> Result>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, zvariant::Type)] +#[cfg_attr(test, derive(Default))] +#[zvariant(signature = "(ssssssouso)")] +pub struct Unit { + pub name: String, + pub description: String, + pub load_state: LoadState, + pub active_state: ActiveState, + pub sub_state: SubState, + pub following: String, + pub unit_object: zvariant::OwnedObjectPath, + pub job_id: u32, + pub job_type: String, + pub job_object: zvariant::OwnedObjectPath, +} + +impl Unit { + /// Returns appid if COSMIC or Flatpak launched this unit + pub fn cosmic_flatpak_name(&self) -> Option<&str> { + self.name + .strip_prefix(COSMIC_SCOPE) + .or_else(|| self.name.strip_prefix(FLATPAK_SCOPE))? + .rsplit_once('-') + .and_then(|(appid, pid_scope)| { + // Check if unit name ends in `-{PID}.scope` + _ = pid_scope.strip_suffix(".scope")?.parse::().ok()?; + Some(appid) + }) + } +} + +/// Load state for systemd units +/// +/// Source: https://github.com/systemd/systemd/blob/main/man/systemctl.xml +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, zvariant::Type)] +#[cfg_attr(test, derive(Default))] +#[zvariant(signature = "s")] +#[serde(rename_all = "kebab-case")] +pub enum LoadState { + #[cfg_attr(test, default)] + Stub, + Loaded, + NotFound, + BadSetting, + Error, + Merged, + Masked, +} + +/// Sub-state for systemd units +/// +/// Source: https://github.com/systemd/systemd/blob/main/man/systemctl.xml +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, zvariant::Type)] +#[cfg_attr(test, derive(Default))] +#[zvariant(signature = "s")] +#[serde(rename_all = "kebab-case")] +pub enum SubState { + #[cfg_attr(test, default)] + Dead, + Active, + Waiting, + Running, + Failed, + Cleaning, + Tentative, + Plugged, + Mounting, + MountingDone, + Mounted, + Remounting, + Unmounting, + RemountingSigterm, + RemountingSigkill, + UnmountingSigterm, + UnmountingSigkill, + Stop, + StopWatchdog, + StopSigterm, + StopSigkill, + StartChown, + Abandoned, + Condition, + Start, + StartPre, + StartPost, + StopPre, + StopPreSigterm, + StopPreSigkill, + StopPost, + Exited, + Reload, + ReloadSignal, + ReloadNotify, + FinalWatchdog, + FinalSigterm, + FinalSigkill, + DeadBeforeAutoRestart, + FailedBeforeAutoRestart, + DeadResourcesPinned, + AutoRestart, + AutoRestartQueued, + Listening, + Activating, + ActivatingDone, + Deactivating, + DeactivatingSigterm, + DeactivatingSigkill, + Elapsed, +} + +/// Activated state for systemd units +/// +/// Source: https://github.com/systemd/systemd/blob/main/man/systemctl.xml +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, zvariant::Type)] +#[cfg_attr(test, derive(Default))] +#[zvariant(signature = "s")] +#[serde(rename_all = "kebab-case")] +pub enum ActiveState { + Active, + Reloading, + #[cfg_attr(test, default)] + Inactive, + Failed, + Activating, + Deactivating, + Maintenance, +} + +#[cfg(test)] +mod tests { + use super::Unit; + + const APPID: &str = "com.system76.CosmicFiles"; + + fn unit_with_name(name: &str) -> Unit { + Unit { + name: name.to_owned(), + ..Default::default() + } + } + + #[test] + fn parse_appid_without_scope_fails() { + let unit = unit_with_name(APPID); + let name = unit.cosmic_flatpak_name(); + assert!( + name.is_none(), + "Only apps launched by COSMIC or Flatpak should be parsed; got: {name:?}" + ); + } + + #[test] + fn parse_appid_with_scope_pid() { + let unit = unit_with_name(&format!("app-cosmic-{APPID}-1234.scope")); + let name = unit + .cosmic_flatpak_name() + .expect("Should parse app launched by COSMIC"); + assert_eq!(APPID, name); + } + + #[test] + fn parse_appid_with_scope_no_pid_fails() { + let unit = unit_with_name(&format!("app-cosmic-{APPID}.scope")); + let name = unit.cosmic_flatpak_name(); + assert!( + name.is_none(), + "Apps launched by COSMIC/Flatpak should have a PID in its scope name" + ); + } +} From cf06965f906f84b6bb7397bc8fa780bab8f20b9f Mon Sep 17 00:00:00 2001 From: Ian Douglas Scott Date: Tue, 10 Jun 2025 19:02:44 -0700 Subject: [PATCH 4/6] fix(examples/background): Update for current libcosmic/iced --- examples/background.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/background.rs b/examples/background.rs index 68b9d62..724efbd 100644 --- a/examples/background.rs +++ b/examples/background.rs @@ -2,10 +2,10 @@ use ashpd::desktop::background::Background; use cosmic::{ - app::{self, message, Core}, + app::{self, Core}, executor, iced::{Length, Size}, - widget, Command, + widget, Task, }; #[derive(Clone, Debug)] @@ -50,14 +50,14 @@ impl cosmic::Application for App { &mut self.core } - fn init(core: Core, _: Self::Flags) -> (Self, app::Command) { + fn init(core: Core, _: Self::Flags) -> (Self, app::Task) { ( Self { core, executable: std::env::args().next().unwrap(), background_allowed: false, }, - Command::none(), + Task::none(), ) } @@ -81,16 +81,16 @@ impl cosmic::Application for App { .into() } - fn update(&mut self, message: Self::Message) -> app::Command { + fn update(&mut self, message: Self::Message) -> app::Task { match message { Message::BackgroundResponse(background_allowed) => { log::info!("Permission to run in the background: {background_allowed}"); self.background_allowed = background_allowed; - Command::none() + Task::none() } Message::RequestBackground => { let executable = self.executable.clone(); - Command::perform(Self::request_background(executable), |result| { + Task::perform(Self::request_background(executable), |result| { let background_allowed = match result { Ok(response) => { assert!( @@ -105,7 +105,7 @@ impl cosmic::Application for App { } }; - message::app(Message::BackgroundResponse(background_allowed)) + cosmic::Action::App(Message::BackgroundResponse(background_allowed)) }) } } From ff964dce1f99a360c8887d94920afdd3d5dd4f6b Mon Sep 17 00:00:00 2001 From: Ian Douglas Scott Date: Tue, 10 Jun 2025 19:48:35 -0700 Subject: [PATCH 5/6] fix(background): Serialize `AppStatus` as type `v` `xdg-desktop-portal` expects this. It's easy enough to serialize this way. --- src/background.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/background.rs b/src/background.rs index 69ae474..14c9cce 100644 --- a/src/background.rs +++ b/src/background.rs @@ -326,8 +326,9 @@ async fn get_app_state_impl( } /// Status of running apps for [`Background::get_app_state`] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, zvariant::Type)] -#[zvariant(signature = "u")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, zvariant::Type)] +#[zvariant(signature = "v")] +#[repr(u32)] enum AppStatus { /// No open windows Background = 0, @@ -337,6 +338,15 @@ enum AppStatus { Active, } +impl serde::Serialize for AppStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + zvariant::Value::U32(*self as u32).serialize(serializer) + } +} + /// Result vardict for [`Background::notify_background`] #[derive(Clone, Copy, Debug, zvariant::SerializeDict, zvariant::Type)] #[zvariant(signature = "a{sv}")] From 6e844217e9cf085c29f2d9c13ba84baef280f733 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Fri, 13 Jun 2025 02:26:52 -0400 Subject: [PATCH 6/6] background: Replace condvar with an iced sub --- src/background.rs | 29 +-------------------------- src/subscription.rs | 24 +++++++++++++++++++--- src/wayland/mod.rs | 49 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/src/background.rs b/src/background.rs index 14c9cce..896e53f 100644 --- a/src/background.rs +++ b/src/background.rs @@ -37,18 +37,11 @@ pub struct Background { } impl Background { - pub fn new( + pub const fn new( wayland_helper: WaylandHelper, tx: mpsc::Sender, rx_conf: watch::Receiver, ) -> Self { - let toplevel_signal = wayland_helper.toplevel_signal(); - let toplevel_tx = tx.clone(); - std::thread::Builder::new() - .name("background-toplevel-updates".into()) - .spawn(move || Background::toplevel_signal(toplevel_signal, toplevel_tx)) - .expect("Spawning toplevels update thread should succeed"); - Self { wayland_helper, tx, @@ -56,26 +49,6 @@ impl Background { } } - /// Trigger [`Background::running_applications_changed`] on toplevel updates - fn toplevel_signal(signal: Arc<(Mutex, Condvar)>, tx: mpsc::Sender) { - loop { - let (lock, cvar) = &*signal; - let mut updated = lock.lock().unwrap(); - - log::debug!("Waiting for toplevel updates"); - while !*updated { - updated = cvar.wait(updated).unwrap(); - } - - log::debug!("Emitting RunningApplicationsChanged in response to toplevel updates"); - debug_assert!(*updated); - *updated = false; - if let Err(e) = tx.blocking_send(subscription::Event::BackgroundToplevels) { - log::warn!("Failed sending event to trigger RunningApplicationsChanged: {e:?}"); - } - } - } - /// Write `desktop_entry` to path `launch_entry`. /// /// The primary purpose of this function is to ease error handling. diff --git a/src/subscription.rs b/src/subscription.rs index addfdb0..04076f3 100644 --- a/src/subscription.rs +++ b/src/subscription.rs @@ -2,6 +2,7 @@ use std::any::TypeId; +use anyhow::Context; use cosmic::{cosmic_theme::palette::Srgba, iced::Subscription}; use futures::{future, SinkExt}; use tokio::sync::mpsc::Receiver; @@ -43,19 +44,29 @@ pub(crate) fn portal_subscription( ) -> cosmic::iced::Subscription { struct PortalSubscription; struct ConfigSubscription; + struct WaylandHelperSubscription; + let helper_portal = helper.clone(); Subscription::batch([ Subscription::run_with_id( TypeId::of::(), cosmic::iced_futures::stream::channel(10, |mut output| async move { let mut state = State::Init; loop { - if let Err(err) = process_changes(&mut state, &mut output, &helper).await { + if let Err(err) = process_changes(&mut state, &mut output, &helper_portal).await + { log::debug!("Portal Subscription Error: {:?}", err); future::pending::<()>().await; } } }), ), + Subscription::run_with_id( + TypeId::of::(), + helper.subscription(), + ) + .map(|wl_event| match wl_event { + wayland::Event::ToplevelsUpdated => Event::BackgroundToplevels, + }), cosmic_config::config_subscription( TypeId::of::(), config::APP_ID.into(), @@ -144,12 +155,19 @@ pub(crate) async fn process_changes( } } Event::BackgroundToplevels => { + log::debug!( + "Emitting RunningApplicationsChanged in response to toplevel updates" + ); let background = conn .object_server() .interface::<_, Background>(DBUS_PATH) - .await?; + .await + .context("Connecting to Background portal D-Bus interface")?; Background::running_applications_changed(background.signal_emitter()) - .await?; + .await + .context( + "Emitting RunningApplicationsChanged for the Background portal", + )?; } Event::Accent(a) => { let object_server = conn.object_server(); diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index c5d8fbe..4bcd5c1 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -30,6 +30,7 @@ use std::{ sync::{Arc, Condvar, Mutex, Weak}, thread, }; +use tokio::sync::broadcast; use wayland_client::{ globals::registry_queue_init, protocol::{wl_buffer, wl_output, wl_shm, wl_shm_pool}, @@ -55,6 +56,8 @@ mod gbm_devices; mod toplevel; mod workspaces; +const SUB_BACKLOG: usize = 10; + #[derive(Clone)] pub struct DmabufHelper { feedback: Arc, @@ -93,7 +96,7 @@ struct WaylandHelperInner { output_infos: Mutex>, output_toplevels: Mutex>>, toplevels: Mutex>, - toplevel_update: Arc<(Mutex, Condvar)>, + tx: broadcast::Sender, qh: QueueHandle, capturer: Capturer, wl_shm: wl_shm::WlShm, @@ -163,10 +166,9 @@ impl AppData { self.toplevel_info_state.toplevels().cloned().collect(); // Signal that toplevels were updated; the actual updates are unimportant here - let (lock, cvar) = &*self.wayland_helper.inner.toplevel_update; - let mut updated = lock.lock().unwrap(); - *updated = true; - cvar.notify_all(); + if let Err(e) = self.wayland_helper.inner.tx.send(Event::ToplevelsUpdated) { + log::warn!("Failed sending toplevels update message: {e}"); + } } } @@ -253,7 +255,7 @@ impl WaylandHelper { let screencopy_state = ScreencopyState::new(&globals, &qh); let shm_state = Shm::bind(&globals, &qh).unwrap(); let zwp_dmabuf = globals.bind(&qh, 4..=4, sctk::globals::GlobalData).unwrap(); - let toplevel_update = Arc::new((Mutex::new(false), Condvar::new())); + let (tx, _) = broadcast::channel(SUB_BACKLOG); let wayland_helper = WaylandHelper { inner: Arc::new(WaylandHelperInner { conn, @@ -261,7 +263,7 @@ impl WaylandHelper { output_infos: Mutex::new(HashMap::new()), output_toplevels: Mutex::new(HashMap::new()), toplevels: Mutex::new(Vec::new()), - toplevel_update, + tx, qh: qh.clone(), capturer: screencopy_state.capturer().clone(), wl_shm: shm_state.wl_shm().clone(), @@ -307,10 +309,6 @@ impl WaylandHelper { self.inner.toplevels.lock().unwrap().clone() } - pub fn toplevel_signal(&self) -> Arc<(Mutex, Condvar)> { - Arc::clone(&self.inner.toplevel_update) - } - pub fn output_info(&self, output: &wl_output::WlOutput) -> Option { self.inner.output_infos.lock().unwrap().get(output).cloned() } @@ -504,6 +502,35 @@ impl WaylandHelper { (), ) } + + /// Subscribe to events from the compositor. + pub fn subscription(&self) -> impl Stream { + let mut rx_helper = self.inner.tx.subscribe(); + + cosmic::iced::stream::channel(SUB_BACKLOG, |mut output| async move { + // Tokio's types don't implement std's Stream yet + loop { + match rx_helper.recv().await { + Ok(message) => { + let _ = output.try_send(message).inspect_err(|e| { + log::warn!( + "Failed sending message from Wayland helper subscription: {e}" + ) + }); + } + Err(e) if matches!(e, broadcast::error::RecvError::Lagged(_)) => (), + _ => break, + } + } + }) + } +} + +/// Events from the compositor, such as new toplevels. +#[derive(Clone, Copy)] +pub enum Event { + /// Toplevels updated in some way (created, destroyed, focus changed) + ToplevelsUpdated, } pub struct ShmImage {