From dc62ac4db094d65528ef618a2997ad2e90723815 Mon Sep 17 00:00:00 2001 From: Jonatan Pettersson Date: Thu, 14 Nov 2024 21:09:42 +0100 Subject: [PATCH 1/3] Support launching app in terminal - Get terminal setting from desktop file - Launch app in terminal if set --- src/desktop.rs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/desktop.rs b/src/desktop.rs index 21b50aca340..3b45c7c7688 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -60,6 +60,7 @@ pub struct DesktopEntryData { pub desktop_actions: Vec, pub mime_types: Vec, pub prefers_dgpu: bool, + pub terminal: bool, } pub fn load_applications<'a>( @@ -225,12 +226,17 @@ impl DesktopEntryData { }) .unwrap_or_default(), prefers_dgpu: de.prefers_non_default_gpu(), + terminal: de.terminal(), } } } -pub async fn spawn_desktop_exec(exec: S, env_vars: I, app_id: Option<&str>) -where +pub async fn spawn_desktop_exec( + exec: S, + env_vars: I, + app_id: Option<&str>, + terminal: bool, +) where S: AsRef, I: IntoIterator, K: AsRef, @@ -243,14 +249,23 @@ where _ => return, }; - let mut cmd = std::process::Command::new(&executable); - - for arg in exec { - // TODO handle "%" args here if necessary? - if !arg.starts_with('%') { - cmd.arg(arg); + let mut cmd = match terminal { + true => { + let mut cmd = std::process::Command::new("cosmic-term"); + cmd.args(vec!["--", format!("{}", &executable).as_str()]); + cmd } - } + false => { + let mut cmd = std::process::Command::new(&executable); + for arg in exec { + // TODO handle "%" args here if necessary? + if !arg.starts_with('%') { + cmd.arg(arg); + } + } + cmd + } + }; cmd.envs(env_vars); From 1e588543a27cbf476899dbc2657f638d14018efa Mon Sep 17 00:00:00 2001 From: Jonatan Pettersson Date: Mon, 18 Nov 2024 20:08:01 +0100 Subject: [PATCH 2/3] Simplify code --- src/desktop.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/desktop.rs b/src/desktop.rs index 3b45c7c7688..bb8fd48e073 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -249,22 +249,19 @@ pub async fn spawn_desktop_exec( _ => return, }; - let mut cmd = match terminal { - true => { - let mut cmd = std::process::Command::new("cosmic-term"); - cmd.args(vec!["--", format!("{}", &executable).as_str()]); - cmd - } - false => { - let mut cmd = std::process::Command::new(&executable); - for arg in exec { - // TODO handle "%" args here if necessary? - if !arg.starts_with('%') { - cmd.arg(arg); - } + let mut cmd = if terminal { + let mut cmd = std::process::Command::new("cosmic-term"); + cmd.args(vec!["--", format!("{}", &executable).as_str()]); + cmd + } else { + let mut cmd = std::process::Command::new(&executable); + for arg in exec { + // TODO handle "%" args here if necessary? + if !arg.starts_with('%') { + cmd.arg(arg); } - cmd } + cmd }; cmd.envs(env_vars); From b450653f65da05c29fbd4fafada9690d5fc313b5 Mon Sep 17 00:00:00 2001 From: Jonatan Pettersson Date: Sun, 24 Nov 2024 21:15:22 +0100 Subject: [PATCH 3/3] Copy mime_app from cosmic-files - Add optional language sorter parameter to MimeAppCache::reload() - Use mime_app::exec_term_command in desktop::spawn_desktop_exec() - Add function mime_app::exec_term_to_command() to get exec command using terminal with fallback - Use this in MimeApp::command() - Use this in desktop::spawn_desktop_exec() --- Cargo.toml | 6 + iced | 2 +- src/desktop.rs | 19 +-- src/lib.rs | 3 + src/mime_app.rs | 347 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 364 insertions(+), 13 deletions(-) create mode 100644 src/mime_app.rs diff --git a/Cargo.toml b/Cargo.toml index bc2d4237e72..9c9e503306a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ desktop = [ "dep:freedesktop-desktop-entry", "dep:mime", "dep:shlex", + "dep:xdg", "tokio?/io-util", "tokio?/net", ] @@ -95,11 +96,14 @@ cosmic-config = { path = "cosmic-config" } cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } css-color = "0.2.5" derive_setters = "0.1.5" +icu_collator = "1.5" image = { version = "0.25.1", optional = true } lazy_static = "1.4.0" libc = { version = "0.2.155", optional = true } license = { version = "3.5.1", optional = true } mime = { version = "0.3.17", optional = true } +mime_guess = "2" +once_cell = "1.19" palette = "0.7.3" rfd = { version = "0.14.0", optional = true } rustix = { version = "0.38.34", features = [ @@ -115,11 +119,13 @@ tracing = "0.1" unicode-segmentation = "1.6" url = "2.4.0" ustr = { version = "1.0.0", features = ["serde"] } +xdg = { version = "2.5.2", optional = true } zbus = { version = "4.2.1", default-features = false, optional = true } [target.'cfg(unix)'.dependencies] freedesktop-icons = "0.2.5" freedesktop-desktop-entry = { version = "0.5.1", optional = true } +freedesktop_entry_parser = "1.3" shlex = { version = "1.3.0", optional = true } [dependencies.cosmic-theme] diff --git a/iced b/iced index 630612a7b1c..256863574ba 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 630612a7b1cb4fb236edabb2dee88b1754976549 +Subproject commit 256863574bacfb1d2797c2a48cba7a3388cbeb59 diff --git a/src/desktop.rs b/src/desktop.rs index bb8fd48e073..3ba54fc0bb6 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -6,6 +6,8 @@ use std::{ path::{Path, PathBuf}, }; +use crate::mime_app::{exec_term_to_command, exec_to_command}; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum IconSource { Name(String), @@ -249,21 +251,14 @@ pub async fn spawn_desktop_exec( _ => return, }; - let mut cmd = if terminal { - let mut cmd = std::process::Command::new("cosmic-term"); - cmd.args(vec!["--", format!("{}", &executable).as_str()]); - cmd + let cmd = if terminal { + exec_term_to_command(&executable, None) } else { - let mut cmd = std::process::Command::new(&executable); - for arg in exec { - // TODO handle "%" args here if necessary? - if !arg.starts_with('%') { - cmd.arg(arg); - } - } - cmd + exec_to_command(&executable, None) }; + let Some(mut cmd) = cmd else { return }; + cmd.envs(env_vars); // https://systemd.io/DESKTOP_ENVIRONMENTS diff --git a/src/lib.rs b/src/lib.rs index 886ae55939e..470b432b81d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,9 @@ pub mod keyboard_nav; #[cfg(feature = "desktop")] pub mod desktop; + +pub mod mime_app; + #[cfg(feature = "process")] pub mod process; diff --git a/src/mime_app.rs b/src/mime_app.rs new file mode 100644 index 00000000000..0f864b3e507 --- /dev/null +++ b/src/mime_app.rs @@ -0,0 +1,347 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +#[cfg(feature = "desktop")] +use crate::desktop; +use crate::widget; +use icu_collator::Collator; +pub use mime_guess::Mime; +use once_cell::sync::Lazy; +use palette::cast::ArraysInto; +use std::{ + cmp::Ordering, collections::HashMap, env, path::PathBuf, process, sync::Mutex, time::Instant, +}; + +pub fn exec_to_command(exec: &str, path_opt: Option) -> Option { + let args_vec: Vec = shlex::split(exec)?; + let mut args = args_vec.iter(); + let mut command = process::Command::new(args.next()?); + for arg in args { + if arg.starts_with('%') { + match arg.as_str() { + "%f" | "%F" | "%u" | "%U" => { + if let Some(path) = &path_opt { + command.arg(path); + } + } + _ => { + tracing::warn!("unsupported Exec code {:?} in {:?}", arg, exec); + return None; + } + } + } else { + command.arg(arg); + } + } + Some(command) +} + +pub fn exec_term_to_command(exec: &str, path_opt: Option) -> Option { + let Some(mut term_cmd) = terminal() + .and_then(|term| term.exec) + .and_then(|exec| exec_to_command(&exec, None)) + else { + tracing::warn!("No terminal was found to run {:?}", exec); + return None; + }; + + let exec_cmd = exec_to_command(exec, path_opt)?; + term_cmd.arg("-e"); + term_cmd.arg(exec_cmd.get_program()); + term_cmd.args(exec_cmd.get_args()); + Some(term_cmd) +} + +#[derive(Clone, Debug)] +pub struct MimeApp { + pub id: String, + pub path: Option, + pub name: String, + pub exec: Option, + pub icon: widget::icon::Handle, + pub is_default: bool, + pub terminal: bool, +} + +impl MimeApp { + //TODO: support multiple files + pub fn command(&self, path_opt: Option) -> Option { + if self.terminal { + exec_term_to_command(self.exec.as_deref()?, path_opt) + } else { + exec_to_command(self.exec.as_deref()?, path_opt) + } + } +} + +#[cfg(feature = "desktop")] +impl From<&desktop::DesktopEntryData> for MimeApp { + fn from(app: &desktop::DesktopEntryData) -> Self { + Self { + id: app.id.clone(), + path: app.path.clone(), + name: app.name.clone(), + exec: app.exec.clone(), + icon: match &app.icon { + desktop::IconSource::Name(name) => widget::icon::from_name(name.as_str()).handle(), + desktop::IconSource::Path(path) => widget::icon::from_path(path.clone()), + }, + is_default: false, + terminal: app.terminal, + } + } +} + +#[cfg(feature = "desktop")] +fn filename_eq(path_opt: &Option, filename: &str) -> bool { + path_opt + .as_ref() + .and_then(|path| path.file_name()) + .map(|x| x == filename) + .unwrap_or(false) +} + +pub trait LanguageSorter { + fn language_sorter(&self) -> Option> { + None + } +} + +pub struct MimeAppCache { + cache: HashMap>, + terminals: Vec, +} + +impl MimeAppCache { + pub fn new() -> Self { + let mut mime_app_cache = Self { + cache: HashMap::new(), + terminals: Vec::new(), + }; + mime_app_cache.reload(None); + mime_app_cache + } + + #[cfg(not(feature = "desktop"))] + pub fn reload(&mut self, language_sorter: Option<&Lazy>) {} + + // Only available when using desktop feature of libcosmic, which only works on Unix-likes + #[cfg(feature = "desktop")] + pub fn reload(&mut self, language_sorter: Option<&Lazy>) { + let start = Instant::now(); + + self.cache.clear(); + self.terminals.clear(); + + //TODO: get proper locale? + let locale = None; + + // Load desktop applications by supported mime types + //TODO: hashmap for all apps by id? + let all_apps = desktop::load_applications(locale, false); + for app in all_apps.iter() { + for mime in app.mime_types.iter() { + let apps = self + .cache + .entry(mime.clone()) + .or_insert_with(|| Vec::with_capacity(1)); + if apps.iter().find(|x| x.id == app.id).is_none() { + apps.push(MimeApp::from(app)); + } + } + for category in app.categories.iter() { + if category == "TerminalEmulator" { + self.terminals.push(MimeApp::from(app)); + break; + } + } + } + + let desktops: Vec = env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .split(':') + .map(|x| x.to_ascii_lowercase()) + .collect(); + + // Load mimeapps.list files + // https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-latest.html + //TODO: ensure correct lookup order + let mut mimeapps_paths = Vec::new(); + match xdg::BaseDirectories::new() { + Ok(xdg_dirs) => { + for path in xdg_dirs.find_data_files("applications/mimeapps.list") { + mimeapps_paths.push(path); + } + for desktop in desktops.iter().rev() { + for path in + xdg_dirs.find_data_files(format!("applications/{desktop}-mimeapps.list")) + { + mimeapps_paths.push(path); + } + } + for path in xdg_dirs.find_config_files("mimeapps.list") { + mimeapps_paths.push(path); + } + for desktop in desktops.iter().rev() { + for path in xdg_dirs.find_config_files(format!("{desktop}-mimeapps.list")) { + mimeapps_paths.push(path); + } + } + } + Err(err) => { + tracing::warn!("failed to get xdg base directories: {}", err); + } + } + + //TODO: handle directory specific behavior + for path in mimeapps_paths { + let entry = match freedesktop_entry_parser::parse_entry(&path) { + Ok(ok) => ok, + Err(err) => { + tracing::warn!("failed to parse {:?}: {}", path, err); + continue; + } + }; + + for attr in entry + .section("Added Associations") + .attrs() + .chain(entry.section("Default Applications").attrs()) + { + if let Ok(mime) = attr.name.parse::() { + if let Some(filenames) = attr.value { + for filename in filenames.split_terminator(';') { + tracing::trace!("add {}={}", mime, filename); + let apps = self + .cache + .entry(mime.clone()) + .or_insert_with(|| Vec::with_capacity(1)); + if apps + .iter() + .find(|x| filename_eq(&x.path, filename)) + .is_none() + { + if let Some(app) = + all_apps.iter().find(|x| filename_eq(&x.path, filename)) + { + apps.push(MimeApp::from(app)); + } else { + tracing::debug!("failed to add association for {:?}: application {:?} not found", mime, filename); + } + } + } + } + } + } + + for attr in entry.section("Removed Associations").attrs() { + if let Ok(mime) = attr.name.parse::() { + if let Some(filenames) = attr.value { + for filename in filenames.split_terminator(';') { + tracing::trace!("remove {}={}", mime, filename); + if let Some(apps) = self.cache.get_mut(&mime) { + apps.retain(|x| !filename_eq(&x.path, filename)); + } + } + } + } + } + + for attr in entry.section("Default Applications").attrs() { + if let Ok(mime) = attr.name.parse::() { + if let Some(filenames) = attr.value { + for filename in filenames.split_terminator(';') { + tracing::trace!("default {}={}", mime, filename); + if let Some(apps) = self.cache.get_mut(&mime) { + let mut found = false; + for app in apps.iter_mut() { + if filename_eq(&app.path, filename) { + app.is_default = true; + found = true; + } else { + app.is_default = false; + } + } + if found { + break; + } else { + tracing::debug!("failed to set default for {:?}: application {:?} not found", mime, filename); + } + } + } + } + } + } + } + + // Sort apps by name + for apps in self.cache.values_mut() { + apps.sort_by(|a, b| match (a.is_default, b.is_default) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => language_sorter + .as_ref() + .map_or(Ordering::Equal, |ls| ls.compare(&a.name, &b.name)), + }); + } + + let elapsed = start.elapsed(); + tracing::info!("loaded mime app cache in {:?}", elapsed); + } + + pub fn get(&self, key: &Mime) -> Vec { + self.cache + .get(&key) + .map_or_else(|| Vec::new(), |x| x.clone()) + } + + #[cfg(feature = "desktop")] + pub fn terminal(&self) -> Option { + //TODO: consider rules in https://github.com/Vladimir-csp/xdg-terminal-exec + + // Look for and return preferred terminals + //TODO: fallback order beyond cosmic-term? + for id in &["com.system76.CosmicTerm"] { + for terminal in self.terminals.iter() { + if &terminal.id == id { + return Some(terminal.clone()); + } + } + } + + // Return whatever was the first terminal found + self.terminals.first().map(|x| x.clone()) + } +} + +static MIME_APP_CACHE: Lazy> = Lazy::new(|| Mutex::new(MimeAppCache::new())); + +pub fn mime_apps(mime: &Mime) -> Vec { + let mime_app_cache = MIME_APP_CACHE.lock().unwrap(); + mime_app_cache.get(mime) +} + +pub fn terminal() -> Option { + let mime_app_cache = MIME_APP_CACHE.lock().unwrap(); + + //TODO: consider rules in https://github.com/Vladimir-csp/xdg-terminal-exec + + // Look for and return preferred terminals + //TODO: fallback order beyond cosmic-term? + for id in &["com.system76.CosmicTerm"] { + for terminal in mime_app_cache.terminals.iter() { + if &terminal.id == id { + return Some(terminal.clone()); + } + } + } + + // Return whatever was the first terminal found + mime_app_cache.terminals.first().map(|x| x.clone()) +} + +pub fn reload(language_sorter: Option<&Lazy>) { + let mut mime_app_cache = MIME_APP_CACHE.lock().unwrap(); + + mime_app_cache.reload(language_sorter); +}