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/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..724efbd --- /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, Core}, + executor, + iced::{Length, Size}, + widget, Task, +}; + +#[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::Task) { + ( + Self { + core, + executable: std::env::args().next().unwrap(), + background_allowed: false, + }, + Task::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::standard("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::Task { + match message { + Message::BackgroundResponse(background_allowed) => { + log::info!("Permission to run in the background: {background_allowed}"); + self.background_allowed = background_allowed; + Task::none() + } + Message::RequestBackground => { + let executable = self.executable.clone(); + Task::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 + } + }; + + cosmic::Action::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..9cf92df 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::Action::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..896e53f --- /dev/null +++ b/src/background.rs @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use std::{ + collections::HashMap, + hash::Hash, + 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::{ + fs, + io::AsyncWriteExt, + sync::{mpsc, watch}, +}; +use zbus::{fdo, object_server::SignalEmitter, zvariant}; + +use crate::{ + app::CosmicPortal, + config::{self, background::PermissionDialog}, + fl, subscription, systemd, + 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 const fn new( + wayland_helper: WaylandHelper, + tx: mpsc::Sender, + rx_conf: watch::Receiver, + ) -> Self { + Self { + wayland_helper, + tx, + rx_conf, + } + } + + /// 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, + #[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 + 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. + // + // &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 { + // 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 background permission for running app {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 in terms of the portal but seemingly still in use + /// Spec: https://specifications.freedesktop.org/autostart-spec/latest/ + async fn enable_autostart( + &self, + appid: String, + enable: bool, + exec: Vec, + flags: u32, + ) -> fdo::Result { + 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: appid.clone(), + path: Default::default(), + groups: Default::default(), + ubuntu_gettext_domain: None, + }; + 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())) { + 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".into(), exec); + + // 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".into(), "true".into()); + } + + // GNOME and KDE both set this key + autostart_fde.add_desktop_entry("X-Flatpak".into(), appid.clone()); + + 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 + #[zbus(signal)] + pub async fn running_applications_changed(context: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +/// 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, zvariant::Type)] +#[zvariant(signature = "v")] +#[repr(u32)] +enum AppStatus { + /// No open windows + Background = 0, + /// At least one opened window + Running, + /// In the foreground + 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}")] +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() + .title(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::Task { + 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::Task::none() +} + +pub fn update_msg(portal: &mut CosmicPortal, msg: Msg) -> cosmic::Task { + 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::Task::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::Task::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::Task::none() +} diff --git a/src/main.rs b/src/main.rs index 6a00461..48b9b4f 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; @@ -19,6 +20,7 @@ mod screencast_dialog; mod screencast_thread; mod screenshot; mod subscription; +mod systemd; mod wayland; mod widget; 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 { @@ -37,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(), @@ -73,14 +90,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 +112,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 +149,26 @@ 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 => { + log::debug!( + "Emitting RunningApplicationsChanged in response to toplevel updates" + ); + let background = conn + .object_server() + .interface::<_, Background>(DBUS_PATH) + .await + .context("Connecting to Background portal D-Bus interface")?; + Background::running_applications_changed(background.signal_emitter()) + .await + .context( + "Emitting RunningApplicationsChanged for the Background portal", + )?; + } Event::Accent(a) => { let object_server = conn.object_server(); let iface_ref = object_server.interface::<_, Settings>(DBUS_PATH).await?; @@ -180,7 +229,7 @@ pub(crate) async fn process_changes( log::error!("Error sending config update: {:?}", err) } } - Event::Init(_) => {} + Event::Init { .. } => {} } } } 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" + ); + } +} diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index bf6a9c5..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,6 +96,7 @@ struct WaylandHelperInner { output_infos: Mutex>, output_toplevels: Mutex>>, toplevels: Mutex>, + tx: broadcast::Sender, qh: QueueHandle, capturer: Capturer, wl_shm: wl_shm::WlShm, @@ -160,6 +164,11 @@ 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 + if let Err(e) = self.wayland_helper.inner.tx.send(Event::ToplevelsUpdated) { + log::warn!("Failed sending toplevels update message: {e}"); + } } } @@ -246,6 +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 (tx, _) = broadcast::channel(SUB_BACKLOG); let wayland_helper = WaylandHelper { inner: Arc::new(WaylandHelperInner { conn, @@ -253,6 +263,7 @@ impl WaylandHelper { output_infos: Mutex::new(HashMap::new()), output_toplevels: Mutex::new(HashMap::new()), toplevels: Mutex::new(Vec::new()), + tx, qh: qh.clone(), capturer: screencopy_state.capturer().clone(), wl_shm: shm_state.wl_shm().clone(), @@ -491,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 {