1use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct App {
31 pub id: String,
34 pub name: String,
36 pub icon: Option<String>,
38 pub path: PathBuf,
40 pub installed: bool,
43}
44
45pub 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 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 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
90fn 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
101pub fn parse_cache(text: &str) -> BTreeMap<String, Vec<String>> {
113 crate::defaults::parse_section(text, "[MIME Cache]")
114}
115
116pub fn on_path(program: &str) -> bool {
119 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
127pub 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 #[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 #[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 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 #[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}