diff --git a/Cargo.toml b/Cargo.toml index b2afe43f4e9..2aa30a04931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ rust-version = "1.80" name = "cosmic" [features] -default = ["multi-window", "a11y"] +default = ["multi-window", "a11y", "dep:shlex"] # Accessibility support a11y = ["iced/a11y", "iced_accessibility"] # Enable about widget @@ -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 = [ @@ -114,11 +118,13 @@ tokio = { version = "1.24.2", optional = true } tracing = "0.1.41" unicode-segmentation = "1.6" url = "2.4.0" +xdg = { version = "2.5.2", optional = true } zbus = { version = "4.2.1", default-features = false, optional = true } [target.'cfg(unix)'.dependencies] freedesktop-icons = { package = "cosmic-freedesktop-icons", git = "https://github.com/pop-os/freedesktop-icons" } 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 2cc6865c908..256863574ba 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 2cc6865c908d025e43d7fc937f6b67defd51ea18 +Subproject commit 256863574bacfb1d2797c2a48cba7a3388cbeb59 diff --git a/src/desktop.rs b/src/desktop.rs index c8a7ab9e9d7..1669f0eb09d 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -6,6 +6,9 @@ use std::path::{Path, PathBuf}; #[cfg(not(windows))] use std::{borrow::Cow, ffi::OsStr}; +#[cfg(not(windows))] +use crate::mime_app::{exec_term_to_command, exec_to_command}; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum IconSource { Name(String), @@ -62,6 +65,7 @@ pub struct DesktopEntryData { pub desktop_actions: Vec, pub mime_types: Vec, pub prefers_dgpu: bool, + pub terminal: bool, } #[cfg(not(windows))] @@ -233,13 +237,18 @@ impl DesktopEntryData { }) .unwrap_or_default(), prefers_dgpu: de.prefers_non_default_gpu(), + terminal: de.terminal(), } } } #[cfg(not(windows))] -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, @@ -252,14 +261,13 @@ where _ => return, }; - let mut cmd = std::process::Command::new(&executable); + let cmd = if terminal { + exec_term_to_command(&executable, None) + } else { + exec_to_command(&executable, None) + }; - for arg in exec { - // TODO handle "%" args here if necessary? - if !arg.starts_with('%') { - cmd.arg(arg); - } - } + let Some(mut cmd) = cmd else { return }; cmd.envs(env_vars); diff --git a/src/lib.rs b/src/lib.rs index 3e583d131fa..fdbac18be57 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(all(feature = "process", not(windows)))] pub mod process; diff --git a/src/mime_app.rs b/src/mime_app.rs new file mode 100644 index 00000000000..5737e57ab82 --- /dev/null +++ b/src/mime_app.rs @@ -0,0 +1,341 @@ +// 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 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); +}