Skip to main content

hyprforge_mime/
apps.rs

1//! Which applications can open a type, and what they are called.
2//!
3//! Two data files, both plain text and both generated by the desktop:
4//! `mimeinfo.cache` next to the desktop entries says which entries
5//! registered for a type, and each entry says what it is called and what
6//! it runs.
7//!
8//! # What this refuses to do
9//!
10//! It does not *launch* anything, and it does not interpret `Exec=`
11//! beyond its first word. Field codes (`%f`, `%U`, `%c`), `TryExec`,
12//! `Terminal=true`, D-Bus activation — that is the whole desktop entry
13//! specification, and reimplementing it is how this suite would end up
14//! as a third opinion about which application opens a file, disagreeing
15//! with the rest of the desktop in ways nobody can see. Launching stays
16//! `gio launch`, with `xdg-open` behind it; see
17//! `hyprforge_files::launch`.
18//!
19//! The first word is read for one reason only: to answer "is this
20//! actually installed". An entry naming a program that is not there is
21//! the failure that started all this — `mimeapps.list` here pointed
22//! `.3mf` at fstl, which had been uninstalled, and a chooser that
23//! offered it would be offering a dead end.
24
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28/// One application, as the desktop describes it.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct App {
31    /// The desktop entry's file name, e.g. `view3d.desktop`. This is the
32    /// identity `mimeapps.list` uses, so it is what a default is set to.
33    pub id: String,
34    /// `Name=`, for showing a person.
35    pub name: String,
36    /// `Icon=`, when it has one.
37    pub icon: Option<String>,
38    /// Where the entry was found — what `gio launch` is handed.
39    pub path: PathBuf,
40    /// Whether the program `Exec=` names can actually be run. See this
41    /// module's doc for why this is the one thing `Exec=` is read for.
42    pub installed: bool,
43}
44
45/// Reads one desktop entry. `None` when it has no `Name=`, or when it
46/// asks not to be shown (`NoDisplay=true`, `Hidden=true`) — those are
47/// entries the desktop itself keeps out of menus, and a chooser is a
48/// menu.
49pub fn parse_entry(path: &Path, text: &str, is_installed: &dyn Fn(&str) -> bool) -> Option<App> {
50    let mut name = None;
51    let mut icon = None;
52    let mut exec = None;
53    let mut hidden = false;
54    // Only the `[Desktop Entry]` group. The `[Desktop Action ...]`
55    // groups below it have their own Name and Exec, and reading those
56    // would rename the application after one of its right-click actions.
57    let mut in_entry = false;
58    for line in text.lines() {
59        let line = line.trim();
60        if line.starts_with('[') {
61            in_entry = line == "[Desktop Entry]";
62            continue;
63        }
64        if !in_entry {
65            continue;
66        }
67        match line.split_once('=') {
68            // `Name[de]=` is a translation; the plain key is the one
69            // this asks for, since nothing here knows the user's locale.
70            Some(("Name", value)) => name = Some(value.trim().to_string()),
71            Some(("Icon", value)) => icon = Some(value.trim().to_string()),
72            Some(("Exec", value)) => exec = Some(value.trim().to_string()),
73            Some(("NoDisplay" | "Hidden", value)) => hidden |= value.trim() == "true",
74            _ => {}
75        }
76    }
77    if hidden {
78        return None;
79    }
80    let program = exec.as_deref().and_then(first_word).unwrap_or_default();
81    Some(App {
82        id: path.file_name()?.to_string_lossy().into_owned(),
83        name: name?,
84        icon,
85        path: path.to_path_buf(),
86        installed: !program.is_empty() && is_installed(program),
87    })
88}
89
90/// The program an `Exec=` line runs, before its arguments. Quoted
91/// because a path with a space in it is allowed to be.
92fn first_word(exec: &str) -> Option<&str> {
93    let exec = exec.trim();
94    match exec.strip_prefix('"') {
95        Some(rest) => rest.split('"').next(),
96        None => exec.split_whitespace().next(),
97    }
98    .filter(|word| !word.is_empty())
99}
100
101/// `mimeinfo.cache`: which entries registered for each type.
102///
103/// This is registration, not choice — every application that says it can
104/// open PNGs is in here, which is why a chooser shows several and why
105/// the *default* is a separate question (see [`crate::defaults`]).
106///
107/// The file has the same shape as `mimeapps.list` — a `[section]` of
108/// `type=one.desktop;two.desktop;` lines — so it is read by the same
109/// scanner. They were two near-copies that disagreed about whether a
110/// repeated key replaces or accumulates, which is the disagreement
111/// CLAUDE.md's rule about a last-one-wins format is about.
112pub fn parse_cache(text: &str) -> BTreeMap<String, Vec<String>> {
113    crate::defaults::parse_section(text, "[MIME Cache]")
114}
115
116/// Whether a program can be run, by walking `PATH`. The real
117/// `is_installed` behind [`parse_entry`].
118pub fn on_path(program: &str) -> bool {
119    // An absolute Exec (`/usr/bin/google-chrome-stable`) names itself.
120    if program.contains('/') {
121        return Path::new(program).is_file();
122    }
123    let Some(path) = std::env::var_os("PATH") else { return false };
124    std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
125}
126
127/// Writes a desktop entry for a command a person typed, and hands back
128/// the entry's file name.
129///
130/// This is the "Other…" answer in a chooser: none of the applications
131/// offered, run *this* instead. The entry goes in the user's own
132/// applications directory, marked `NoDisplay=true` so it stays out of
133/// menus — it is a record of one person's one-off choice, not an
134/// application anybody installed.
135///
136/// The name follows the convention every other implementation uses
137/// (`word-usercreated-1.desktop`), counting up rather than overwriting,
138/// so two different commands beginning with the same word do not
139/// quietly become one.
140///
141/// Returns the entry's file name and the name written inside it. Both,
142/// because a caller needs to show one and record the other, and working
143/// the name out a second time is how they came to disagree: the entry
144/// on disk said `zeditor` while the caller's copy said
145/// `/usr/bin/zeditor`.
146///
147/// `command` is written into `Exec=` as given. Nothing here parses it —
148/// see this module's doc — which does mean a command that is nonsense
149/// produces an entry that fails to launch. That is visible immediately
150/// and recoverable by choosing again; guessing at what somebody meant
151/// would not be.
152pub fn write_custom_entry(applications: &Path, command: &str) -> std::io::Result<(String, String)> {
153    let word = command
154        .split_whitespace()
155        .next()
156        .and_then(|first| first.rsplit('/').next())
157        .filter(|word| !word.is_empty())
158        .ok_or_else(|| {
159            std::io::Error::new(std::io::ErrorKind::InvalidInput, "that command names no program")
160        })?;
161    let safe: String = word.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect();
162    let stem = if safe.is_empty() { "command".to_string() } else { safe };
163
164    std::fs::create_dir_all(applications)?;
165    for attempt in 1..1000 {
166        let id = format!("{stem}-usercreated-{attempt}.desktop");
167        let path = applications.join(&id);
168        if path.exists() {
169            continue;
170        }
171        let entry = format!(
172            "[Desktop Entry]\nType=Application\nName={word}\nNoDisplay=true\nExec={command} %f\n"
173        );
174        hyprforge_paths::write_atomic(&path, &entry)?;
175        return Ok((id, word.to_string()));
176    }
177    Err(std::io::Error::other("too many entries already exist for that command"))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn everything_installed(_: &str) -> bool {
185        true
186    }
187
188    fn entry(text: &str) -> Option<App> {
189        parse_entry(Path::new("/a/view3d.desktop"), text, &everything_installed)
190    }
191
192    #[test]
193    fn an_entry_reads_as_its_name_icon_and_identity() {
194        let app = entry("[Desktop Entry]\nName=3D Viewer\nIcon=view3d\nExec=view3d %f\n").unwrap();
195        assert_eq!(app.id, "view3d.desktop", "what mimeapps.list calls it");
196        assert_eq!(app.name, "3D Viewer");
197        assert_eq!(app.icon.as_deref(), Some("view3d"));
198        assert!(app.installed);
199    }
200
201    /// The fstl case: the entry is fine, the program is gone. A chooser
202    /// that offered it would be offering a dead end.
203    #[test]
204    fn an_entry_whose_program_is_missing_is_marked_not_installed() {
205        let app = parse_entry(
206            Path::new("/a/fstl.desktop"),
207            "[Desktop Entry]\nName=fstl\nExec=fstl %f\n",
208            &|program| program != "fstl",
209        )
210        .unwrap();
211        assert!(!app.installed);
212        assert_eq!(app.name, "fstl", "still named, so it can be said what is missing");
213    }
214
215    /// A desktop entry's action groups have their own Name and Exec.
216    /// Reading those would rename the application after one of its
217    /// right-click actions.
218    #[test]
219    fn only_the_desktop_entry_group_is_read() {
220        let app = entry(
221            "[Desktop Entry]\nName=Chrome\nExec=chrome %U\n\n\
222             [Desktop Action new-window]\nName=New Window\nExec=chrome --new-window\n",
223        )
224        .unwrap();
225        assert_eq!(app.name, "Chrome");
226    }
227
228    #[test]
229    fn an_entry_the_desktop_hides_is_not_offered() {
230        assert!(entry("[Desktop Entry]\nName=Hidden Helper\nExec=x\nNoDisplay=true\n").is_none());
231        assert!(entry("[Desktop Entry]\nName=Old\nExec=x\nHidden=true\n").is_none());
232        assert!(entry("[Desktop Entry]\nExec=x\n").is_none(), "no name, nothing to show");
233    }
234
235    #[test]
236    fn an_exec_path_with_a_space_keeps_its_whole_path() {
237        let app = entry("[Desktop Entry]\nName=X\nExec=\"/opt/my app/run\" %f\n").unwrap();
238        assert!(app.installed);
239        assert_eq!(first_word("\"/opt/my app/run\" %f"), Some("/opt/my app/run"));
240        assert_eq!(first_word("/usr/bin/chrome %U"), Some("/usr/bin/chrome"));
241    }
242
243    #[test]
244    fn the_cache_lists_every_application_that_registered_for_a_type() {
245        let cache = parse_cache(
246            "[MIME Cache]\n\
247             model/stl=BambuStudio.desktop;plasticity.desktop;view3d.desktop;\n\
248             image/png=firefox.desktop;\n",
249        );
250        assert_eq!(
251            cache.get("model/stl").unwrap(),
252            &["BambuStudio.desktop", "plasticity.desktop", "view3d.desktop"]
253        );
254        assert_eq!(cache.get("text/plain"), None);
255    }
256
257    #[test]
258    fn a_typed_command_becomes_an_entry_that_stays_out_of_menus() {
259        let dir = tempfile::tempdir().unwrap();
260        let (id, name) = write_custom_entry(dir.path(), "kate --new").unwrap();
261        assert_eq!(id, "kate-usercreated-1.desktop");
262        assert_eq!(name, "kate", "the same name the entry itself carries");
263        let text = std::fs::read_to_string(dir.path().join(&id)).unwrap();
264        assert!(text.contains("Exec=kate --new %f"), "{text}");
265        assert!(text.contains("NoDisplay=true"), "a one-off choice is not a menu entry");
266
267        // And it reads back as an application, which is the whole point.
268        let app = parse_entry(&dir.path().join(&id), &text, &everything_installed);
269        assert!(app.is_none(), "NoDisplay keeps it out of a chooser's own list");
270    }
271
272    /// Two different commands starting with the same word must not
273    /// become one entry.
274    #[test]
275    fn a_second_command_with_the_same_first_word_gets_its_own_entry() {
276        let dir = tempfile::tempdir().unwrap();
277        assert_eq!(write_custom_entry(dir.path(), "kate a").unwrap().0, "kate-usercreated-1.desktop");
278        assert_eq!(write_custom_entry(dir.path(), "kate b").unwrap().0, "kate-usercreated-2.desktop");
279    }
280
281    #[test]
282    fn a_command_with_a_path_is_named_by_its_program() {
283        let dir = tempfile::tempdir().unwrap();
284        let (id, name) = write_custom_entry(dir.path(), "/usr/bin/zeditor --wait").unwrap();
285        assert_eq!(id, "zeditor-usercreated-1.desktop");
286        assert_eq!(name, "zeditor", "the path is stripped in both places, or neither");
287        assert!(std::fs::read_to_string(dir.path().join(&id)).unwrap().contains("Exec=/usr/bin/zeditor --wait %f"));
288    }
289
290    #[test]
291    fn an_empty_command_is_refused_rather_than_written() {
292        let dir = tempfile::tempdir().unwrap();
293        assert!(write_custom_entry(dir.path(), "   ").is_err());
294    }
295
296    #[test]
297    fn a_program_is_found_on_the_path_and_an_absolute_one_by_itself() {
298        assert!(on_path("sh"), "every machine running these tests has a shell");
299        assert!(!on_path("definitely-not-a-program-xyz"));
300        assert!(on_path("/bin/sh") || on_path("/usr/bin/sh"));
301        assert!(!on_path("/nonexistent-xyz/sh"));
302    }
303}