use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct App {
pub id: String,
pub name: String,
pub icon: Option<String>,
pub path: PathBuf,
pub installed: bool,
}
pub fn parse_entry(path: &Path, text: &str, is_installed: &dyn Fn(&str) -> bool) -> Option<App> {
let mut name = None;
let mut icon = None;
let mut exec = None;
let mut hidden = false;
let mut in_entry = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_entry = line == "[Desktop Entry]";
continue;
}
if !in_entry {
continue;
}
match line.split_once('=') {
Some(("Name", value)) => name = Some(value.trim().to_string()),
Some(("Icon", value)) => icon = Some(value.trim().to_string()),
Some(("Exec", value)) => exec = Some(value.trim().to_string()),
Some(("NoDisplay" | "Hidden", value)) => hidden |= value.trim() == "true",
_ => {}
}
}
if hidden {
return None;
}
let program = exec.as_deref().and_then(first_word).unwrap_or_default();
Some(App {
id: path.file_name()?.to_string_lossy().into_owned(),
name: name?,
icon,
path: path.to_path_buf(),
installed: !program.is_empty() && is_installed(program),
})
}
fn first_word(exec: &str) -> Option<&str> {
let exec = exec.trim();
match exec.strip_prefix('"') {
Some(rest) => rest.split('"').next(),
None => exec.split_whitespace().next(),
}
.filter(|word| !word.is_empty())
}
pub fn parse_cache(text: &str) -> BTreeMap<String, Vec<String>> {
crate::defaults::parse_section(text, "[MIME Cache]")
}
pub fn on_path(program: &str) -> bool {
if program.contains('/') {
return Path::new(program).is_file();
}
let Some(path) = std::env::var_os("PATH") else { return false };
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
pub fn write_custom_entry(applications: &Path, command: &str) -> std::io::Result<(String, String)> {
let word = command
.split_whitespace()
.next()
.and_then(|first| first.rsplit('/').next())
.filter(|word| !word.is_empty())
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "that command names no program")
})?;
let safe: String = word.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect();
let stem = if safe.is_empty() { "command".to_string() } else { safe };
std::fs::create_dir_all(applications)?;
for attempt in 1..1000 {
let id = format!("{stem}-usercreated-{attempt}.desktop");
let path = applications.join(&id);
if path.exists() {
continue;
}
let entry = format!(
"[Desktop Entry]\nType=Application\nName={word}\nNoDisplay=true\nExec={command} %f\n"
);
hyprforge_paths::write_atomic(&path, &entry)?;
return Ok((id, word.to_string()));
}
Err(std::io::Error::other("too many entries already exist for that command"))
}
#[cfg(test)]
mod tests {
use super::*;
fn everything_installed(_: &str) -> bool {
true
}
fn entry(text: &str) -> Option<App> {
parse_entry(Path::new("/a/view3d.desktop"), text, &everything_installed)
}
#[test]
fn an_entry_reads_as_its_name_icon_and_identity() {
let app = entry("[Desktop Entry]\nName=3D Viewer\nIcon=view3d\nExec=view3d %f\n").unwrap();
assert_eq!(app.id, "view3d.desktop", "what mimeapps.list calls it");
assert_eq!(app.name, "3D Viewer");
assert_eq!(app.icon.as_deref(), Some("view3d"));
assert!(app.installed);
}
#[test]
fn an_entry_whose_program_is_missing_is_marked_not_installed() {
let app = parse_entry(
Path::new("/a/fstl.desktop"),
"[Desktop Entry]\nName=fstl\nExec=fstl %f\n",
&|program| program != "fstl",
)
.unwrap();
assert!(!app.installed);
assert_eq!(app.name, "fstl", "still named, so it can be said what is missing");
}
#[test]
fn only_the_desktop_entry_group_is_read() {
let app = entry(
"[Desktop Entry]\nName=Chrome\nExec=chrome %U\n\n\
[Desktop Action new-window]\nName=New Window\nExec=chrome --new-window\n",
)
.unwrap();
assert_eq!(app.name, "Chrome");
}
#[test]
fn an_entry_the_desktop_hides_is_not_offered() {
assert!(entry("[Desktop Entry]\nName=Hidden Helper\nExec=x\nNoDisplay=true\n").is_none());
assert!(entry("[Desktop Entry]\nName=Old\nExec=x\nHidden=true\n").is_none());
assert!(entry("[Desktop Entry]\nExec=x\n").is_none(), "no name, nothing to show");
}
#[test]
fn an_exec_path_with_a_space_keeps_its_whole_path() {
let app = entry("[Desktop Entry]\nName=X\nExec=\"/opt/my app/run\" %f\n").unwrap();
assert!(app.installed);
assert_eq!(first_word("\"/opt/my app/run\" %f"), Some("/opt/my app/run"));
assert_eq!(first_word("/usr/bin/chrome %U"), Some("/usr/bin/chrome"));
}
#[test]
fn the_cache_lists_every_application_that_registered_for_a_type() {
let cache = parse_cache(
"[MIME Cache]\n\
model/stl=BambuStudio.desktop;plasticity.desktop;view3d.desktop;\n\
image/png=firefox.desktop;\n",
);
assert_eq!(
cache.get("model/stl").unwrap(),
&["BambuStudio.desktop", "plasticity.desktop", "view3d.desktop"]
);
assert_eq!(cache.get("text/plain"), None);
}
#[test]
fn a_typed_command_becomes_an_entry_that_stays_out_of_menus() {
let dir = tempfile::tempdir().unwrap();
let (id, name) = write_custom_entry(dir.path(), "kate --new").unwrap();
assert_eq!(id, "kate-usercreated-1.desktop");
assert_eq!(name, "kate", "the same name the entry itself carries");
let text = std::fs::read_to_string(dir.path().join(&id)).unwrap();
assert!(text.contains("Exec=kate --new %f"), "{text}");
assert!(text.contains("NoDisplay=true"), "a one-off choice is not a menu entry");
let app = parse_entry(&dir.path().join(&id), &text, &everything_installed);
assert!(app.is_none(), "NoDisplay keeps it out of a chooser's own list");
}
#[test]
fn a_second_command_with_the_same_first_word_gets_its_own_entry() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(write_custom_entry(dir.path(), "kate a").unwrap().0, "kate-usercreated-1.desktop");
assert_eq!(write_custom_entry(dir.path(), "kate b").unwrap().0, "kate-usercreated-2.desktop");
}
#[test]
fn a_command_with_a_path_is_named_by_its_program() {
let dir = tempfile::tempdir().unwrap();
let (id, name) = write_custom_entry(dir.path(), "/usr/bin/zeditor --wait").unwrap();
assert_eq!(id, "zeditor-usercreated-1.desktop");
assert_eq!(name, "zeditor", "the path is stripped in both places, or neither");
assert!(std::fs::read_to_string(dir.path().join(&id)).unwrap().contains("Exec=/usr/bin/zeditor --wait %f"));
}
#[test]
fn an_empty_command_is_refused_rather_than_written() {
let dir = tempfile::tempdir().unwrap();
assert!(write_custom_entry(dir.path(), " ").is_err());
}
#[test]
fn a_program_is_found_on_the_path_and_an_absolute_one_by_itself() {
assert!(on_path("sh"), "every machine running these tests has a shell");
assert!(!on_path("definitely-not-a-program-xyz"));
assert!(on_path("/bin/sh") || on_path("/usr/bin/sh"));
assert!(!on_path("/nonexistent-xyz/sh"));
}
}