diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 37ed364..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "rust-analyzer.cargo.features": [ - ] -} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 967355c..689b518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,4 @@ strsim = "0.11.1" thiserror = "1" xdg = "2.4.0" log = "0.4.21" + diff --git a/examples/specific_file.rs b/examples/specific_file.rs index 526080b..d352528 100644 --- a/examples/specific_file.rs +++ b/examples/specific_file.rs @@ -3,7 +3,7 @@ use std::path::Path; use freedesktop_desktop_entry::DesktopEntry; fn main() { - let path = Path::new("tests/org.mozilla.firefox.desktop"); + let path = Path::new("tests_entries/org.mozilla.firefox.desktop"); let locales = &["fr_FR", "en", "it"]; // if let Ok(bytes) = fs::read_to_string(path) { diff --git a/src/exec.rs b/src/exec.rs new file mode 100644 index 0000000..2d0f73b --- /dev/null +++ b/src/exec.rs @@ -0,0 +1,260 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::DesktopEntry; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ExecError { + #[error("{0}")] + WrongFormat(String), + + #[error("Exec field is empty")] + ExecFieldIsEmpty, + + #[error("Exec key was not found")] + ExecFieldNotFound, +} + +impl<'a> DesktopEntry<'a> { + pub fn parse_exec(&self) -> Result, ExecError> { + self.get_args(self.exec(), &[], &[] as &[&str]) + } + + /// 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 parse_exec_with_uris( + &self, + uris: &[&'a str], + locales: &[L], + ) -> Result, ExecError> + where + L: AsRef, + { + self.get_args(self.exec(), uris, locales) + } + + pub fn parse_exec_action(&self, action_name: &str) -> Result, ExecError> { + self.get_args(self.action_exec(action_name), &[], &[] as &[&str]) + } + + pub fn parse_exec_action_with_uris( + &self, + action_name: &str, + uris: &[&'a str], + locales: &[L], + ) -> Result, ExecError> + where + L: AsRef, + { + self.get_args(self.action_exec(action_name), uris, locales) + } + + fn get_args( + &'a self, + exec: Option<&'a str>, + uris: &[&'a str], + locales: &[L], + ) -> Result, ExecError> + where + L: AsRef, + { + let Some(exec) = exec else { + return Err(ExecError::ExecFieldNotFound); + }; + + if exec.contains('=') { + return Err(ExecError::WrongFormat("equal sign detected".into())); + } + + let exec = if let Some(without_prefix) = exec.strip_prefix('\"') { + without_prefix + .strip_suffix('\"') + .ok_or(ExecError::WrongFormat("unmatched quote".into()))? + } else { + exec + }; + + let mut args: Vec = Vec::new(); + + for arg in exec.split_ascii_whitespace() { + match ArgOrFieldCode::try_from(arg) { + Ok(arg) => match arg { + ArgOrFieldCode::SingleFileName | ArgOrFieldCode::SingleUrl => { + if let Some(arg) = uris.first() { + args.push(arg.to_string()); + } + } + ArgOrFieldCode::FileList | ArgOrFieldCode::UrlList => { + uris.iter().for_each(|uri| args.push(uri.to_string())); + } + ArgOrFieldCode::IconKey => { + if let Some(icon) = self.icon() { + args.push(icon.to_string()); + } + } + ArgOrFieldCode::TranslatedName => { + if let Some(name) = self.name(locales) { + args.push(name.to_string()); + } + } + ArgOrFieldCode::DesktopFileLocation => { + args.push(self.path.to_string_lossy().to_string()); + } + ArgOrFieldCode::Arg(arg) => { + args.push(arg.to_string()); + } + }, + Err(e) => { + log::error!("{}", e); + } + } + } + + if args.is_empty() { + return Err(ExecError::ExecFieldIsEmpty); + } + + Ok(args) + } +} + +// 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), +} + +#[derive(Debug, Error)] +enum ExecErrorInternal<'a> { + #[error("Unknown field code: '{0}'")] + UnknownFieldCode(&'a str), + + #[error("Deprecated field code: '{0}'")] + DeprecatedFieldCode(&'a str), +} + +impl<'a> TryFrom<&'a str> for ArgOrFieldCode<'a> { + type Error = ExecErrorInternal<'a>; + + // todo: handle escaping + 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(ExecErrorInternal::DeprecatedFieldCode(value)) + } + other if other.starts_with('%') => Err(ExecErrorInternal::UnknownFieldCode(other)), + other => Ok(ArgOrFieldCode::Arg(other)), + } + } +} + +#[cfg(test)] +mod test { + + use std::path::PathBuf; + + use crate::{get_languages_from_env, DesktopEntry}; + + use super::ExecError; + + #[test] + fn should_return_unmatched_quote_error() { + let path = PathBuf::from("tests_entries/exec/unmatched-quotes.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&[], &locales); + + assert!(matches!(result.unwrap_err(), ExecError::WrongFormat(..))); + } + + #[test] + fn should_fail_if_exec_string_is_empty() { + let path = PathBuf::from("tests_entries/exec/empty-exec.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&[], &locales); + + assert!(matches!(result.unwrap_err(), ExecError::ExecFieldIsEmpty)); + } + + #[test] + fn should_exec_simple_command() { + let path = PathBuf::from("tests_entries/exec/alacritty-simple.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&[], &locales); + + assert!(result.is_ok()); + } + + #[test] + fn should_exec_complex_command() { + let path = PathBuf::from("tests_entries/exec/non-terminal-cmd.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&[], &locales); + + assert!(result.is_ok()); + } + + #[test] + fn should_exec_terminal_command() { + let path = PathBuf::from("tests_entries/exec/non-terminal-cmd.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&[], &locales); + + assert!(result.is_ok()); + } + + #[test] + #[ignore = "Needs a desktop environment with nvim installed, run locally only"] + fn should_parse_exec_with_field_codes() { + let path = PathBuf::from("/usr/share/applications/nvim.desktop"); + let locales = get_languages_from_env(); + let de = DesktopEntry::from_path(path, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&["src/lib.rs"], &locales); + + assert!(result.is_ok()); + } + + #[test] + #[ignore = "Needs a desktop environment with gnome Books installed, run locally only"] + fn should_parse_exec_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, Some(&locales)).unwrap(); + let result = de.parse_exec_with_uris(&["src/lib.rs"], &locales); + + assert!(result.is_ok()); + } + + #[test] + #[ignore = "Needs a desktop environment with Nautilus installed, run locally only"] + fn should_parse_exec_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, Some(&locales)).unwrap(); + let _result = de.parse_exec_with_uris(&[], &locales); + let path = std::env::current_dir().unwrap(); + let path = path.to_string_lossy(); + let path = format!("file:///{path}"); + let result = de.parse_exec_with_uris(&[path.as_str()], &locales); + + assert!(result.is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs index a1fcf12..2879c6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,9 @@ mod decoder; mod iter; +mod exec; +pub use exec::ExecError; + pub mod matching; pub use decoder::DecodeError; @@ -477,7 +480,7 @@ fn env_with_locale() { let locales = &["fr_FR"]; let de = DesktopEntry::from_path( - PathBuf::from("tests/org.mozilla.firefox.desktop"), + PathBuf::from("tests_entries/org.mozilla.firefox.desktop"), Some(locales), ) .unwrap(); diff --git a/tests/code.desktop b/tests_entries/code.desktop similarity index 100% rename from tests/code.desktop rename to tests_entries/code.desktop diff --git a/tests/com.brave.Browser.flextop.brave-cinhimbnkkaeohfgghhklpknlkffjgod-Default.desktop b/tests_entries/com.brave.Browser.flextop.brave-cinhimbnkkaeohfgghhklpknlkffjgod-Default.desktop similarity index 100% rename from tests/com.brave.Browser.flextop.brave-cinhimbnkkaeohfgghhklpknlkffjgod-Default.desktop rename to tests_entries/com.brave.Browser.flextop.brave-cinhimbnkkaeohfgghhklpknlkffjgod-Default.desktop diff --git a/tests_entries/exec/alacritty-simple.desktop b/tests_entries/exec/alacritty-simple.desktop new file mode 100644 index 0000000..704a72e --- /dev/null +++ b/tests_entries/exec/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/exec/empty-exec.desktop b/tests_entries/exec/empty-exec.desktop new file mode 100644 index 0000000..fb28c29 --- /dev/null +++ b/tests_entries/exec/empty-exec.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Exec= +Terminal=false +Type=Application +Name=NoExecKey diff --git a/tests_entries/exec/non-terminal-cmd.desktop b/tests_entries/exec/non-terminal-cmd.desktop new file mode 100644 index 0000000..3b84b75 --- /dev/null +++ b/tests_entries/exec/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/exec/terminal-cmd.desktop b/tests_entries/exec/terminal-cmd.desktop new file mode 100644 index 0000000..70cf76a --- /dev/null +++ b/tests_entries/exec/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/exec/unmatched-quotes.desktop b/tests_entries/exec/unmatched-quotes.desktop new file mode 100644 index 0000000..6f8f6ef --- /dev/null +++ b/tests_entries/exec/unmatched-quotes.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Exec="alacritty -e +Terminal=false +Type=Application +Name=InvalidCommand \ No newline at end of file diff --git a/tests/org.kde.krita.desktop b/tests_entries/org.kde.krita.desktop similarity index 100% rename from tests/org.kde.krita.desktop rename to tests_entries/org.kde.krita.desktop diff --git a/tests/org.mozilla.firefox.desktop b/tests_entries/org.mozilla.firefox.desktop similarity index 100% rename from tests/org.mozilla.firefox.desktop rename to tests_entries/org.mozilla.firefox.desktop