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}