Skip to main content

qframe/desktop/
mod.rs

1//! Which programs open a file, read from the desktop's own databases, and starting one of them.
2//!
3//! The desktop already knows what every file is and which programs open it: shared-mime-info says
4//! what a file is, the desktop entries say which programs take which kinds, and `mimeapps.list`
5//! holds the choices a person made in any other file manager. Reading those instead of keeping a
6//! table of our own means a file opens here with the program it opens with everywhere else.
7//!
8//! - [`XdgDirs`] names the folders: [`XdgDirs::from_env`] with the process's variables in an
9//!   application, a fake tree in a test, so no test ever reads the machine's own databases.
10//! - [`MimeDb`] tells what a file is: from its name ([`MimeDb::guess`]), from its first 4 KiB when
11//!   the name says nothing ([`MimeDb::sniff`]), and what else it is a case of
12//!   ([`MimeDb::ancestors`]: Rust source is plain text), and what it is called in words in the
13//!   person's language ([`MimeDb::comment`]: "Rust source code").
14//! - [`Apps`] lists the programs for a kind and the one to use; [`Openers::for_file`] answers both
15//!   for one file as [`Choices`].
16//! - [`DesktopApp::command`] gives the command line, never handed to a shell;
17//!   [`DesktopApp::launch`] starts it the right way for its kind: a terminal program through a
18//!   [`Handoff`](crate::runtime::Handoff), a graphical one through
19//!   [`Open::program`](crate::runtime::Open::program).
20//!
21//! Reading never panics: a missing file is normal, and a line or a file that cannot be used is
22//! skipped with a [`Diagnostic`] that says where it is. Nothing is ever written: the person's
23//! defaults are their desktop's setting.
24//!
25//! Not read, on purpose: the binary `magic` rules of shared-mime-info (a name no pattern knows is
26//! told apart only as text or bytes). Of its XML descriptions only the `<comment>` lines are
27//! read, line by line in the shape `update-mime-database` writes them, with no XML parser.
28
29mod apps;
30mod comment;
31mod exec;
32mod keyfile;
33mod launch;
34mod mime;
35mod program;
36
37#[cfg(test)]
38mod fixture;
39#[cfg(test)]
40mod tests;
41
42use std::ffi::OsStr;
43use std::fs;
44use std::io::ErrorKind;
45use std::path::{Path, PathBuf};
46
47pub use apps::{Apps, DesktopApp};
48pub use launch::{LaunchError, Launched, graphical_session};
49pub use mime::MimeDb;
50
51use crate::diagnostics::{Diagnostic, Location};
52
53/// The folders the desktop's databases are read from, and the desktops the person is running.
54///
55/// Folders come in the order they are searched: the person's own before the system's, since what a
56/// person installs or changes for themselves overrides what came with the system.
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
58pub struct XdgDirs {
59    /// The person's own data folder (`XDG_DATA_HOME`), searched first.
60    pub data_home: Option<PathBuf>,
61    /// The system's data folders (`XDG_DATA_DIRS`), most important first.
62    pub data_dirs: Vec<PathBuf>,
63    /// The person's own configuration folder (`XDG_CONFIG_HOME`), searched first.
64    pub config_home: Option<PathBuf>,
65    /// The system's configuration folders (`XDG_CONFIG_DIRS`), most important first.
66    pub config_dirs: Vec<PathBuf>,
67    /// The desktops the person is running (`XDG_CURRENT_DESKTOP`), lowercased, most specific
68    /// first. A desktop may keep its own `mimeapps.list` beside the shared one.
69    pub desktops: Vec<String>,
70}
71
72impl XdgDirs {
73    /// The folders named by the environment, with the defaults the base directory specification
74    /// gives for anything unset.
75    ///
76    /// `lookup` returns a variable's value: `XDG_DATA_HOME` (default `$HOME/.local/share`),
77    /// `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`), `XDG_CONFIG_HOME` (default
78    /// `$HOME/.config`), `XDG_CONFIG_DIRS` (default `/etc/xdg`) and `XDG_CURRENT_DESKTOP` (a
79    /// colon-separated list). An empty value counts as unset. A relative folder is ignored, as the
80    /// specification asks: it would mean something different in every folder the program is
81    /// started from.
82    pub fn from_env(lookup: impl Fn(&str) -> Option<String>) -> Self {
83        let var = |name: &str| lookup(name).filter(|value| !value.is_empty());
84        let home = var("HOME").map(PathBuf::from).filter(|path| path.is_absolute());
85        let own = |name: &str, below_home: &str| {
86            var(name)
87                .map(PathBuf::from)
88                .filter(|path| path.is_absolute())
89                .or_else(|| home.as_ref().map(|home| home.join(below_home)))
90        };
91        let system = |name: &str, default: &[&str]| {
92            let named: Vec<PathBuf> = var(name).map(|value| absolute_folders(&value)).unwrap_or_default();
93            if named.is_empty() { default.iter().map(PathBuf::from).collect() } else { named }
94        };
95        Self {
96            data_home: own("XDG_DATA_HOME", ".local/share"),
97            data_dirs: system("XDG_DATA_DIRS", &["/usr/local/share", "/usr/share"]),
98            config_home: own("XDG_CONFIG_HOME", ".config"),
99            config_dirs: system("XDG_CONFIG_DIRS", &["/etc/xdg"]),
100            desktops: var("XDG_CURRENT_DESKTOP")
101                .map(|value| value.split(':').filter(|name| !name.is_empty()).map(str::to_lowercase).collect())
102                .unwrap_or_default(),
103        }
104    }
105
106    /// The data folders, the person's own first.
107    fn data(&self) -> impl Iterator<Item = &PathBuf> {
108        self.data_home.iter().chain(&self.data_dirs)
109    }
110
111    /// The configuration folders, the person's own first.
112    fn config(&self) -> impl Iterator<Item = &PathBuf> {
113        self.config_home.iter().chain(&self.config_dirs)
114    }
115}
116
117/// The absolute folders in a colon-separated list.
118fn absolute_folders(list: &str) -> Vec<PathBuf> {
119    list.split(':').map(PathBuf::from).filter(|path| path.is_absolute()).collect()
120}
121
122/// Everything needed to tell which programs open a file, read once and asked many times.
123#[derive(Debug, Clone)]
124pub struct Openers {
125    /// What kind each file is.
126    pub mime: MimeDb,
127    /// The programs and the associations between kinds and programs.
128    pub apps: Apps,
129}
130
131impl Openers {
132    /// Reads the kinds and the programs from the given folders.
133    ///
134    /// `lang` is the person's language as in `LANG` (`tr_TR.UTF-8`), used for program names;
135    /// `path_var` is the program search path as in `PATH`, used to drop programs that are not
136    /// installed.
137    #[must_use]
138    pub fn load(dirs: &XdgDirs, lang: &str, path_var: Option<&OsStr>) -> Self {
139        Self { mime: MimeDb::load(dirs), apps: Apps::load(dirs, lang, path_var) }
140    }
141
142    /// Every problem found while reading, the kinds' files first.
143    #[must_use]
144    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
145        self.mime.diagnostics().iter().chain(self.apps.diagnostics()).collect()
146    }
147
148    /// The kind of the file at `path` and the programs that open it.
149    #[must_use]
150    pub fn for_file(&self, path: &Path) -> Choices {
151        let mime = self.mime.canonical(&self.mime.sniff(path));
152        let apps: Vec<DesktopApp> = self.apps.for_mime(&self.mime, &mime).into_iter().cloned().collect();
153        let default =
154            self.apps.default_for(&self.mime, &mime).and_then(|chosen| apps.iter().position(|app| app.id == chosen.id));
155        Choices { mime, apps, default }
156    }
157}
158
159/// The programs that open one file.
160#[derive(Debug, Clone, PartialEq)]
161pub struct Choices {
162    /// The file's kind, as a MIME type (`text/x-rust`).
163    pub mime: String,
164    /// The programs that open it, the most fitting first.
165    pub apps: Vec<DesktopApp>,
166    /// Which of [`apps`](Self::apps) opens the file when nothing else is asked for; `None` only
167    /// when there is no program at all.
168    pub default: Option<usize>,
169}
170
171/// The largest database file read. The real ones are a few hundred KiB at most; the limit keeps a
172/// stray huge file from being pulled whole into memory.
173const LARGEST_FILE: u64 = 16 << 20;
174
175/// The bytes of a small regular file, or `None` when there is none to read.
176///
177/// A missing file is normal and says nothing. Anything but a regular file is refused before it is
178/// opened, since reading a named pipe that happens to carry a database's name would wait forever;
179/// that, a file too large and one that cannot be read are reported.
180fn read_small(path: &Path, diagnostics: &mut Vec<Diagnostic>) -> Option<Vec<u8>> {
181    let meta = match fs::metadata(path) {
182        Ok(meta) => meta,
183        Err(error) if error.kind() == ErrorKind::NotFound => return None,
184        Err(error) => {
185            diagnostics.push(Diagnostic::warning(None, format!("{}: {error}", path.display())));
186            return None;
187        }
188    };
189    let problem = if !meta.is_file() {
190        "not a regular file; it is not read"
191    } else if meta.len() > LARGEST_FILE {
192        "larger than 16 MiB; it is not read"
193    } else {
194        return match fs::read(path) {
195            Ok(bytes) => Some(bytes),
196            Err(error) => {
197                diagnostics.push(Diagnostic::warning(None, format!("{}: {error}", path.display())));
198                None
199            }
200        };
201    };
202    diagnostics.push(Diagnostic::warning(None, format!("{}: {problem}", path.display())));
203    None
204}
205
206/// The lines of a text file with their numbers, starting at 1. A line that is not valid UTF-8 is
207/// reported and skipped, so one broken line never costs the rest of the file.
208fn lines<'a>(bytes: &'a [u8], path: &Path, diagnostics: &mut Vec<Diagnostic>) -> Vec<(usize, &'a str)> {
209    let mut out = Vec::new();
210    for (index, line) in bytes.split(|&byte| byte == b'\n').enumerate() {
211        match std::str::from_utf8(line) {
212            Ok(line) => out.push((index + 1, line.strip_suffix('\r').unwrap_or(line))),
213            Err(_) => warn(diagnostics, path, index + 1, "the line is not UTF-8; it is skipped"),
214        }
215    }
216    out
217}
218
219/// Reports a problem on line `line` of `path`.
220fn warn(diagnostics: &mut Vec<Diagnostic>, path: &Path, line: usize, message: &str) {
221    let location = Location { file: path.display().to_string(), line, column: 1 };
222    diagnostics.push(Diagnostic::warning(Some(location), message));
223}