xdg_utils/
lib.rs

1/* xdg-utils library
2 *
3 * Copyright 2019-2020 Manos Pitsidianakis
4 *
5 * xdg-utils is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * xdg-utils is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with xdg-utils. If not, see <http://www.gnu.org/licenses/>.
17 */
18
19//! Query system for default apps using XDG MIME databases.
20//!
21//! The xdg-utils library provides dependency-free (except for `std`) Rust implementations of some
22//! common functions in the freedesktop project `xdg-utils`.
23//!
24//! # What is implemented?
25//! * Function <a class="fn" href="fn.query_default_app.html" title="xdg_utils::query_default_app fn">query_default_app</a> performs like the xdg-utils function `binary_to_desktop_file`
26//! * Function <a class="fn" href="fn.query_mime_info.html" title="xdg_utils::query_mime_info fn">query_mime_info</a> launches the `mimetype` or else the `file` command.
27//!
28//! Some of the utils may be implemented by combining these functions with other functions in the Rust
29//! standard library.
30//!
31//! | Name            | Function                                               | Implemented functionalities|
32//! |-----------------|--------------------------------------------------------|----------------------------|
33//! |`xdg-desktop-menu`| Install desktop menu items                             | no
34//! |`xdg-desktop-icon`| Install icons to the desktop                           | no
35//! |`xdg-icon-resource`| Install icon resources                                 | no
36//! |`xdg-mime`        | Query information about file type handling and install descriptions for new file types| queries only
37//! |`xdg-open`        | Open a file or URL in the user's preferred application | all (combine crate functions with `std::process::Command`)
38//! |`xdg-email`       | Send mail using the user's preferred e-mail composer   | no
39//! |`xdg-screensaver` | Control the screensaver                                | no
40//!
41//! # Specification
42//! <https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-latest.html>
43//!
44//! # Reference implementation
45//! <https://cgit.freedesktop.org/xdg/xdg-utils/tree/scripts/xdg-utils-common.in>
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    #[test]
51    fn it_works() {
52        /* Run with `cargo test -- --nocapture` to see output. */
53        println!("{:?}", query_default_app("image/jpeg"));
54        println!("{:?}", query_default_app("text/html"));
55        println!("{:?}", query_default_app("video/mp4"));
56        println!("{:?}", query_default_app("application/pdf"));
57    }
58
59    #[test]
60    fn ini() {
61        let ini = Ini(String::from("[foo]\n# comment\nbar=baz\n\n[bar]\nbar=foo"));
62        for (key, value) in ini.iter_section("foo") {
63            assert_eq!(key, "bar");
64            assert_eq!(value, "baz");
65        }
66    }
67}
68
69use std::collections::HashMap;
70use std::env;
71use std::fs;
72use std::fs::File;
73use std::io::{Error, ErrorKind, Read, Result};
74use std::path::{Path, PathBuf};
75use std::process::{Command, Stdio};
76use std::str;
77
78macro_rules! split_and_chain {
79    ($xdg_vars:ident[$key:literal]) => {
80        $xdg_vars.get($key).map(String::as_str).unwrap_or("").split(':')
81    };
82    ($xdg_vars:ident[$key:literal], $($tail_xdg_vars:ident[$tail_key:literal]),+$(,)*) => {
83
84        split_and_chain!($xdg_vars[$key]).chain(split_and_chain!($($tail_xdg_vars[$tail_key]),+))
85    }
86}
87
88struct Ini(String);
89
90impl Ini {
91    fn from_filename(filename: &Path) -> Result<Ini> {
92        let mut file: File = File::open(filename)?;
93
94        let mut contents: Vec<u8> = vec![];
95        file.read_to_end(&mut contents)?;
96
97        let contents_str =
98            String::from_utf8(contents).map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
99        Ok(Ini(contents_str))
100    }
101
102    fn iter_section(&self, section: &str) -> impl Iterator<Item = (&str, &str)> {
103        let section = format!("[{}]", section);
104        let mut lines = self.0.lines();
105
106        // Eat lines until we find the beginning of our section.
107        loop {
108            let line = lines.next();
109            if let Some(line) = line {
110                if line == section {
111                    break;
112                }
113            } else {
114                break;
115            }
116        }
117
118        // Then take all foo=bar lines until the next section.
119        lines
120            .filter(|line| !line.starts_with('#'))
121            .take_while(|line| !line.starts_with('['))
122            .filter_map(|line| {
123                let split: Vec<_> = line.splitn(2, '=').collect();
124                if split.len() != 2 {
125                    None
126                } else {
127                    Some((split[0], split[1]))
128                }
129            })
130    }
131}
132
133/// Returns the command string of the desktop file that is the default application of given MIME type `query`
134///
135/// # Example
136/// ```no_run
137/// use xdg_utils::query_default_app;
138///
139/// // The crate author recommends firefox.
140/// assert_eq!(Ok("firefox".into()), query_default_app("text/html").map_err(|_| ()));
141/// ```
142pub fn query_default_app<T: AsRef<str>>(query: T) -> Result<String> {
143    // Values are directory paths separated by : in case it's more than one.
144    let mut xdg_vars: HashMap<String, String> = HashMap::new();
145    let env_vars: env::Vars = env::vars();
146
147    for (k, v) in env_vars {
148        if k.starts_with("XDG_CONFIG")
149            || k.starts_with("XDG_DATA")
150            || k.starts_with("XDG_CURRENT_DESKTOP")
151            || k == "HOME"
152        {
153            xdg_vars.insert(k.to_string(), v.to_string());
154        }
155    }
156
157    // Insert defaults if variables are missing
158    if xdg_vars.contains_key("HOME") && !xdg_vars.contains_key("XDG_DATA_HOME") {
159        let h = xdg_vars["HOME"].clone();
160        xdg_vars.insert("XDG_DATA_HOME".to_string(), format!("{}/.local/share", h));
161    }
162
163    if xdg_vars.contains_key("HOME") && !xdg_vars.contains_key("XDG_CONFIG_HOME") {
164        let h = xdg_vars["HOME"].clone();
165        xdg_vars.insert("XDG_CONFIG_HOME".to_string(), format!("{}/.config", h));
166    }
167
168    if !xdg_vars.contains_key("XDG_DATA_DIRS") {
169        xdg_vars.insert(
170            "XDG_DATA_DIRS".to_string(),
171            "/usr/local/share:/usr/share".to_string(),
172        );
173    }
174
175    if !xdg_vars.contains_key("XDG_CONFIG_DIRS") {
176        xdg_vars.insert("XDG_CONFIG_DIRS".to_string(), "/etc/xdg".to_string());
177    }
178
179    let desktops: Option<Vec<String>> = if xdg_vars.contains_key("XDG_CURRENT_DESKTOP") {
180        let list = xdg_vars["XDG_CURRENT_DESKTOP"]
181            .trim()
182            .split(':')
183            .map(str::to_ascii_lowercase)
184            .collect();
185        Some(list)
186    } else {
187        None
188    };
189
190    // Search for mime entry in files.
191    for p in split_and_chain!(
192        xdg_vars["XDG_CONFIG_HOME"],
193        xdg_vars["XDG_CONFIG_DIRS"],
194        xdg_vars["XDG_DATA_HOME"],
195        xdg_vars["XDG_DATA_DIRS"],
196    ) {
197        if let Some(ref d) = desktops {
198            for desktop in d {
199                let pb: PathBuf = PathBuf::from(format!(
200                    "{var_value}/{desktop_val}-mimeapps.list",
201                    var_value = p,
202                    desktop_val = desktop
203                ));
204                if pb.exists() {
205                    if let Some(ret) = check_mimeapps_list(&pb, &xdg_vars, &query)? {
206                        return Ok(ret);
207                    }
208                }
209            }
210        }
211        let pb: PathBuf = PathBuf::from(format!("{var_value}/mimeapps.list", var_value = p));
212        if pb.exists() {
213            if let Some(ret) = check_mimeapps_list(&pb, &xdg_vars, &query)? {
214                return Ok(ret);
215            }
216        }
217    }
218
219    // Search again but for different paths.
220    for p in split_and_chain!(xdg_vars["XDG_DATA_HOME"], xdg_vars["XDG_DATA_DIRS"]) {
221        if let Some(ref d) = desktops {
222            for desktop in d {
223                let pb: PathBuf = PathBuf::from(format!(
224                    "{var_value}/applications/{desktop_val}-mimeapps.list",
225                    var_value = p,
226                    desktop_val = desktop
227                ));
228                if pb.exists() {
229                    if let Some(ret) = check_mimeapps_list(&pb, &xdg_vars, &query)? {
230                        return Ok(ret);
231                    }
232                }
233            }
234        }
235        let pb: PathBuf = PathBuf::from(format!(
236            "{var_value}/applications/mimeapps.list",
237            var_value = p
238        ));
239        if pb.exists() {
240            if let Some(ret) = check_mimeapps_list(&pb, &xdg_vars, &query)? {
241                return Ok(ret);
242            }
243        }
244    }
245
246    Err(Error::new(
247        ErrorKind::NotFound,
248        format!("No results for mime query: {}", query.as_ref()),
249    ))
250}
251
252fn check_mimeapps_list<T: AsRef<str>>(
253    filename: &Path,
254    xdg_vars: &HashMap<String, String>,
255    query: T,
256) -> Result<Option<String>> {
257    let ini = Ini::from_filename(filename)?;
258    for (key, value) in ini
259        .iter_section("Added Associations")
260        .chain(ini.iter_section("Default Applications"))
261    {
262        if key != query.as_ref() {
263            continue;
264        }
265        for v in value.split(';') {
266            if v.trim().is_empty() {
267                continue;
268            }
269
270            if let Some(b) = desktop_file_to_command(v, xdg_vars)? {
271                return Ok(Some(b));
272            }
273        }
274    }
275
276    Ok(None)
277}
278
279// Find the desktop file in the filesystem, then find the binary it uses from its "Exec=..." line
280// entry.
281fn desktop_file_to_command(
282    desktop_name: &str,
283    xdg_vars: &HashMap<String, String>,
284) -> Result<Option<String>> {
285    for dir in split_and_chain!(xdg_vars["XDG_DATA_HOME"], xdg_vars["XDG_DATA_DIRS"]) {
286        let mut file_path: Option<PathBuf> = None;
287        let mut p;
288        if desktop_name.contains('-') {
289            let v: Vec<&str> = desktop_name.split('-').collect();
290            let (vendor, app): (&str, &str) = (v[0], v[1]);
291
292            p = PathBuf::from(format!(
293                "{dir}/applications/{vendor}/{app}",
294                dir = dir,
295                vendor = vendor,
296                app = app
297            ));
298            if p.exists() {
299                file_path = Some(p);
300            }
301        }
302
303        if file_path.is_none() {
304            'indir: for indir in &[format!("{}/applications", dir)] {
305                p = PathBuf::from(format!(
306                    "{indir}/{desktop}",
307                    indir = indir,
308                    desktop = desktop_name
309                ));
310                if p.exists() {
311                    file_path = Some(p);
312                    break 'indir;
313                }
314                p.pop(); // Remove {desktop} from path.
315                if p.is_dir() {
316                    for entry in fs::read_dir(&p)? {
317                        let mut p = entry?.path().to_owned();
318                        p.push(desktop_name);
319                        if p.exists() {
320                            file_path = Some(p);
321                            break 'indir;
322                        }
323                    }
324                }
325            }
326        }
327        if let Some(file_path) = file_path {
328            let ini = Ini::from_filename(&file_path)?;
329            for (key, value) in ini.iter_section("Desktop Entry") {
330                if key != "Exec" {
331                    continue;
332                }
333                return Ok(Some(String::from(value)));
334            }
335        }
336    }
337
338    Ok(None)
339}
340
341/// Returns the MIME type of given file
342/// https://cgit.freedesktop.org/xdg/xdg-utils/tree/scripts/xdg-mime.in
343///
344/// # Example
345/// ```
346/// use xdg_utils::query_mime_info;
347/// let result = query_mime_info("/bin/sh")
348///                 .map_err(|_| ())
349///                 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
350/// let result_str = result.as_ref().map(|s| s.as_str());
351/// assert!(Ok("application/x-pie-executable") == result_str || Ok("application/x-sharedlib") == result_str)
352/// ```
353pub fn query_mime_info<T: AsRef<Path>>(query: T) -> Result<Vec<u8>> {
354    let command_obj = Command::new("mimetype")
355        .args(&["--brief", "--dereference"])
356        .arg(query.as_ref())
357        .stdin(Stdio::piped())
358        .stdout(Stdio::piped())
359        .spawn()
360        .or_else(|_| {
361            Command::new("file")
362                .args(&["--brief", "--dereference", "--mime-type"])
363                .arg(query.as_ref())
364                .stdin(Stdio::piped())
365                .stdout(Stdio::piped())
366                .spawn()
367        })?;
368
369    Ok(drop_right_whitespace(
370        command_obj.wait_with_output()?.stdout,
371    ))
372}
373
374#[inline(always)]
375fn drop_right_whitespace(mut vec: Vec<u8>) -> Vec<u8> {
376    while vec.last() == Some(&b'\n') {
377        vec.pop();
378    }
379    vec
380}