diff --git a/Cargo.toml b/Cargo.toml index f909711..8901e74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "freedesktop-desktop-entry" -version = "0.5.4" +version = "0.6.0" authors = ["Michael Aaron Murphy "] edition = "2021" homepage = "https://github.com/pop-os/freedesktop-desktop-entry" @@ -18,3 +18,9 @@ textdistance = "1.0.2" strsim = "0.11.1" thiserror = "1" xdg = "2.4.0" +udev = "0.8.0" +zbus = "4.2.2" +log = "0.4.21" + +[dev-dependencies] +speculoos = "0.11.0" diff --git a/examples/de_launch.rs b/examples/de_launch.rs new file mode 100644 index 0000000..6205c8f --- /dev/null +++ b/examples/de_launch.rs @@ -0,0 +1,13 @@ +use freedesktop_desktop_entry::{get_languages_from_env, DesktopEntry}; +use std::env; +use std::path::PathBuf; + +fn main() { + let args: Vec = env::args().collect(); + let path = &args.get(1).expect("Not enough arguments"); + let path = PathBuf::from(path); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).expect("Error decoding desktop entry"); + de.launch_with_uris(&[], false, &locales) + .expect("Failed to run desktop entry"); +} diff --git a/src/decoder.rs b/src/decoder.rs index 3fac2b8..cef1256 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -1,3 +1,6 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + use std::{ borrow::Cow, collections::BTreeMap, @@ -68,10 +71,7 @@ impl<'a> DesktopEntry<'a> { } /// Return an owned [`DesktopEntry`] - pub fn from_path( - path: PathBuf, - locales: &[L], - ) -> Result, DecodeError> + pub fn from_path(path: PathBuf, locales: &[L]) -> Result, DecodeError> where L: AsRef, { @@ -194,7 +194,7 @@ where } /// Ex: if a locale equal fr_FR, add fr -fn add_generic_locales<'a, L: AsRef>(locales: &'a [L]) -> Vec<&'a str> { +fn add_generic_locales>(locales: &[L]) -> Vec<&str> { let mut v = Vec::with_capacity(locales.len() + 1); for l in locales { diff --git a/src/exec/dbus.rs b/src/exec/dbus.rs new file mode 100644 index 0000000..db5e2a2 --- /dev/null +++ b/src/exec/dbus.rs @@ -0,0 +1,121 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::exec::error::ExecError; +use crate::exec::graphics::Gpus; +use crate::DesktopEntry; +use std::collections::HashMap; +use zbus::blocking::Connection; +use zbus::names::OwnedBusName; +use zbus::proxy; +use zbus::zvariant::{OwnedValue, Str}; + +// https://specifications.freedesktop.org/desktop-entry-spec/1.1/ar01s07.html +#[proxy(interface = "org.freedesktop.Application")] +trait Application { + fn activate(&self, platform_data: HashMap) -> zbus::Result<()>; + + fn open(&self, uris: &[&str], platform_data: HashMap) -> zbus::Result<()>; + + // XXX: https://gitlab.freedesktop.org/xdg/xdg-specs/-/issues/134 + fn activate_action( + &self, + action_name: &str, + parameters: &[&str], + platform_data: HashMap, + ) -> zbus::Result<()>; +} + +impl DesktopEntry<'_> { + pub(crate) fn should_launch_on_dbus(&self) -> Option { + match self.desktop_entry_bool("DBusActivatable") { + true => match Connection::session() { + Ok(conn) => { + if self.is_bus_actionable(&conn) { + Some(conn) + } else { + None + } + } + Err(e) => { + log::error!("can't open dbus session: {}", e); + None + } + }, + false => None, + } + } + + fn is_bus_actionable(&self, conn: &Connection) -> bool { + let dbus_proxy = zbus::blocking::fdo::DBusProxy::new(conn); + + if dbus_proxy.is_err() { + return false; + } + + let dbus_proxy = dbus_proxy.unwrap(); + let dbus_names = dbus_proxy.list_activatable_names(); + + if dbus_names.is_err() { + return false; + } + + let dbus_names = dbus_names.unwrap(); + + dbus_names + .into_iter() + .map(OwnedBusName::into_inner) + .any(|name| name.as_str() == self.appid) + } + + pub(crate) fn dbus_launch(&self, conn: &Connection, uris: &[&str]) -> Result<(), ExecError> { + let app_proxy = self.get_app_proxy(conn)?; + let platform_data = self.get_platform_data(); + + if !uris.is_empty() { + app_proxy.open(uris, platform_data)?; + } else { + app_proxy.activate(platform_data)?; + } + + Ok(()) + } + + pub(crate) fn dbus_launch_action( + &self, + conn: &Connection, + action_name: &str, + uris: &[&str], + ) -> Result<(), ExecError> { + let app_proxy = self.get_app_proxy(conn)?; + let platform_data = self.get_platform_data(); + app_proxy.activate_action(action_name, uris, platform_data)?; + + Ok(()) + } + + fn get_app_proxy(&self, conn: &Connection) -> Result { + let dbus_path = self.appid.replace('.', "/").replace('-', "_"); + let dbus_path = format!("/{dbus_path}"); + let app_proxy = ApplicationProxyBlocking::builder(conn) + .destination(self.appid.as_ref())? + .path(dbus_path)? + .build()?; + Ok(app_proxy) + } + + // todo: XDG_ACTIVATION_TOKEN and DESKTOP_STARTUP_ID ? + // https://github.com/pop-os/libcosmic/blob/master/src/app/mod.rs + fn get_platform_data(&self) -> HashMap { + let mut platform_data = HashMap::new(); + if self.prefers_non_default_gpu() { + let gpus = Gpus::load(); + if let Some(gpu) = gpus.non_default() { + for (opt, value) in gpu.launch_options() { + platform_data.insert(opt, OwnedValue::from(Str::from(value.as_str()))); + } + } + } + platform_data + } +} diff --git a/src/exec/error.rs b/src/exec/error.rs new file mode 100644 index 0000000..bf32b61 --- /dev/null +++ b/src/exec/error.rs @@ -0,0 +1,49 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + +use std::env::VarError; +use std::io; +use std::path::Path; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ExecError<'a> { + #[error("{0}")] + WrongFormat(String), + + #[error("Exec string is empty")] + EmptyExecString, + + #[error("$SHELL environment variable is not set")] + ShellNotFound(#[from] VarError), + + #[error("Failed to run Exec command")] + IoError(#[from] io::Error), + + #[error("Exec command '{exec}' exited with status code '{status:?}'")] + NonZeroStatusCode { status: Option, exec: String }, + + #[error("Unknown field code: '{0}'")] + UnknownFieldCode(String), + + #[error("Deprecated field code: '{0}'")] + DeprecatedFieldCode(String), + + #[error("Exec key not found in desktop entry '{0:?}'")] + MissingExecKey(&'a Path), + + #[error("Action '{action}' not found for desktop entry '{desktop_entry:?}'")] + ActionNotFound { + action: String, + desktop_entry: &'a Path, + }, + + #[error("Exec key not found for action :'{action}' in desktop entry '{desktop_entry:?}'")] + ActionExecKeyNotFound { + action: String, + desktop_entry: &'a Path, + }, + + #[error("Failed to launch aplication via dbus: {0}")] + DBusError(#[from] zbus::Error), +} diff --git a/src/exec/graphics.rs b/src/exec/graphics.rs new file mode 100644 index 0000000..412ee89 --- /dev/null +++ b/src/exec/graphics.rs @@ -0,0 +1,210 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; +use std::io; +use std::ops::Deref; +use std::path::PathBuf; + +// const VULKAN_ICD_PATH: &str = std::env!( +// "VULKAN_ICD_PATH", +// "must define system Vulkan ICD path (ex: `/usr/share/vulkan/icd.d`)" +// ); + +#[derive(Debug, Default)] +pub struct Gpus { + devices: Vec, + default: Option, +} + +impl Gpus { + // Get gpus via udev + pub fn load() -> Self { + let drivers = get_gpus(); + + let mut gpus = Gpus::default(); + for dev in drivers.unwrap() { + if dev.is_default { + gpus.default = Some(dev) + } else { + gpus.devices.push(dev) + } + } + + gpus + } + + /// `true` if there is at least one non default gpu + pub fn is_switchable(&self) -> bool { + self.default.is_some() && !self.devices.is_empty() + } + + /// Return the default gpu + pub fn get_default(&self) -> Option<&Dev> { + self.default.as_ref() + } + + /// Get the first non-default gpu, the current `PreferNonDefaultGpu` specification + /// Does not tell us which one should be used. Anyway most machine out there should have + /// only one discrete graphic card. + /// see: https://gitlab.freedesktop.org/xdg/xdg-specs/-/issues/59 + pub fn non_default(&self) -> Option<&Dev> { + self.devices.first() + } +} + +#[derive(Debug)] +pub struct Dev { + id: usize, + driver: Driver, + is_default: bool, + parent_path: PathBuf, +} + +impl Dev { + /// Get the environment variable to launch a program with the correct gpu settings + pub fn launch_options(&self) -> Vec<(String, String)> { + let dev_num = self.id.to_string(); + let mut options = vec![]; + + match self.driver { + Driver::Unknown | Driver::Amd(_) | Driver::Intel => { + options.push(("DRI_PRIME".into(), dev_num)) + } + Driver::Nvidia => { + options.push(("__GLX_VENDOR_LIBRARY_NAME".into(), "nvidia".into())); + options.push(("__NV_PRIME_RENDER_OFFLOAD".into(), "1".into())); + options.push(("__VK_LAYER_NV_optimus".into(), "NVIDIA_only".into())); + } + } + + match self.get_vulkan_icd_paths() { + Ok(vulkan_icd_paths) if !vulkan_icd_paths.is_empty() => { + options.push(("VK_ICD_FILENAMES".into(), vulkan_icd_paths.join(":"))) + } + Err(err) => eprintln!("Failed to open vulkan icd paths: {err}"), + _ => {} + } + + options + } + + // Lookup vulkan icd files and return the ones matching the driver in use + fn get_vulkan_icd_paths(&self) -> io::Result> { + let vulkan_icd_paths = dirs::data_dir() + .expect("local data dir does not exists") + .join("vulkan/icd.d"); + + // Path::new(VULKAN_ICD_PATH) + let vulkan_icd_paths = &[vulkan_icd_paths.as_path()]; + + let mut icd_paths = vec![]; + if let Some(driver) = self.driver.as_str() { + for path in vulkan_icd_paths { + if path.exists() { + for entry in path.read_dir()? { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + let path_str = path.to_string_lossy(); + if path_str.contains(driver) { + icd_paths.push(path_str.to_string()) + } + } + } + } + } + } + + Ok(icd_paths) + } +} + +// Ensure we filter out "render" devices having the same parent as the card +impl Hash for Dev { + fn hash(&self, state: &mut H) { + state.write(self.parent_path.to_string_lossy().as_bytes()); + state.finish(); + } +} + +impl PartialEq for Dev { + fn eq(&self, other: &Self) -> bool { + self.parent_path == other.parent_path + } +} + +impl Eq for Dev {} + +#[derive(Debug)] +enum Driver { + Intel, + Amd(String), + Nvidia, + Unknown, +} + +impl Driver { + fn from_udev>(driver: Option) -> Driver { + match driver.as_deref() { + // For amd devices we need the name of the driver to get vulkan icd files + Some("radeon") => Driver::Amd("radeon".to_string()), + Some("amdgpu") => Driver::Amd("amdgpu".to_string()), + Some("nvidia") => Driver::Nvidia, + Some("iris") | Some("i915") | Some("i965") => Driver::Intel, + _ => Driver::Unknown, + } + } + + fn as_str(&self) -> Option<&str> { + match self { + Driver::Intel => Some("intel"), + Driver::Amd(driver) => Some(driver.as_str()), + Driver::Nvidia => Some("nvidia"), + Driver::Unknown => None, + } + } +} + +fn get_gpus() -> io::Result> { + let mut enumerator = udev::Enumerator::new()?; + let mut dev_map = HashSet::new(); + let mut drivers: Vec = enumerator + .scan_devices()? + .filter(|dev| { + dev.devnode() + .map(|path| path.starts_with("/dev/dri")) + .unwrap_or(false) + }) + .filter_map(|dev| { + dev.parent().and_then(|parent| { + let id = dev.sysnum(); + let parent_path = parent.syspath().to_path_buf(); + let driver = parent.driver().map(|d| d.to_string_lossy().to_string()); + let driver = Driver::from_udev(driver); + + let is_default = parent + .attribute_value("boot_vga") + .map(|v| v == "1") + .unwrap_or(false); + + id.map(|id| Dev { + id, + driver, + is_default, + parent_path, + }) + }) + }) + .collect(); + + // Sort the devices by sysnum so we get card0, card1 first and ignore the other 3D devices + drivers.sort_by(|a, b| a.id.cmp(&b.id)); + + for dev in drivers { + dev_map.insert(dev); + } + + Ok(dev_map) +} diff --git a/src/exec/mod.rs b/src/exec/mod.rs new file mode 100644 index 0000000..d83b021 --- /dev/null +++ b/src/exec/mod.rs @@ -0,0 +1,389 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::exec::error::ExecError; +use crate::exec::graphics::Gpus; +use crate::DesktopEntry; +use std::borrow::Cow; +use std::convert::TryFrom; +use std::path::PathBuf; +use std::process::Command; + +mod dbus; +pub mod error; +mod graphics; + +impl<'a> DesktopEntry<'a> { + pub fn launch(&self, prefer_non_default_gpu: bool) -> Result<(), ExecError> { + match self.should_launch_on_dbus() { + Some(conn) => self.dbus_launch(&conn, &[]), + None => self.shell_launch(self.exec(), &[], prefer_non_default_gpu, &[] as &[&str]), + } + } + + /// Execute the given desktop entry `Exec` key with either the default gpu or the alternative one if available. + /// Macros like `%f` (cf [.desktop spec](https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables)) will + /// be subtitued using the `uris` parameter. + pub fn launch_with_uris( + &self, + uris: &[&'a str], + prefer_non_default_gpu: bool, + locales: &[L], + ) -> Result<(), ExecError> + where + L: AsRef, + { + match self.should_launch_on_dbus() { + Some(conn) => self.dbus_launch(&conn, uris), + None => self.shell_launch(self.exec(), uris, prefer_non_default_gpu, locales), + } + } + + pub fn launch_action( + &self, + action_name: &str, + prefer_non_default_gpu: bool, + ) -> Result<(), ExecError> { + match self.should_launch_on_dbus() { + Some(conn) => self.dbus_launch_action(&conn, action_name, &[]), + None => self.shell_launch( + self.action_exec(action_name), + &[], + prefer_non_default_gpu, + &[] as &[&str], + ), + } + } + + pub fn launch_action_with_uris( + &self, + action_name: &str, + uris: &[&'a str], + prefer_non_default_gpu: bool, + locales: &[L], + ) -> Result<(), ExecError> + where + L: AsRef, + { + match self.should_launch_on_dbus() { + Some(conn) => self.dbus_launch_action(&conn, action_name, uris), + None => self.shell_launch( + self.action_exec(action_name), + uris, + prefer_non_default_gpu, + locales, + ), + } + } + + // https://github.com/pop-os/libcosmic/blob/master/src/desktop.rs + fn shell_launch( + &'a self, + exec: Option<&'a str>, + uris: &[&str], + prefer_non_default_gpu: bool, + locales: &[L], + ) -> Result<(), ExecError> + where + L: AsRef, + { + if exec.is_none() { + return Err(ExecError::MissingExecKey(&self.path)); + } + + let exec = exec.unwrap(); + let exec = if let Some(unquoted_exec) = exec.strip_prefix('\"') { + unquoted_exec + .strip_suffix('\"') + .ok_or(ExecError::WrongFormat("unmatched quote".into()))? + } else { + exec + }; + + let mut exec_args = vec![]; + + for arg in exec.split_ascii_whitespace() { + let arg = ArgOrFieldCode::try_from(arg)?; + exec_args.push(arg); + } + + let exec_args = self.get_args(uris, exec_args, locales); + + if exec_args.is_empty() { + return Err(ExecError::EmptyExecString); + } + + let exec_args = exec_args.join(" "); + let shell = std::env::var("SHELL")?; + + let status = if self.terminal() { + let (terminal, separator) = detect_terminal(); + let terminal = terminal.to_string_lossy(); + let args = format!("{terminal} {separator} {exec_args}"); + let args = ["-c", &args]; + let mut cmd = Command::new(shell); + if prefer_non_default_gpu { + with_non_default_gpu(cmd) + } else { + cmd + } + .args(args) + .spawn()? + .try_wait()? + } else { + let mut cmd = Command::new(shell); + + if prefer_non_default_gpu { + with_non_default_gpu(cmd) + } else { + cmd + } + .args(["-c", &exec_args]) + .spawn()? + .try_wait()? + }; + + if let Some(status) = status { + if !status.success() { + return Err(ExecError::NonZeroStatusCode { + status: status.code(), + exec: exec.to_string(), + }); + } + } + + Ok(()) + } + + // Replace field code with their values and ignore deprecated and unknown field codes + fn get_args( + &'a self, + uris: &[&'a str], + exec_args: Vec>, + locales: &[L], + ) -> Vec> + where + L: AsRef, + { + let mut final_args: Vec> = Vec::new(); + + for arg in exec_args { + match arg { + ArgOrFieldCode::SingleFileName | ArgOrFieldCode::SingleUrl => { + if let Some(arg) = uris.first() { + final_args.push(Cow::Borrowed(arg)); + } + } + ArgOrFieldCode::FileList | ArgOrFieldCode::UrlList => { + uris.iter() + .for_each(|uri| final_args.push(Cow::Borrowed(uri))); + } + ArgOrFieldCode::IconKey => { + if let Some(icon) = self.icon() { + final_args.push(Cow::Borrowed(icon)); + } + } + ArgOrFieldCode::TranslatedName => { + if let Some(name) = self.name(locales) { + final_args.push(name.clone()); + } + } + ArgOrFieldCode::DesktopFileLocation => { + final_args.push(self.path.to_string_lossy()); + } + ArgOrFieldCode::Arg(arg) => { + final_args.push(Cow::Borrowed(arg)); + } + } + } + + final_args + } +} + +fn with_non_default_gpu(mut cmd: Command) -> Command { + let gpus = Gpus::load(); + let gpu = if gpus.is_switchable() { + gpus.non_default() + } else { + gpus.get_default() + }; + + if let Some(gpu) = gpu { + for (opt, value) in gpu.launch_options() { + cmd.env(opt, value); + } + } + + cmd +} + +// either a command line argument or a field-code as described +// in https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables +enum ArgOrFieldCode<'a> { + SingleFileName, + FileList, + SingleUrl, + UrlList, + IconKey, + TranslatedName, + DesktopFileLocation, + Arg(&'a str), +} + +impl<'a> TryFrom<&'a str> for ArgOrFieldCode<'a> { + type Error = ExecError<'a>; + + fn try_from(value: &'a str) -> Result { + match value { + "%f" => Ok(ArgOrFieldCode::SingleFileName), + "%F" => Ok(ArgOrFieldCode::FileList), + "%u" => Ok(ArgOrFieldCode::SingleUrl), + "%U" => Ok(ArgOrFieldCode::UrlList), + "%i" => Ok(ArgOrFieldCode::IconKey), + "%c" => Ok(ArgOrFieldCode::TranslatedName), + "%k" => Ok(ArgOrFieldCode::DesktopFileLocation), + "%d" | "%D" | "%n" | "%N" | "%v" | "%m" => { + Err(ExecError::DeprecatedFieldCode(value.to_string())) + } + other if other.starts_with('%') => Err(ExecError::UnknownFieldCode(other.to_string())), + other => Ok(ArgOrFieldCode::Arg(other)), + } + } +} + +// Returns the default terminal emulator linked to `/usr/bin/x-terminal-emulator` +// or fallback to gnome terminal, then konsole +fn detect_terminal() -> (PathBuf, &'static str) { + use std::fs::read_link; + + const SYMLINK: &str = "/usr/bin/x-terminal-emulator"; + + if let Ok(found) = read_link(SYMLINK) { + let arg = if found.to_string_lossy().contains("gnome-terminal") { + "--" + } else { + "-e" + }; + + return (read_link(&found).unwrap_or(found), arg); + } + + let gnome_terminal = PathBuf::from("/usr/bin/gnome-terminal"); + if gnome_terminal.exists() { + (gnome_terminal, "--") + } else { + (PathBuf::from("/usr/bin/konsole"), "-e") + } +} + +#[cfg(test)] +mod test { + use crate::exec::error::ExecError; + use crate::exec::with_non_default_gpu; + use crate::{get_languages_from_env, DesktopEntry}; + use speculoos::prelude::*; + + use std::path::PathBuf; + use std::process::Command; + + #[test] + fn should_return_unmatched_quote_error() { + let path = PathBuf::from("tests/entries/unmatched-quotes.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&[], false, &locales); + + assert_that!(result) + .is_err() + .matches(|err| matches!(err, ExecError::WrongFormat(..))); + } + + #[test] + fn should_fail_if_exec_string_is_empty() { + let path = PathBuf::from("tests/entries/empty-exec.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&[], false, &locales); + + assert_that!(result) + .is_err() + .matches(|err| matches!(err, ExecError::EmptyExecString)); + } + + #[test] + #[ignore = "Needs a desktop environment and alacritty installed, run locally only"] + fn should_exec_simple_command() { + let path = PathBuf::from("tests/entries/alacritty-simple.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&[], false, &locales); + + assert_that!(result).is_ok(); + } + + #[test] + #[ignore = "Needs a desktop environment and alacritty and mesa-utils installed, run locally only"] + fn should_exec_complex_command() { + let path = PathBuf::from("tests/entries/non-terminal-cmd.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&[], false, &locales); + + assert_that!(result).is_ok(); + } + + #[test] + #[ignore = "Needs a desktop environment and alacritty and mesa-utils installed, run locally only"] + fn should_exec_terminal_command() { + let path = PathBuf::from("tests/entries/non-terminal-cmd.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&[], false, &locales); + + assert_that!(result).is_ok(); + } + + #[test] + #[ignore = "Needs a desktop environment with nvim installed, run locally only"] + fn should_launch_with_field_codes() { + let path = PathBuf::from("/usr/share/applications/nvim.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&["src/lib.rs"], false, &locales); + + assert_that!(result).is_ok(); + } + + #[test] + #[ignore = "Needs a desktop environment with gnome Books installed, run locally only"] + fn should_launch_with_dbus() { + let path = PathBuf::from("/usr/share/applications/org.gnome.Books.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let result = de.launch_with_uris(&["src/lib.rs"], false, &locales); + + assert_that!(result).is_ok(); + } + + #[test] + #[ignore = "Needs a desktop environment with Nautilus installed, run locally only"] + fn should_launch_with_dbus_and_field_codes() { + let path = PathBuf::from("/usr/share/applications/org.gnome.Nautilus.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, &locales).unwrap(); + let _result = de.launch_with_uris(&[], false, &locales); + let path = std::env::current_dir().unwrap(); + let path = path.to_string_lossy(); + let path = format!("file:///{path}"); + let result = de.launch_with_uris(&[path.as_str()], false, &locales); + + assert_that!(result).is_ok(); + } + + #[test] + fn should_build_command_with_gpu() { + let cmd = with_non_default_gpu(Command::new("glxgears")); + assert_that!(cmd.get_envs().collect::>()).is_not_empty(); + } +} diff --git a/src/iter.rs b/src/iter.rs index bdaedcc..1d90669 100644 --- a/src/iter.rs +++ b/src/iter.rs @@ -37,7 +37,6 @@ impl Iterator for Iter { Err(_) => continue, } } - return None; } diff --git a/src/lib.rs b/src/lib.rs index b911c26..cf5fa52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,16 +2,16 @@ // SPDX-License-Identifier: MPL-2.0 mod decoder; +mod exec; +pub use exec::error::ExecError; mod iter; pub mod matching; -#[cfg(test)] -mod test; - pub use self::iter::Iter; use std::borrow::Cow; use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use xdg::BaseDirectories; @@ -24,7 +24,7 @@ pub type Locale<'a> = Cow<'a, str>; pub type LocaleMap<'a> = BTreeMap, Value<'a>>; pub type Value<'a> = Cow<'a, str>; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq)] pub struct DesktopEntry<'a> { pub appid: Cow<'a, str>, pub groups: Groups<'a>, @@ -32,10 +32,23 @@ pub struct DesktopEntry<'a> { pub ubuntu_gettext_domain: Option>, } +impl Hash for DesktopEntry<'_> { + fn hash(&self, state: &mut H) { + self.appid.hash(state); + } +} + +impl PartialEq for DesktopEntry<'_> { + fn eq(&self, other: &Self) -> bool { + self.appid == other.appid + } +} + + impl DesktopEntry<'_> { /// Construct a new [`DesktopEntry`] from an appid. The name field will be /// set to that appid. - pub fn from_appid<'a>(appid: &'a str) -> DesktopEntry<'a> { + pub fn from_appid(appid: &str) -> DesktopEntry<'_> { let mut de = DesktopEntry { appid: Cow::Borrowed(appid), groups: Groups::default(), @@ -77,12 +90,10 @@ impl<'a> DesktopEntry<'a> { DesktopEntry { appid: Cow::Owned(self.appid.to_string()), groups: new_groups, - ubuntu_gettext_domain: if let Some(ubuntu_gettext_domain) = &self.ubuntu_gettext_domain - { - Some(Cow::Owned(ubuntu_gettext_domain.to_string())) - } else { - None - }, + ubuntu_gettext_domain: self + .ubuntu_gettext_domain + .as_ref() + .map(|ubuntu_gettext_domain| Cow::Owned(ubuntu_gettext_domain.to_string())), path: Cow::Owned(self.path.to_path_buf()), } } @@ -93,9 +104,9 @@ impl<'a> DesktopEntry<'a> { self.appid.as_ref() } - /// A desktop entry field is any field under the `[Desktop Entry]` section. + /// A desktop entry field if any field under the `[Desktop Entry]` section. pub fn desktop_entry(&'a self, key: &str) -> Option<&'a str> { - Self::entry(self.groups.get("Desktop Entry"), key).map(|e| e.as_ref()) + Self::entry(self.groups.get("Desktop Entry"), key) } pub fn desktop_entry_localized>( @@ -154,27 +165,35 @@ impl<'a> DesktopEntry<'a> { self.desktop_entry("Exec") } - /// Return categories separated by `;` - pub fn categories(&'a self) -> Option<&'a str> { + /// Return categories + pub fn categories(&'a self) -> Option> { self.desktop_entry("Categories") + .map(|e| e.split(';').collect()) } - /// Return keywords separated by `;` - pub fn keywords>(&'a self, locales: &[L]) -> Option> { - self.desktop_entry_localized("Keywords", locales) + /// Return keywords + pub fn keywords>(&'a self, locales: &[L]) -> Option>> { + self.localized_entry_splitted(self.groups.get("Desktop Entry"), "Keywords", locales) } - /// Return mime types separated by `;` - pub fn mime_type(&'a self) -> Option<&'a str> { + /// Return mime types + pub fn mime_type(&'a self) -> Option> { self.desktop_entry("MimeType") + .map(|e| e.split(';').collect()) } pub fn no_display(&'a self) -> bool { self.desktop_entry_bool("NoDisplay") } - pub fn only_show_in(&'a self) -> Option<&'a str> { + pub fn only_show_in(&'a self) -> Option> { self.desktop_entry("OnlyShowIn") + .map(|e| e.split(';').collect()) + } + + pub fn not_show_in(&'a self) -> Option> { + self.desktop_entry("NotShowIn") + .map(|e| e.split(';').collect()) } pub fn flatpak(&'a self) -> Option<&'a str> { @@ -201,9 +220,9 @@ impl<'a> DesktopEntry<'a> { self.desktop_entry("Type") } - /// Return actions separated by `;` - pub fn actions(&'a self) -> Option<&'a str> { + pub fn actions(&'a self) -> Option> { self.desktop_entry("Actions") + .map(|e| e.split(';').collect()) } /// An action is defined as `[Desktop Action actions-name]` where `action-name` @@ -214,7 +233,7 @@ impl<'a> DesktopEntry<'a> { /// Name=Open a New Window /// ``` /// you will need to call - /// ```rust + /// ```ignore /// entry.action_entry("new-window", "Name") /// ``` pub fn action_entry(&'a self, action: &str, key: &str) -> Option<&'a str> { @@ -266,13 +285,7 @@ impl<'a> DesktopEntry<'a> { key: &str, locales: &[L], ) -> Option> { - let Some(group) = group else { - return None; - }; - - let Some((default_value, locale_map)) = group.get(key) else { - return None; - }; + let (default_value, locale_map) = group?.get(key)?; for locale in locales { match locale_map.get(locale.as_ref()) { @@ -287,9 +300,43 @@ impl<'a> DesktopEntry<'a> { } } if let Some(domain) = ubuntu_gettext_domain { - return Some(Cow::Owned(dgettext(domain, &default_value))); + return Some(Cow::Owned(dgettext(domain, default_value))); } - return Some(default_value.clone()); + Some(default_value.clone()) + } + + pub fn localized_entry_splitted>( + &self, + group: Option<&'a KeyMap<'a>>, + key: &str, + locales: &[L], + ) -> Option>> { + let (default_value, locale_map) = group?.get(key)?; + + for locale in locales { + match locale_map.get(locale.as_ref()) { + Some(value) => { + return Some(value.split(';').map(|e| Cow::Borrowed(e)).collect()); + } + None => { + if let Some(pos) = memchr::memchr(b'_', locale.as_ref().as_bytes()) { + if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) { + return Some(value.split(';').map(|e| Cow::Borrowed(e)).collect()); + } + } + } + } + } + if let Some(domain) = &self.ubuntu_gettext_domain { + return Some( + dgettext(domain, default_value) + .split(';') + .map(|e| Cow::Owned(e.to_string())) + .collect(), + ); + } + + Some(default_value.split(';').map(|e| Cow::Borrowed(e)).collect()) } } @@ -403,9 +450,40 @@ pub fn get_languages_from_env() -> Vec { l } +pub fn current_desktop() -> Option> { + std::env::var("XDG_CURRENT_DESKTOP").ok().map(|x| { + let x = x.to_ascii_lowercase(); + if x == "unity" { + vec!["gnome".to_string()] + } else { + x.split(':').map(|e| e.to_string()).collect() + } + }) +} + #[test] -fn locales_env_test() { +fn add_field() { + let appid = "appid"; + let de = DesktopEntry::from_appid(appid); + + assert_eq!(de.appid, appid); + assert_eq!(de.name(&[] as &[&str]).unwrap(), appid); + let s = get_languages_from_env(); println!("{:?}", s); } + +#[test] +fn env_with_locale() { + let locales = &["fr_FR"]; + + let de = DesktopEntry::from_path(PathBuf::from("tests/org.mozilla.firefox.desktop"), locales) + .unwrap(); + + assert_eq!(de.generic_name(locales).unwrap(), "Navigateur Web"); + + let locales = &["nb"]; + + assert_eq!(de.generic_name(locales).unwrap(), "Web Browser"); +} diff --git a/src/matching.rs b/src/matching.rs index 025ac55..65a37c6 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -1,7 +1,19 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + use std::cmp::max; use crate::DesktopEntry; +#[inline] +fn add_value(v: &mut Vec, value: &str, is_multiple: bool) { + if is_multiple { + value.split(';').for_each(|e| v.push(e.to_lowercase())); + } else { + v.push(value.to_lowercase()); + } +} + /// The returned value is between 0.0 and 1.0 (higher value means more similar). /// You can use the `additional_values` parameter to add runtime string. pub fn get_entry_score<'a, Q, L>( @@ -14,53 +26,50 @@ where Q: AsRef, L: AsRef, { - // let the user do this ? - let query = query.as_ref().to_lowercase(); - - // todo: cache all this ? - - let fields = ["Name", "GenericName", "Comment", "Categories", "Keywords"]; - let fields_not_translatable = ["Exec", "StartupWMClass"]; + // (field name, is separated by ";") + let fields = [ + ("Name", false), + ("GenericName", false), + ("Comment", false), + ("Categories", true), + ("Keywords", true), + ]; let mut normalized_values: Vec = Vec::new(); normalized_values.extend(additional_values.iter().map(|val| val.to_lowercase())); - let de_id = entry.appid.to_lowercase(); - let de_wm_class = entry.startup_wm_class().unwrap_or_default().to_lowercase(); - - normalized_values.push(de_id); - normalized_values.push(de_wm_class); - let desktop_entry_group = entry.groups.get("Desktop Entry"); - for field in fields_not_translatable { - if let Some(e) = DesktopEntry::entry(desktop_entry_group, field) { - normalized_values.push(e.to_lowercase()); - } - } + for field in fields { + if let Some(group) = desktop_entry_group { + if let Some((default_value, locale_map)) = group.get(field.0) { + add_value(&mut normalized_values, default_value, field.1); - for locale in locales { - for field in fields { - if let Some(group) = desktop_entry_group { - if let Some((default_value, locale_map)) = group.get(field) { + let mut at_least_one_locale = false; + + for locale in locales { match locale_map.get(locale.as_ref()) { Some(value) => { - normalized_values.push(value.to_lowercase()); + add_value(&mut normalized_values, value, field.1); + at_least_one_locale = true; } None => { if let Some(pos) = locale.as_ref().find('_') { if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) { - normalized_values.push(value.to_lowercase()); + add_value(&mut normalized_values, value, field.1); + at_least_one_locale = true; } } } } + } + if !at_least_one_locale { if let Some(domain) = &entry.ubuntu_gettext_domain { - let gettext_value = crate::dgettext(domain, &default_value); + let gettext_value = crate::dgettext(domain, default_value); if !gettext_value.is_empty() { - normalized_values.push(gettext_value.to_lowercase()); + add_value(&mut normalized_values, &gettext_value, false); } } } @@ -68,6 +77,8 @@ where } } + let query = query.as_ref().to_lowercase(); + normalized_values .into_iter() .map(|de| strsim::jaro_winkler(&query, &de)) @@ -86,9 +97,8 @@ fn compare_str<'a>(pattern: &'a str, de_value: &'a str) -> f64 { fn match_entry_from_id(pattern: &str, de: &DesktopEntry) -> f64 { let de_id = de.appid.to_lowercase(); let de_wm_class = de.startup_wm_class().unwrap_or_default().to_lowercase(); - let de_name = de.name(&[] as &[&str]).unwrap_or_default().to_lowercase(); - *[de_id, de_wm_class, de_name] + *[de_id, de_wm_class] .map(|de| compare_str(pattern, &de)) .iter() .max_by(|e1, e2| e1.total_cmp(e2)) @@ -119,6 +129,7 @@ impl Default for MatchAppIdOptions { /// Return the best match over all provided [`DesktopEntry`]. /// Use this to match over the values provided by the compositor, not the user. +/// First entries get the priority. pub fn get_best_match<'a, I>( patterns: &[I], entries: &'a [DesktopEntry<'a>], diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 8588778..0000000 --- a/src/test.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::path::Path; - -use crate::DesktopEntry; - -#[test] -fn test() { - let path = Path::new("tests/org.mozilla.firefox.desktop"); - - let locales = &["fr", "fr_FR.UTF-8"]; - - if let Ok(entry) = DesktopEntry::from_path(path.to_path_buf(), locales) { - let e = DesktopEntry::localized_entry( - None, - entry.groups.get("Desktop Entry"), - "GenericName", - &["fr"], - ) - .unwrap(); - - assert_eq!(e, "Navigateur Web"); - } -} diff --git a/tests/entries/alacritty-simple.desktop b/tests/entries/alacritty-simple.desktop new file mode 100644 index 0000000..704a72e --- /dev/null +++ b/tests/entries/alacritty-simple.desktop @@ -0,0 +1,19 @@ +[Desktop Entry] +Type=Application +TryExec=alacritty +Exec=alacritty +Icon=Alacritty +Terminal=false +Categories=System;TerminalEmulator; + +Name=Alacritty +GenericName=Terminal +Comment=A fast, cross-platform, OpenGL terminal emulator +StartupWMClass=Alacritty +Actions=New; + +X-Desktop-File-Install-Version=0.26 + +[Desktop Action New] +Name=New Terminal +Exec=alacritty diff --git a/tests/entries/empty-exec.desktop b/tests/entries/empty-exec.desktop new file mode 100644 index 0000000..fb28c29 --- /dev/null +++ b/tests/entries/empty-exec.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Exec= +Terminal=false +Type=Application +Name=NoExecKey diff --git a/tests/entries/non-terminal-cmd.desktop b/tests/entries/non-terminal-cmd.desktop new file mode 100644 index 0000000..3b84b75 --- /dev/null +++ b/tests/entries/non-terminal-cmd.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Exec=alacritty -e glxgears -info +Terminal=false +Type=Application +Name=GlxGearNoTerminal \ No newline at end of file diff --git a/tests/entries/terminal-cmd.desktop b/tests/entries/terminal-cmd.desktop new file mode 100644 index 0000000..70cf76a --- /dev/null +++ b/tests/entries/terminal-cmd.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Exec=glxgears -info +Terminal=true +Type=Application +Name=GlxGearTerminal \ No newline at end of file diff --git a/tests/entries/unmatched-quotes.desktop b/tests/entries/unmatched-quotes.desktop new file mode 100644 index 0000000..6f8f6ef --- /dev/null +++ b/tests/entries/unmatched-quotes.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Exec="alacritty -e +Terminal=false +Type=Application +Name=InvalidCommand \ No newline at end of file