freedesktop_desktop_entry/
exec.rs

1// Copyright 2021 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4use crate::DesktopEntry;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum ExecError {
9    #[error("{0}")]
10    WrongFormat(String),
11
12    #[error("Exec field is empty")]
13    ExecFieldIsEmpty,
14
15    #[error("Exec key was not found")]
16    ExecFieldNotFound,
17}
18
19impl DesktopEntry {
20    pub fn parse_exec(&self) -> Result<Vec<String>, ExecError> {
21        self.get_args(self.exec(), &[], &[] as &[&str])
22    }
23
24    /// 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.
25    pub fn parse_exec_with_uris<L>(
26        &self,
27        uris: &[&str],
28        locales: &[L],
29    ) -> Result<Vec<String>, ExecError>
30    where
31        L: AsRef<str>,
32    {
33        self.get_args(self.exec(), uris, locales)
34    }
35
36    pub fn parse_exec_action(&self, action_name: &str) -> Result<Vec<String>, ExecError> {
37        self.get_args(self.action_exec(action_name), &[], &[] as &[&str])
38    }
39
40    pub fn parse_exec_action_with_uris<L>(
41        &self,
42        action_name: &str,
43        uris: &[&str],
44        locales: &[L],
45    ) -> Result<Vec<String>, ExecError>
46    where
47        L: AsRef<str>,
48    {
49        self.get_args(self.action_exec(action_name), uris, locales)
50    }
51
52    fn get_args<L>(
53        &self,
54        exec: Option<&str>,
55        uris: &[&str],
56        locales: &[L],
57    ) -> Result<Vec<String>, ExecError>
58    where
59        L: AsRef<str>,
60    {
61        let Some(exec) = exec else {
62            return Err(ExecError::ExecFieldNotFound);
63        };
64
65        let exec = if let Some(without_prefix) = exec.strip_prefix('\"') {
66            without_prefix
67                .strip_suffix('\"')
68                .ok_or(ExecError::WrongFormat("unmatched quote".into()))?
69        } else {
70            exec
71        };
72
73        let mut args: Vec<String> = Vec::new();
74
75        for arg in exec.split_ascii_whitespace() {
76            match ArgOrFieldCode::try_from(arg) {
77                Ok(arg) => match arg {
78                    ArgOrFieldCode::SingleFileName | ArgOrFieldCode::SingleUrl => {
79                        if let Some(arg) = uris.first() {
80                            args.push(arg.to_string());
81                        }
82                    }
83                    ArgOrFieldCode::FileList | ArgOrFieldCode::UrlList => {
84                        uris.iter().for_each(|uri| args.push(uri.to_string()));
85                    }
86                    ArgOrFieldCode::IconKey => {
87                        if let Some(icon) = self.icon() {
88                            args.push(icon.to_string());
89                        }
90                    }
91                    ArgOrFieldCode::TranslatedName => {
92                        if let Some(name) = self.name(locales) {
93                            args.push(name.to_string());
94                        }
95                    }
96                    ArgOrFieldCode::DesktopFileLocation => {
97                        args.push(self.path.to_string_lossy().to_string());
98                    }
99                    ArgOrFieldCode::Arg(arg) => {
100                        args.push(arg.to_string());
101                    }
102                },
103                Err(e) => {
104                    log::error!("{}", e);
105                }
106            }
107        }
108
109        if args.is_empty() {
110            return Err(ExecError::ExecFieldIsEmpty);
111        }
112
113        if args.first().unwrap().contains('=') {
114            return Err(ExecError::WrongFormat("equal sign detected".into()));
115        }
116
117        Ok(args)
118    }
119}
120
121// either a command line argument or a field-code as described
122// in https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables
123enum ArgOrFieldCode<'a> {
124    SingleFileName,
125    FileList,
126    SingleUrl,
127    UrlList,
128    IconKey,
129    TranslatedName,
130    DesktopFileLocation,
131    Arg(&'a str),
132}
133
134#[derive(Debug, Error)]
135enum ExecErrorInternal<'a> {
136    #[error("Unknown field code: '{0}'")]
137    UnknownFieldCode(&'a str),
138
139    #[error("Deprecated field code: '{0}'")]
140    DeprecatedFieldCode(&'a str),
141}
142
143impl<'a> TryFrom<&'a str> for ArgOrFieldCode<'a> {
144    type Error = ExecErrorInternal<'a>;
145
146    // todo: handle escaping
147    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
148        match value {
149            "%f" => Ok(ArgOrFieldCode::SingleFileName),
150            "%F" => Ok(ArgOrFieldCode::FileList),
151            "%u" => Ok(ArgOrFieldCode::SingleUrl),
152            "%U" => Ok(ArgOrFieldCode::UrlList),
153            "%i" => Ok(ArgOrFieldCode::IconKey),
154            "%c" => Ok(ArgOrFieldCode::TranslatedName),
155            "%k" => Ok(ArgOrFieldCode::DesktopFileLocation),
156            "%d" | "%D" | "%n" | "%N" | "%v" | "%m" => {
157                Err(ExecErrorInternal::DeprecatedFieldCode(value))
158            }
159            other if other.starts_with('%') => Err(ExecErrorInternal::UnknownFieldCode(other)),
160            other => Ok(ArgOrFieldCode::Arg(other)),
161        }
162    }
163}
164
165#[cfg(test)]
166mod test {
167
168    use std::path::PathBuf;
169
170    use crate::{get_languages_from_env, DesktopEntry};
171
172    use super::ExecError;
173
174    #[test]
175    fn should_return_unmatched_quote_error() {
176        let path = PathBuf::from("tests_entries/exec/unmatched-quotes.desktop");
177        let locales = get_languages_from_env();
178        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
179        let result = de.parse_exec_with_uris(&[], &locales);
180
181        assert!(matches!(result.unwrap_err(), ExecError::WrongFormat(..)));
182    }
183
184    #[test]
185    fn should_fail_if_exec_string_is_empty() {
186        let path = PathBuf::from("tests_entries/exec/empty-exec.desktop");
187        let locales = get_languages_from_env();
188        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
189        let result = de.parse_exec_with_uris(&[], &locales);
190
191        assert!(matches!(result.unwrap_err(), ExecError::ExecFieldIsEmpty));
192    }
193
194    #[test]
195    fn should_exec_simple_command() {
196        let path = PathBuf::from("tests_entries/exec/alacritty-simple.desktop");
197        let locales = get_languages_from_env();
198        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
199        let result = de.parse_exec_with_uris(&[], &locales);
200
201        assert!(result.is_ok());
202    }
203
204    #[test]
205    fn should_exec_complex_command() {
206        let path = PathBuf::from("tests_entries/exec/non-terminal-cmd.desktop");
207        let locales = get_languages_from_env();
208        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
209        let result = de.parse_exec_with_uris(&[], &locales);
210
211        assert!(result.is_ok());
212    }
213
214    #[test]
215    fn should_exec_terminal_command() {
216        let path = PathBuf::from("tests_entries/exec/non-terminal-cmd.desktop");
217        let locales = get_languages_from_env();
218        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
219        let result = de.parse_exec_with_uris(&[], &locales);
220
221        assert!(result.is_ok());
222    }
223
224    #[test]
225    #[ignore = "Needs a desktop environment with nvim installed, run locally only"]
226    fn should_parse_exec_with_field_codes() {
227        let path = PathBuf::from("/usr/share/applications/nvim.desktop");
228        let locales = get_languages_from_env();
229        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
230        let result = de.parse_exec_with_uris(&["src/lib.rs"], &locales);
231
232        assert!(result.is_ok());
233    }
234
235    #[test]
236    #[ignore = "Needs a desktop environment with gnome Books installed, run locally only"]
237    fn should_parse_exec_with_dbus() {
238        let path = PathBuf::from("/usr/share/applications/org.gnome.Books.desktop");
239        let locales = get_languages_from_env();
240        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
241        let result = de.parse_exec_with_uris(&["src/lib.rs"], &locales);
242
243        assert!(result.is_ok());
244    }
245
246    #[test]
247    #[ignore = "Needs a desktop environment with Nautilus installed, run locally only"]
248    fn should_parse_exec_with_dbus_and_field_codes() {
249        let path = PathBuf::from("/usr/share/applications/org.gnome.Nautilus.desktop");
250        let locales = get_languages_from_env();
251        let de = DesktopEntry::from_path(path, Some(&locales)).unwrap();
252        let _result = de.parse_exec_with_uris(&[], &locales);
253        let path = std::env::current_dir().unwrap();
254        let path = path.to_string_lossy();
255        let path = format!("file:///{path}");
256        let result = de.parse_exec_with_uris(&[path.as_str()], &locales);
257
258        assert!(result.is_ok());
259    }
260}