use std::collections::{BinaryHeap, HashSet};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub const APPLICATION_LIMIT: usize = 400;
const DESKTOP_LINE_LIMIT: usize = 2_048;
const DESKTOP_BYTES_LIMIT: u64 = 256 * 1024;
const DISCOVERY_DEPTH_LIMIT: usize = 8;
const DISCOVERY_ENTRY_LIMIT: usize = APPLICATION_LIMIT * 16;
const DIRECTORY_ENTRY_LIMIT: usize = APPLICATION_LIMIT * 8;
const EXTENSION_KEY_MAX_LEN: usize = 24;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DesktopApp {
pub id: String,
pub name: String,
pub exec: String,
pub mimes: Vec<String>,
pub icon: Option<String>,
pub working_dir: Option<PathBuf>,
pub terminal: bool,
pub desktop_file: Option<PathBuf>,
}
pub fn application_dirs() -> Vec<PathBuf> {
let mut roots = Vec::new();
let data_home = std::env::var_os("XDG_DATA_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| {
std::env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(|home| PathBuf::from(home).join(".local/share"))
.filter(|path| path.is_absolute())
});
if let Some(home) = data_home {
roots.push(home);
}
let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_default();
if data_dirs.trim().is_empty() {
roots.push(PathBuf::from("/usr/local/share"));
roots.push(PathBuf::from("/usr/share"));
} else {
roots.extend(
data_dirs
.split(':')
.filter(|part| !part.trim().is_empty())
.map(PathBuf::from),
);
}
let mut seen = HashSet::new();
roots
.into_iter()
.filter(|root| root.is_absolute())
.map(|root| root.join("applications"))
.filter(|directory| seen.insert(directory.clone()))
.collect()
}
pub fn discover_applications() -> Vec<DesktopApp> {
discover_applications_in(&application_dirs())
}
pub fn discover_applications_in(dirs: &[PathBuf]) -> Vec<DesktopApp> {
let mut seen: HashSet<String> = HashSet::new();
let mut apps: Vec<DesktopApp> = Vec::new();
let context = ParseContext::from_environment();
'outer: for dir in dirs {
for (id, path) in desktop_files_in(dir) {
if apps.len() >= APPLICATION_LIMIT {
break 'outer;
}
if !seen.insert(id.clone()) {
continue;
}
let Ok(contents) = std::fs::File::open(&path) else {
continue;
};
let bytes = read_bounded(contents);
let Ok(text) = String::from_utf8(bytes) else {
continue;
};
if let Some(mut app) = parse_desktop_entry_with_context(&id, &text, &context) {
app.desktop_file = Some(path);
apps.push(app);
}
}
}
apps.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.id.cmp(&right.id))
});
apps.truncate(APPLICATION_LIMIT);
apps
}
fn desktop_files_in(root: &Path) -> Vec<(String, PathBuf)> {
let mut paths = Vec::new();
let mut visited = 0;
collect_desktop_files(root, 0, &mut visited, &mut paths);
paths.sort_by(|left, right| {
let left_relative = left.strip_prefix(root).unwrap_or(left);
let right_relative = right.strip_prefix(root).unwrap_or(right);
left_relative.cmp(right_relative)
});
paths
.into_iter()
.filter_map(|path| desktop_id(root, &path).map(|id| (id, path)))
.collect()
}
fn collect_desktop_files(
directory: &Path,
depth: usize,
visited: &mut usize,
output: &mut Vec<PathBuf>,
) {
if depth > DISCOVERY_DEPTH_LIMIT || *visited >= DISCOVERY_ENTRY_LIMIT {
return;
}
for path in bounded_sorted_directory_entries(directory) {
if *visited >= DISCOVERY_ENTRY_LIMIT {
break;
}
*visited += 1;
let Ok(file_type) = std::fs::symlink_metadata(&path).map(|metadata| metadata.file_type())
else {
continue;
};
if file_type.is_dir() {
collect_desktop_files(&path, depth + 1, visited, output);
} else if path.extension().and_then(|extension| extension.to_str()) == Some("desktop") {
output.push(path);
}
}
}
fn bounded_sorted_directory_entries(directory: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(directory) else {
return Vec::new();
};
let mut smallest: BinaryHeap<PathBuf> = BinaryHeap::new();
for entry in entries.flatten() {
let path = entry.path();
if smallest.len() < DIRECTORY_ENTRY_LIMIT {
smallest.push(path);
} else if smallest.peek().is_some_and(|largest| path < *largest) {
smallest.pop();
smallest.push(path);
}
}
let mut paths = smallest.into_vec();
paths.sort();
paths
}
fn desktop_id(root: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?;
let mut components: Vec<&str> = relative
.components()
.map(|component| component.as_os_str().to_str())
.collect::<Option<_>>()?;
let file_name = components.pop()?;
let stem = file_name.strip_suffix(".desktop")?;
if stem.is_empty() {
return None;
}
components.push(stem);
Some(components.join("-"))
}
fn read_bounded(file: std::fs::File) -> Vec<u8> {
use std::io::Read;
let mut handle = file.take(DESKTOP_BYTES_LIMIT);
let mut buffer = Vec::new();
let _ = handle.read_to_end(&mut buffer);
buffer
}
pub fn parse_desktop_entry(id: &str, contents: &str) -> Option<DesktopApp> {
parse_desktop_entry_with_context(id, contents, &ParseContext::from_environment())
}
#[derive(Debug)]
struct ParseContext {
locale_names: Vec<String>,
current_desktops: Vec<String>,
executable_path: Vec<PathBuf>,
}
impl ParseContext {
fn from_environment() -> Self {
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"]
.into_iter()
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()));
let current_desktops = std::env::var("XDG_CURRENT_DESKTOP")
.ok()
.into_iter()
.flat_map(|value| {
value
.split(':')
.map(str::trim)
.filter(|desktop| !desktop.is_empty())
.map(String::from)
.collect::<Vec<_>>()
})
.collect();
let executable_path = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
Self {
locale_names: locale_name_candidates(locale.as_deref()),
current_desktops,
executable_path,
}
}
}
fn parse_desktop_entry_with_context(
id: &str,
contents: &str,
context: &ParseContext,
) -> Option<DesktopApp> {
let mut section_started = false;
let mut is_application = false;
let mut name: Option<String> = None;
let mut localized_names: Vec<(String, String)> = Vec::new();
let mut exec: Option<String> = None;
let mut try_exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut working_dir: Option<PathBuf> = None;
let mut terminal = false;
let mut only_show_in: Option<Vec<String>> = None;
let mut not_show_in: Vec<String> = Vec::new();
let mut mimes: Vec<String> = Vec::new();
for (index, raw_line) in contents.lines().enumerate() {
if index >= DESKTOP_LINE_LIMIT {
break;
}
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
if section_started {
break;
}
section_started |= line.eq_ignore_ascii_case("[desktop entry]");
continue;
}
if !section_started {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"Type" => {
if !value.eq_ignore_ascii_case("application") {
return None;
}
is_application = true;
}
"NoDisplay" | "Hidden" => {
if parse_flag(value) {
return None;
}
}
"Name" => {
name.get_or_insert(unescape_desktop_string(value)?);
}
"Exec" => {
exec.get_or_insert(unescape_desktop_string(value)?);
}
"TryExec" => {
try_exec.get_or_insert(unescape_desktop_string(value)?);
}
"Icon" => {
icon.get_or_insert(unescape_desktop_string(value)?);
}
"Path" => {
let path = unescape_desktop_string(value)?;
if !path.is_empty() {
working_dir.get_or_insert_with(|| PathBuf::from(path));
}
}
"Terminal" => terminal = parse_flag(value),
"OnlyShowIn" => {
only_show_in.get_or_insert(parse_string_list(value)?);
}
"NotShowIn" => {
if not_show_in.is_empty() {
not_show_in = parse_string_list(value)?;
}
}
"MimeType" => {
for mime in parse_string_list(value)? {
if !mimes.iter().any(|known| known.eq_ignore_ascii_case(&mime)) {
mimes.push(mime);
}
}
}
_ => {
if let Some(locale) = key
.strip_prefix("Name[")
.and_then(|suffix| suffix.strip_suffix(']'))
{
let localized = unescape_desktop_string(value)?;
if !localized_names.iter().any(|(known, _)| known == locale) {
localized_names.push((locale.to_string(), localized));
}
}
}
}
}
if !is_application || !desktop_is_visible(only_show_in.as_deref(), ¬_show_in, context) {
return None;
}
if try_exec
.as_deref()
.is_some_and(|program| !try_exec_is_available(program, &context.executable_path))
{
return None;
}
let exec = exec?;
exec_command_os(&exec, &[])?;
let localized_name = context.locale_names.iter().find_map(|candidate| {
localized_names
.iter()
.find(|(locale, _)| locale == candidate)
.map(|(_, value)| value.clone())
});
Some(DesktopApp {
id: id.to_string(),
name: localized_name.or(name).unwrap_or_else(|| id.to_string()),
exec,
mimes,
icon,
working_dir,
terminal,
desktop_file: None,
})
}
fn locale_name_candidates(locale: Option<&str>) -> Vec<String> {
let Some(locale) = locale.map(str::trim).filter(|locale| !locale.is_empty()) else {
return Vec::new();
};
let (base_with_encoding, modifier) = locale
.split_once('@')
.map_or((locale, None), |(base, modifier)| (base, Some(modifier)));
let base = base_with_encoding
.split_once('.')
.map_or(base_with_encoding, |(without_encoding, _)| without_encoding);
if base.eq_ignore_ascii_case("C") || base.eq_ignore_ascii_case("POSIX") || base.is_empty() {
return Vec::new();
}
let language = base.split_once('_').map_or(base, |(language, _)| language);
let mut candidates = Vec::with_capacity(4);
if let Some(modifier) = modifier.filter(|modifier| !modifier.is_empty()) {
candidates.push(format!("{base}@{modifier}"));
}
candidates.push(base.to_string());
if let Some(modifier) = modifier.filter(|modifier| !modifier.is_empty()) {
candidates.push(format!("{language}@{modifier}"));
}
candidates.push(language.to_string());
candidates.dedup();
candidates
}
fn desktop_is_visible(
only_show_in: Option<&[String]>,
not_show_in: &[String],
context: &ParseContext,
) -> bool {
for desktop in &context.current_desktops {
if only_show_in.is_some_and(|allowed| allowed.iter().any(|entry| entry == desktop)) {
return true;
}
if not_show_in.iter().any(|entry| entry == desktop) {
return false;
}
}
only_show_in.is_none()
}
fn try_exec_is_available(program: &str, executable_path: &[PathBuf]) -> bool {
if program.is_empty() {
return false;
}
let candidate = Path::new(program);
if candidate.is_absolute() {
return is_executable_file(candidate);
}
executable_path
.iter()
.map(|directory| directory.join(candidate))
.any(|path| is_executable_file(&path))
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn parse_string_list(value: &str) -> Option<Vec<String>> {
let value = unescape_desktop_string(value)?;
Some(
value
.split(';')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(String::from)
.collect(),
)
}
fn unescape_desktop_string(value: &str) -> Option<String> {
let mut output = String::with_capacity(value.len());
let mut characters = value.chars();
while let Some(character) = characters.next() {
if character != '\\' {
output.push(character);
continue;
}
output.push(match characters.next()? {
's' => ' ',
'n' => '\n',
't' => '\t',
'r' => '\r',
'\\' => '\\',
_ => return None,
});
}
Some(output)
}
fn parse_flag(value: &str) -> bool {
value.eq_ignore_ascii_case("true") || value == "1"
}
pub fn find_app<'a>(apps: &'a [DesktopApp], id: &str) -> Option<&'a DesktopApp> {
apps.iter().find(|app| app.id == id)
}
pub fn mime_match_score(app: &DesktopApp, mime: &str) -> u8 {
let requested = mime.split(';').next().unwrap_or_default().trim();
let Some((requested_type, requested_subtype)) = requested.split_once('/') else {
return 0;
};
if requested_type.is_empty() || requested_subtype.is_empty() {
return 0;
}
app.mimes
.iter()
.map(|declared| {
let declared = declared.trim();
if declared.eq_ignore_ascii_case(requested) {
return 2;
}
let Some((declared_type, declared_subtype)) = declared.split_once('/') else {
return 0;
};
u8::from(declared_type.eq_ignore_ascii_case(requested_type) && declared_subtype == "*")
})
.max()
.unwrap_or(0)
}
pub fn app_supports_mime(app: &DesktopApp, mime: &str) -> bool {
mime_match_score(app, mime) > 0
}
pub fn applications_for_mime<'a>(apps: &'a [DesktopApp], mime: &str) -> Vec<&'a DesktopApp> {
let mut matching: Vec<&DesktopApp> = apps
.iter()
.filter(|app| app_supports_mime(app, mime))
.collect();
sort_apps_for_mime(&mut matching, mime);
matching
}
pub fn rank_applications_for_mime<'a>(apps: &'a [DesktopApp], mime: &str) -> Vec<&'a DesktopApp> {
let mut ranked: Vec<&DesktopApp> = apps.iter().collect();
sort_apps_for_mime(&mut ranked, mime);
ranked
}
fn sort_apps_for_mime(apps: &mut [&DesktopApp], mime: &str) {
apps.sort_by(|left, right| {
let left_score = mime_match_score(left, mime);
let right_score = mime_match_score(right, mime);
let left_fallback = u8::from(left_score == 0 && left.mimes.is_empty());
let right_fallback = u8::from(right_score == 0 && right.mimes.is_empty());
right_score
.cmp(&left_score)
.then_with(|| right_fallback.cmp(&left_fallback))
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
.then_with(|| left.id.cmp(&right.id))
});
}
pub fn extension_key(path: &Path) -> Option<String> {
let extension = path.extension()?.to_str()?;
let extension = extension.trim();
if extension.is_empty() || extension.len() > EXTENSION_KEY_MAX_LEN {
return None;
}
Some(extension.to_ascii_lowercase())
}
pub fn mime_hint_for_path(path: &Path) -> Option<&'static str> {
if path.is_dir() {
return Some("inode/directory");
}
let extension = extension_key(path)?;
Some(match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" | "jpe" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
"svgz" => "image/svg+xml-compressed",
"tif" | "tiff" => "image/tiff",
"avif" => "image/avif",
"heic" | "heif" => "image/heif",
"ico" => "image/vnd.microsoft.icon",
"mp3" => "audio/mpeg",
"wav" => "audio/vnd.wave",
"ogg" | "oga" => "audio/ogg",
"opus" => "audio/ogg",
"flac" => "audio/flac",
"m4a" => "audio/mp4",
"aac" => "audio/aac",
"wma" => "audio/x-ms-wma",
"mid" | "midi" => "audio/midi",
"mp4" | "m4v" => "video/mp4",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
"mov" => "video/quicktime",
"mpg" | "mpeg" | "mpe" => "video/mpeg",
"ogv" => "video/ogg",
"flv" => "video/x-flv",
"wmv" => "video/x-ms-wmv",
"3gp" => "video/3gpp",
"txt" | "log" => "text/plain",
"md" | "markdown" => "text/markdown",
"csv" => "text/csv",
"tsv" => "text/tab-separated-values",
"html" | "htm" => "text/html",
"css" => "text/css",
"xml" => "application/xml",
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
"toml" => "application/toml",
"rs" => "text/rust",
"py" => "text/x-python",
"js" | "mjs" | "cjs" => "text/javascript",
"ts" | "tsx" => "text/typescript",
"sh" | "bash" | "zsh" | "fish" => "application/x-shellscript",
"c" => "text/x-csrc",
"h" => "text/x-chdr",
"cc" | "cpp" | "cxx" => "text/x-c++src",
"hh" | "hpp" | "hxx" => "text/x-c++hdr",
"java" => "text/x-java",
"go" => "text/x-go",
"rb" => "application/x-ruby",
"php" => "application/x-php",
"sql" => "application/sql",
"desktop" => "application/x-desktop",
"pdf" => "application/pdf",
"rtf" => "application/rtf",
"epub" => "application/epub+zip",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt" => "application/vnd.ms-powerpoint",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"odt" => "application/vnd.oasis.opendocument.text",
"ods" => "application/vnd.oasis.opendocument.spreadsheet",
"odp" => "application/vnd.oasis.opendocument.presentation",
"zip" => "application/zip",
"tar" => "application/x-tar",
"gz" => "application/gzip",
"bz2" => "application/x-bzip2",
"xz" => "application/x-xz",
"zst" => "application/zstd",
"7z" => "application/x-7z-compressed",
"rar" => "application/vnd.rar",
"tgz" => "application/x-compressed-tar",
"tbz" => "application/x-bzip1-compressed-tar",
"tbz2" => "application/x-bzip2-compressed-tar",
"txz" => "application/x-xz-compressed-tar",
"tzst" => "application/x-zstd-compressed-tar",
_ => return None,
})
}
pub fn exec_command(exec: &str, paths: &[&Path]) -> Option<Vec<String>> {
exec_command_os(exec, paths).map(|argv| {
argv.into_iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect()
})
}
pub fn exec_command_os(exec: &str, paths: &[&Path]) -> Option<Vec<OsString>> {
exec_command_os_with_metadata(exec, paths, None)
}
pub fn desktop_exec_command_os(app: &DesktopApp, paths: &[&Path]) -> Option<Vec<OsString>> {
exec_command_os_with_metadata(
&app.exec,
paths,
Some(ExecMetadata {
name: &app.name,
icon: app.icon.as_deref(),
desktop_file: app.desktop_file.as_deref(),
}),
)
}
#[derive(Clone, Copy)]
struct ExecMetadata<'a> {
name: &'a str,
icon: Option<&'a str>,
desktop_file: Option<&'a Path>,
}
fn exec_command_os_with_metadata(
exec: &str,
paths: &[&Path],
metadata: Option<ExecMetadata<'_>>,
) -> Option<Vec<OsString>> {
let tokens = tokenize_exec(exec)?;
if tokens.is_empty() {
return None;
}
if !executable_template_is_valid(&tokens[0].text) {
return None;
}
let mut argv: Vec<OsString> = Vec::with_capacity(tokens.len() + paths.len());
let mut file_code_count = 0_u8;
for token in &tokens {
if token.quoted_field_code {
return None;
}
match token.text.as_str() {
"%F" => {
file_code_count = file_code_count.checked_add(1)?;
if file_code_count > 1 {
return None;
}
argv.extend(paths.iter().map(|path| path.as_os_str().to_owned()));
}
"%U" => {
file_code_count = file_code_count.checked_add(1)?;
if file_code_count > 1 {
return None;
}
for path in paths {
argv.push(file_uri(path)?.into());
}
}
"%i" => {
if let Some(icon) = metadata.and_then(|metadata| metadata.icon) {
argv.push("--icon".into());
argv.push(icon.into());
}
}
_ => {
expand_field_codes_os(
&mut argv,
&token.text,
paths,
&mut file_code_count,
metadata,
)?;
}
}
}
if file_code_count == 0 {
argv.extend(paths.iter().map(|path| path.as_os_str().to_owned()));
}
let executable = argv.first()?.to_str()?;
if executable.is_empty() || executable.contains('=') {
return None;
}
Some(argv)
}
fn executable_template_is_valid(token: &str) -> bool {
if token.is_empty() {
return false;
}
let mut characters = token.chars();
while let Some(character) = characters.next() {
if character == '%' && characters.next() != Some('%') {
return false;
}
}
true
}
fn expand_field_codes_os(
argv: &mut Vec<OsString>,
token: &str,
paths: &[&Path],
file_code_count: &mut u8,
metadata: Option<ExecMetadata<'_>>,
) -> Option<()> {
let mut out = OsString::new();
let mut text = String::with_capacity(token.len());
let mut had_code = false;
let mut chars = token.chars().peekable();
while let Some(current) = chars.next() {
if current != '%' {
text.push(current);
continue;
}
match chars.next() {
Some('%') => text.push('%'),
Some(code @ ('f' | 'u')) => {
*file_code_count = file_code_count.checked_add(1)?;
if *file_code_count > 1 {
return None;
}
had_code = true;
if let Some(path) = paths.first() {
if !text.is_empty() {
out.push(std::mem::take(&mut text));
}
if code == 'u' {
out.push(file_uri(path)?);
} else {
out.push(path.as_os_str());
}
}
}
Some('F' | 'U') => return None,
Some('i') => return None,
Some(code @ ('c' | 'k')) => {
had_code = true;
if !text.is_empty() {
out.push(std::mem::take(&mut text));
}
match (code, metadata) {
('c', Some(metadata)) => out.push(metadata.name),
('k', Some(metadata)) => {
if let Some(desktop_file) = metadata.desktop_file {
out.push(desktop_file.as_os_str());
}
}
_ => {}
}
}
Some('d' | 'D' | 'n' | 'N' | 'v' | 'm') => {
had_code = true;
}
Some(_) | None => return None,
}
}
if !text.is_empty() {
out.push(text);
}
if !out.as_os_str().is_empty() || !had_code {
argv.push(out);
}
Some(())
}
fn file_uri(path: &Path) -> Option<String> {
let absolute;
let path = if path.is_absolute() {
path
} else {
absolute = std::env::current_dir().ok()?.join(path);
&absolute
};
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt as _;
path.as_os_str().as_bytes()
};
#[cfg(not(unix))]
let bytes = path.to_string_lossy().as_bytes();
let mut uri = String::with_capacity(bytes.len().saturating_mul(3).saturating_add(7));
uri.push_str("file://");
for byte in bytes {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
uri.push(char::from(*byte))
}
other => {
use std::fmt::Write as _;
let _ = write!(uri, "%{other:02X}");
}
}
}
Some(uri)
}
struct ExecToken {
text: String,
quoted_field_code: bool,
}
fn tokenize_exec(exec: &str) -> Option<Vec<ExecToken>> {
if exec.contains('\0') {
return None;
}
let mut tokens: Vec<ExecToken> = Vec::new();
let mut token = String::new();
let mut has_token = false;
let mut quoted_field_code = false;
let mut chars = exec.chars().peekable();
while let Some(current) = chars.next() {
match current {
'>' | '<' | '~' | '|' | '&' | ';' | '$' | '*' | '?' | '#' | '(' | ')' | '`' => {
return None
}
'\'' => {
has_token = true;
let mut closed = false;
for quoted in chars.by_ref() {
if quoted == '\'' {
closed = true;
break;
}
token.push(quoted);
}
if !closed {
return None;
}
}
'"' => {
has_token = true;
let mut closed = false;
while let Some(quoted) = chars.next() {
match quoted {
'"' => {
closed = true;
break;
}
'\\' => match chars.peek().copied() {
Some(escaped @ ('"' | '\\' | '`' | '$')) => {
chars.next();
token.push(escaped);
}
_ => token.push('\\'),
},
'%' if chars.peek().is_some_and(char::is_ascii_alphabetic) => {
quoted_field_code = true;
token.push('%');
}
'$' | '`' => return None,
other => token.push(other),
}
}
if !closed {
return None;
}
}
'\\' => {
has_token = true;
token.push(chars.next()?);
}
whitespace if whitespace.is_whitespace() => {
if has_token {
tokens.push(ExecToken {
text: std::mem::take(&mut token),
quoted_field_code,
});
has_token = false;
quoted_field_code = false;
}
}
other => {
token.push(other);
has_token = true;
}
}
}
if has_token {
tokens.push(ExecToken {
text: token,
quoted_field_code,
});
}
Some(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
fn context(locale: Option<&str>, desktops: &[&str], path: &[PathBuf]) -> ParseContext {
ParseContext {
locale_names: locale_name_candidates(locale),
current_desktops: desktops
.iter()
.map(|desktop| (*desktop).to_string())
.collect(),
executable_path: path.to_vec(),
}
}
fn test_app(id: &str, name: &str, mimes: &[&str]) -> DesktopApp {
DesktopApp {
id: id.to_string(),
name: name.to_string(),
exec: format!("{id} %F"),
mimes: mimes.iter().map(|mime| (*mime).to_string()).collect(),
icon: None,
working_dir: None,
terminal: false,
desktop_file: None,
}
}
fn temp_root(label: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"guth-open-with-{label}-{}-{nonce}",
std::process::id()
))
}
#[test]
fn parses_application_entries_and_rejects_hidden_or_foreign_types() {
let contents = "[Desktop Entry]\nType=Application\nName=Image Viewer\nExec=imv %f\nMimeType=image/png;image/jpeg;\n";
let app = parse_desktop_entry("imv", contents).expect("entry should parse");
assert_eq!(app.id, "imv");
assert_eq!(app.name, "Image Viewer");
assert_eq!(app.exec, "imv %f");
assert_eq!(app.mimes, vec!["image/png", "image/jpeg"]);
let hidden = "[Desktop Entry]\nType=Application\nName=Hidden\nExec=x %f\nNoDisplay=true\n";
assert!(parse_desktop_entry("hidden", hidden).is_none());
let foreign = "[Desktop Entry]\nType=Link\nName=Link\nURL=https://example.com\n";
assert!(parse_desktop_entry("link", foreign).is_none());
let missing_exec = "[Desktop Entry]\nType=Application\nName=Broken\n";
assert!(parse_desktop_entry("broken", missing_exec).is_none());
}
#[test]
fn stops_parsing_after_first_section() {
let contents = "[Desktop Entry]\nName=Fallback\nName[de]=Anders\nExec=app %f\n\n[Desktop Action new]\nName=Override\nExec=evil %f\n";
let contents = contents.replacen(
"[Desktop Entry]\n",
"[Desktop Entry]\nType=Application\n",
1,
);
let app = parse_desktop_entry_with_context(
"app",
&contents,
&context(Some("en_US.UTF-8"), &[], &[]),
)
.expect("entry should parse");
assert_eq!(app.name, "Fallback");
assert_eq!(app.exec, "app %f");
}
#[test]
fn selects_the_most_specific_localized_name() {
let contents = "[Desktop Entry]\nType=Application\nName=Fallback\nName[sr]=Srpski\nName[sr@Latn]=Srpski Latinica\nName[sr_YU]=Srpski YU\nName[sr_YU@Latn]=Najpreciznije\nExec=viewer %f\n";
let exact = parse_desktop_entry_with_context(
"viewer",
contents,
&context(Some("sr_YU.UTF-8@Latn"), &[], &[]),
)
.expect("exact locale");
assert_eq!(exact.name, "Najpreciznije");
let language_modifier = parse_desktop_entry_with_context(
"viewer",
contents,
&context(Some("sr_RS.UTF-8@Latn"), &[], &[]),
)
.expect("language modifier fallback");
assert_eq!(language_modifier.name, "Srpski Latinica");
let c_locale = parse_desktop_entry_with_context(
"viewer",
contents,
&context(Some("C.UTF-8"), &[], &[]),
)
.expect("C locale fallback");
assert_eq!(c_locale.name, "Fallback");
}
#[test]
fn honors_desktop_environment_visibility_lists() {
let only_gnome = "[Desktop Entry]\nType=Application\nName=GNOME Tool\nExec=tool %f\nOnlyShowIn=GNOME;Unity;\n";
assert!(parse_desktop_entry_with_context(
"tool",
only_gnome,
&context(None, &["GNOME"], &[]),
)
.is_some());
assert!(parse_desktop_entry_with_context(
"tool",
only_gnome,
&context(None, &["KDE"], &[]),
)
.is_none());
assert!(
parse_desktop_entry_with_context("tool", only_gnome, &context(None, &[], &[]),)
.is_none()
);
let not_gnome =
"[Desktop Entry]\nType=Application\nName=Other Tool\nExec=tool %f\nNotShowIn=GNOME;\n";
assert!(parse_desktop_entry_with_context(
"tool",
not_gnome,
&context(None, &["GNOME"], &[]),
)
.is_none());
assert!(
parse_desktop_entry_with_context("tool", not_gnome, &context(None, &["KDE"], &[]),)
.is_some()
);
}
#[cfg(unix)]
#[test]
fn try_exec_requires_an_executable_file_and_searches_path() {
use std::os::unix::fs::PermissionsExt as _;
let root = temp_root("try-exec");
std::fs::create_dir_all(&root).expect("create executable path");
let executable = root.join("guth-test-viewer");
std::fs::write(&executable, "#!/bin/sh\n").expect("write executable");
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755))
.expect("mark executable");
let base = "[Desktop Entry]\nType=Application\nName=Viewer\nExec=viewer %f\n";
let relative = format!("{base}TryExec=guth-test-viewer\n");
assert!(parse_desktop_entry_with_context(
"viewer",
&relative,
&context(None, &[], std::slice::from_ref(&root)),
)
.is_some());
let unavailable = format!("{base}TryExec=definitely-not-a-guth-program\n");
assert!(parse_desktop_entry_with_context(
"viewer",
&unavailable,
&context(None, &[], std::slice::from_ref(&root)),
)
.is_none());
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o644))
.expect("remove executable bit");
assert!(parse_desktop_entry_with_context(
"viewer",
&relative,
&context(None, &[], std::slice::from_ref(&root)),
)
.is_none());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn substitutes_single_file_field_codes() {
let path = Path::new("/tmp/example.png");
assert_eq!(
exec_command("imv %f", &[path]),
Some(vec!["imv".to_string(), "/tmp/example.png".to_string()])
);
assert_eq!(
exec_command("code --new-window %F", &[path]),
Some(vec![
"code".to_string(),
"--new-window".to_string(),
"/tmp/example.png".to_string()
])
);
assert_eq!(
exec_command("mpv --title %%f %u", &[path]),
Some(vec![
"mpv".to_string(),
"--title".to_string(),
"%f".to_string(),
"file:///tmp/example.png".to_string()
])
);
}
#[test]
fn expands_multi_file_codes_and_appends_when_no_code_present() {
let alpha = Path::new("/tmp/a.txt");
let beta = Path::new("/tmp/b.txt");
assert_eq!(
exec_command("diff-tool %F", &[alpha, beta]),
Some(vec![
"diff-tool".to_string(),
"/tmp/a.txt".to_string(),
"/tmp/b.txt".to_string()
])
);
assert_eq!(
exec_command("plain-app", &[alpha]),
Some(vec!["plain-app".to_string(), "/tmp/a.txt".to_string()])
);
}
#[test]
fn honors_quoting_and_drops_unavailable_metadata_codes() {
let path = Path::new("/tmp/some dir/x.txt");
assert_eq!(
exec_command("sh -c 'echo \"hi\"' '' %k %f", &[path]),
Some(vec![
"sh".to_string(),
"-c".to_string(),
"echo \"hi\"".to_string(),
"".to_string(),
"/tmp/some dir/x.txt".to_string()
])
);
}
#[test]
fn discovers_sorted_deduplicated_entries_from_directories() {
let root = temp_root("discovery");
let primary = root.join("primary");
let secondary = root.join("secondary");
std::fs::create_dir_all(&primary).expect("create primary");
std::fs::create_dir_all(&secondary).expect("create secondary");
std::fs::write(
primary.join("zeta.desktop"),
"[Desktop Entry]\nType=Application\nName=zeta\nExec=zeta %f\n",
)
.expect("write zeta");
std::fs::write(
secondary.join("zeta.desktop"),
"[Desktop Entry]\nType=Application\nName=shadowed\nExec=shadow %f\n",
)
.expect("write shadow zeta");
std::fs::write(
secondary.join("alpha.desktop"),
"[Desktop Entry]\nType=Application\nName=Alpha\nExec=alpha %f\n",
)
.expect("write alpha");
std::fs::write(
secondary.join("gone.desktop"),
"[Desktop Entry]\nType=Application\nName=Gone\nExec=gone %f\nNoDisplay=true\n",
)
.expect("write gone");
std::fs::write(primary.join("notes.txt"), "not a launcher").expect("write notes");
let apps = discover_applications_in(&[primary, secondary]);
let ids: Vec<&str> = apps.iter().map(|app| app.id.as_str()).collect();
assert_eq!(ids, vec!["alpha", "zeta"]);
assert_eq!(apps[1].name, "zeta");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn recursively_discovers_desktop_ids_and_honors_hidden_overrides() {
let root = temp_root("recursive-discovery");
let primary = root.join("primary");
let secondary = root.join("secondary");
std::fs::create_dir_all(primary.join("vendor/tools")).expect("create nested primary");
std::fs::create_dir_all(secondary.join("vendor")).expect("create nested secondary");
std::fs::write(
primary.join("vendor/tools/viewer.desktop"),
"[Desktop Entry]\nType=Application\nName=Nested Viewer\nExec=viewer %f\n",
)
.expect("write nested viewer");
std::fs::write(
primary.join("vendor-hidden.desktop"),
"[Desktop Entry]\nType=Application\nName=Removed\nExec=removed %f\nHidden=true\n",
)
.expect("write hidden override");
std::fs::write(
secondary.join("vendor-hidden.desktop"),
"[Desktop Entry]\nType=Application\nName=Must Stay Hidden\nExec=visible %f\n",
)
.expect("write shadowed entry");
std::fs::write(
secondary.join("vendor/alpha.desktop"),
"[Desktop Entry]\nType=Application\nName=Alpha\nExec=alpha %f\n",
)
.expect("write alpha");
let apps = discover_applications_in(&[primary, secondary]);
let ids: Vec<&str> = apps.iter().map(|app| app.id.as_str()).collect();
assert_eq!(ids, vec!["vendor-alpha", "vendor-tools-viewer"]);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn mime_helpers_filter_and_rank_specific_matches() {
let apps = vec![
test_app("wrong", "A mismatch", &["text/plain"]),
test_app("generic", "Generic", &[]),
test_app("wildcard", "Wildcard", &["image/*"]),
test_app("exact", "Exact", &["IMAGE/PNG"]),
];
assert_eq!(mime_match_score(&apps[3], "image/png; charset=binary"), 2);
assert_eq!(mime_match_score(&apps[2], "image/png"), 1);
assert!(!app_supports_mime(&apps[0], "image/png"));
let matching: Vec<&str> = applications_for_mime(&apps, "image/png")
.into_iter()
.map(|app| app.id.as_str())
.collect();
assert_eq!(matching, vec!["exact", "wildcard"]);
let ranked: Vec<&str> = rank_applications_for_mime(&apps, "image/png")
.into_iter()
.map(|app| app.id.as_str())
.collect();
assert_eq!(ranked, vec!["exact", "wildcard", "generic", "wrong"]);
}
#[test]
fn extension_keys_are_lowercase_and_bounded() {
assert_eq!(
extension_key(Path::new("/tmp/Report.PDF")),
Some("pdf".to_string())
);
assert_eq!(extension_key(Path::new("/tmp/noext")), None);
assert_eq!(extension_key(Path::new("/tmp/.hidden")), None);
let long = format!("a.{}", "x".repeat(EXTENSION_KEY_MAX_LEN + 1));
assert_eq!(extension_key(Path::new(&long)), None);
}
#[test]
fn mime_hints_cover_common_desktop_file_families() {
assert_eq!(
mime_hint_for_path(Path::new("/tmp/photo.PNG")),
Some("image/png")
);
assert_eq!(
mime_hint_for_path(Path::new("/tmp/song.mp3")),
Some("audio/mpeg")
);
assert_eq!(
mime_hint_for_path(Path::new("/tmp/movie.mkv")),
Some("video/x-matroska")
);
assert_eq!(
mime_hint_for_path(Path::new("/tmp/source.rs")),
Some("text/rust")
);
assert_eq!(
mime_hint_for_path(Path::new("/tmp/REPORT.PDF")),
Some("application/pdf")
);
assert_eq!(
mime_hint_for_path(Path::new("/tmp/document.docx")),
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
);
assert_eq!(
mime_hint_for_path(Path::new("/tmp/backup.tgz")),
Some("application/x-compressed-tar")
);
}
#[test]
fn mime_hints_identify_directories_and_reject_unknown_extensions() {
let directory = temp_root("mime-directory.PNG");
std::fs::create_dir_all(&directory).expect("create MIME test directory");
assert_eq!(mime_hint_for_path(&directory), Some("inode/directory"));
assert_eq!(
mime_hint_for_path(Path::new("/tmp/file.unknown-guth")),
None
);
assert_eq!(mime_hint_for_path(Path::new("/tmp/no-extension")), None);
let oversized = format!("/tmp/file.{}", "x".repeat(EXTENSION_KEY_MAX_LEN + 1));
assert_eq!(mime_hint_for_path(Path::new(&oversized)), None);
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn exec_command_rejects_empty_templates() {
assert_eq!(exec_command("", &[Path::new("/tmp/a.txt")]), None);
assert_eq!(exec_command(" ", &[Path::new("/tmp/a.txt")]), None);
assert!(exec_command("app", &[]).is_some());
}
#[test]
fn exec_command_rejects_malformed_or_ambiguous_templates() {
let path = Path::new("/tmp/a.txt");
assert_eq!(exec_command("viewer \"unterminated", &[path]), None);
assert_eq!(exec_command("viewer trailing\\", &[path]), None);
assert_eq!(exec_command("viewer %Z", &[path]), None);
assert_eq!(exec_command("viewer 50%", &[path]), None);
assert_eq!(exec_command("viewer %f %u", &[path]), None);
assert_eq!(exec_command("viewer --files=%F", &[path]), None);
assert_eq!(exec_command("%f", &[path]), None);
assert_eq!(exec_command("name=viewer %f", &[path]), None);
}
#[test]
fn exec_command_handles_spec_double_quote_escapes() {
let path = Path::new("/tmp/a.txt");
assert_eq!(
exec_command(r#"viewer "cost\$5 and \`tick\`" %f"#, &[path]),
Some(vec![
"viewer".to_string(),
"cost$5 and `tick`".to_string(),
"/tmp/a.txt".to_string(),
])
);
}
#[cfg(unix)]
#[test]
fn native_exec_arguments_preserve_non_utf8_file_names() {
use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
let path = PathBuf::from(OsString::from_vec(b"/tmp/report-\xff.bin".to_vec()));
let argv = exec_command_os("viewer --input=%f", &[&path]).expect("valid command");
assert_eq!(argv[0], "viewer");
assert_eq!(
argv[1].as_os_str().as_bytes(),
b"--input=/tmp/report-\xff.bin"
);
let appended = exec_command_os("viewer", &[&path]).expect("valid command");
assert_eq!(appended[1].as_os_str(), path.as_os_str());
let uri = exec_command_os("viewer %u", &[&path]).expect("valid command");
assert_eq!(uri[1], "file:///tmp/report-%FF.bin");
}
#[test]
fn desktop_metadata_field_codes_are_available_to_real_launches() {
let app = DesktopApp {
id: "org.example.viewer".to_string(),
name: "Example Viewer".to_string(),
exec: "viewer %c %k %f".to_string(),
mimes: vec!["image/png".to_string()],
icon: Some("example-viewer".to_string()),
working_dir: Some(PathBuf::from("/tmp")),
terminal: false,
desktop_file: Some(PathBuf::from("/tmp/org.example.viewer.desktop")),
};
let path = Path::new("/tmp/image.png");
let argv = desktop_exec_command_os(&app, &[path]).expect("metadata command should parse");
let rendered = argv
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
"viewer",
"Example Viewer",
"/tmp/org.example.viewer.desktop",
"/tmp/image.png"
]
);
}
#[test]
fn uri_field_codes_use_percent_encoded_file_uris() {
let alpha = Path::new("/tmp/one report.txt");
let beta = Path::new("/tmp/two#draft.txt");
assert_eq!(
exec_command("viewer %U", &[alpha, beta]),
Some(vec![
"viewer".to_string(),
"file:///tmp/one%20report.txt".to_string(),
"file:///tmp/two%23draft.txt".to_string(),
])
);
assert_eq!(
exec_command("viewer --uri=%u", &[alpha]),
Some(vec![
"viewer".to_string(),
"--uri=file:///tmp/one%20report.txt".to_string(),
])
);
let relative = Path::new("relative report.txt");
let relative_uri = exec_command("viewer %u", &[relative])
.expect("relative URI command")
.pop()
.expect("URI argument");
assert!(relative_uri.starts_with("file:///"));
assert!(relative_uri.ends_with("/relative%20report.txt"));
}
}