diff --git a/Cargo.lock b/Cargo.lock index 1eacea1..a59d913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7674,6 +7674,7 @@ dependencies = [ "rust-embed", "rustix 0.38.40", "serde", + "shlex", "tempfile", "time", "tokio", diff --git a/Cargo.toml b/Cargo.toml index eb87e58..58ea9c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ time = { version = "0.3.31", features = [ "macros", ] } url = "2.5" +shlex = "1" # i18n i18n-embed = { version = "0.14.1", 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..68b9d62 --- /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::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::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 d31d810..c0526e5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,6 @@ -use crate::{access, config, file_chooser, fl, 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::{self, dropdown}; 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,10 +89,7 @@ impl cosmic::Application for CosmicPortal { fn init( core: app::Core, - Flags { - config_handler, - config, - }: Self::Flags, + _: Self::Flags, ) -> (Self, cosmic::iced::Task>) { let mut model = cosmic::widget::dropdown::multi::model(); model.insert(dropdown::multi::list( @@ -118,14 +109,14 @@ impl cosmic::Application for CosmicPortal { ), ], )); - model.selected = Some(config.screenshot.save_location); + model.selected = None; 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(), @@ -133,6 +124,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, @@ -153,6 +145,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) } @@ -179,19 +173,31 @@ impl cosmic::Application for CosmicPortal { subscription::Event::CancelScreencast(handle) => { screencast_dialog::cancel(self, handle).map(cosmic::app::Message::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::app::Message::App), Msg::Screencast(m) => { screencast_dialog::update_msg(self, m).map(cosmic::app::Message::App) } + Msg::Background(m) => background::update_msg(self, m).map(cosmic::app::Message::App), Msg::Output(o_event, wl_output) => { match o_event { OutputEvent::Created(Some(info)) @@ -265,19 +271,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..9566a83 --- /dev/null +++ b/src/background.rs @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use std::{ + borrow::Cow, + collections::HashSet, + hash::{Hash, Hasher}, + 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::SignalContext, 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 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:?}"); + } + } + } + + /// 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, + ) -> fdo::Result { + let apps: HashSet<_> = self + .wayland_helper + .toplevels() + .into_iter() + // Evaluate apps with top levels first as our initial state + .map(|(_, info)| { + let status = if info + .state + .contains(&zcosmic_toplevel_handle_v1::State::Activated) + { + AppStatus::Active + } else { + AppStatus::Running + }; + + AppState { + app_id: info.app_id, + status, + } + }) + .chain( + systemd::Systemd1Proxy::new(connection) + .await? + .list_units() + .await? + .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| AppState { + app_id: app_id.to_owned(), + status: AppStatus::Background, + }) + }), + ) + .collect(); + + log::debug!("GetAppState is returning {} open apps", apps.len()); + #[cfg(debug_assertions)] + log::trace!("App statuses: {apps:#?}"); + + Ok(AppStates { + apps: apps.into_iter().collect(), + }) + } + + /// 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 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: 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 + #[zbus(signal)] + pub async fn running_applications_changed(context: &SignalContext<'_>) -> zbus::Result<()>; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, zvariant::Type)] +#[zvariant(signature = "u")] +enum AppStatus { + /// No open windows + Background = 0, + /// At least one opened window + Running, + /// In the foreground + Active, +} + +#[derive(Clone, Debug, zvariant::SerializeDict, zvariant::Type)] +#[zvariant(signature = "a{sv}")] +struct AppStates { + apps: Vec, +} + +#[derive(Clone, Debug, Eq, zvariant::SerializeDict, zvariant::Type)] +#[zvariant(signature = "{sv}")] +struct AppState { + app_id: String, + status: AppStatus, +} + +impl Hash for AppState { + fn hash(&self, state: &mut H) { + state.write(self.app_id.as_bytes()); + } +} + +impl PartialEq for AppState { + fn eq(&self, other: &Self) -> bool { + self.app_id == other.app_id + } +} + +/// Result vardict for [`Background::notify_background`] +#[derive(Clone, 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 167375f..06b5c49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ pub use cosmic_portal_config as config; mod access; mod app; +mod background; mod buffer; mod documents; mod file_chooser; @@ -16,6 +17,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 206da42..d842ac1 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, path::PathBuf}; use tokio::sync::mpsc::Sender; +use tokio::sync::watch; use wayland_client::protocol::wl_output::WlOutput; use zbus::zvariant; @@ -126,11 +127,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( @@ -418,12 +428,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(); @@ -815,7 +822,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::ConnectionBuilder::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/systemd.rs b/src/systemd.rs new file mode 100644 index 0000000..6f2bfdc --- /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" +)] +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 3661975..b081dff 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -87,6 +87,7 @@ struct WaylandHelperInner { output_infos: Mutex>, output_toplevels: Mutex>>, toplevels: Mutex>, + toplevel_update: Arc<(Mutex, Condvar)>, qh: QueueHandle, screencopy_manager: zcosmic_screencopy_manager_v2::ZcosmicScreencopyManagerV2, output_source_manager: ZcosmicOutputImageSourceManagerV1, @@ -167,6 +168,12 @@ impl AppData { .toplevels() .filter_map(|(handle, info)| Some((handle.clone(), info?.clone()))) .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(); } } @@ -250,6 +257,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, @@ -257,6 +265,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(), screencopy_manager: screencopy_state.screencopy_manager.clone(), output_source_manager: screencopy_state.output_source_manager.clone().unwrap(), @@ -304,6 +313,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() }