use common::Result;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
#[derive(Clone)]
pub struct DesktopApp {
pub name: String,
pub exec: String,
pub is_cli: bool,
}
pub fn list_applications() -> Result<Vec<DesktopApp>> {
let mut apps = Vec::new();
let dirs = [
"/usr/share/applications",
"/usr/local/share/applications",
"/var/lib/flatpak/exports/share/applications",
];
let home_apps = dirs::data_dir().map(|d| d.join("applications"));
for dir in dirs.iter().map(Path::new).chain(home_apps.as_deref()) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "desktop") {
if let Some(app) = parse_desktop_file(&path) {
apps.push(app);
}
}
}
}
}
apps.sort_by_key(|a| a.name.to_lowercase());
apps.dedup_by(|a, b| a.name == b.name);
Ok(apps)
}
fn parse_desktop_file(path: &Path) -> Option<DesktopApp> {
let content = fs::read_to_string(path).ok()?;
let mut name = None;
let mut exec = None;
let mut no_display = false;
let mut in_desktop_entry = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') {
in_desktop_entry = line == "[Desktop Entry]";
continue;
}
if !in_desktop_entry {
continue;
}
if line.starts_with("Name=") && name.is_none() {
name = Some(line[5..].to_string());
} else if line.starts_with("Exec=") && exec.is_none() {
exec = exec_command(&line[5..]);
} else if line == "NoDisplay=true" || line == "Hidden=true" {
no_display = true;
} else if line.starts_with("Type=") && line != "Type=Application" {
return None;
}
}
if no_display {
return None;
}
Some(DesktopApp {
name: name?,
exec: exec?,
is_cli: false,
})
}
pub fn exec_command(value: &str) -> Option<String> {
let args = split_exec(&unescape_value(value))?;
let args: Vec<String> = args.into_iter().filter_map(expand_field_codes).collect();
if args.is_empty() {
return None;
}
if args[0] == "env" && !env_runs_a_program(&args[1..]) {
return None;
}
Some(
args.iter()
.map(|a| shell_quote(a))
.collect::<Vec<_>>()
.join(" "),
)
}
fn env_runs_a_program(args: &[String]) -> bool {
let mut args = args.iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"-u" | "--unset" | "-C" | "--chdir" => {
args.next();
}
"-S" | "--split-string" => return args.next().is_some(),
"--" => return args.next().is_some(),
a if a.starts_with("-S") && a.len() > 2 => return true,
a if a.starts_with('-') => {}
a if a.contains('=') => {}
_ => return true,
}
}
false
}
fn unescape_value(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('s') => out.push(' '),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
fn split_exec(value: &str) -> Option<Vec<String>> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_arg = false;
let mut chars = value.chars();
while let Some(c) = chars.next() {
match c {
'"' => {
in_arg = true;
loop {
match chars.next()? {
'"' => break,
'\\' => match chars.next()? {
e @ ('"' | '`' | '$' | '\\') => current.push(e),
other => {
current.push('\\');
current.push(other);
}
},
other => current.push(other),
}
}
}
c if c.is_whitespace() => {
if in_arg {
args.push(std::mem::take(&mut current));
in_arg = false;
}
}
c => {
in_arg = true;
current.push(c);
}
}
}
if in_arg {
args.push(current);
}
Some(args)
}
fn expand_field_codes(arg: String) -> Option<String> {
if arg.len() == 2 && arg.starts_with('%') && arg != "%%" {
return None;
}
let mut out = String::with_capacity(arg.len());
let mut chars = arg.chars();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
} else if let Some(next) = chars.next() {
if next == '%' {
out.push('%');
}
}
}
Some(out)
}
fn shell_quote(arg: &str) -> String {
let plain = !arg.is_empty()
&& arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
if plain {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', r"'\''"))
}
}
pub fn search_cli_apps(query: &str) -> Vec<DesktopApp> {
if query.len() < 2 {
return Vec::new();
}
let query_lower = query.to_lowercase();
let mut apps = Vec::new();
if let Ok(path_var) = std::env::var("PATH") {
for dir in path_var.split(':') {
let dir_path = Path::new(dir);
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.to_lowercase().contains(&query_lower) {
continue;
}
if let Ok(meta) = entry.metadata() {
if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
apps.push(DesktopApp {
name: format!("{} (CLI)", name),
exec: shell_quote(&name),
is_cli: true,
});
}
}
}
}
}
}
apps.sort_by(|a, b| a.name.cmp(&b.name));
apps.dedup_by(|a, b| a.exec == b.exec);
apps
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exec_drops_field_codes_and_keeps_arguments() {
assert_eq!(exec_command("firefox %u").as_deref(), Some("firefox"));
assert_eq!(
exec_command("code --new-window %F").as_deref(),
Some("code --new-window")
);
assert_eq!(
exec_command("app --pct=100%%").as_deref(),
Some("app --pct=100%")
);
assert_eq!(
exec_command("app --file=%f").as_deref(),
Some("app --file=")
);
}
#[test]
fn exec_keeps_quoted_arguments_together() {
assert_eq!(
exec_command(r#""/opt/My App/app" --flag %U"#).as_deref(),
Some("'/opt/My App/app' --flag")
);
assert_eq!(
exec_command(r#"sh -c "echo hi; sleep 1""#).as_deref(),
Some("sh -c 'echo hi; sleep 1'")
);
assert_eq!(
exec_command(r#"app "say \\"hi\\"" "c:\\\\dir""#).as_deref(),
Some(r#"app 'say "hi"' 'c:\dir'"#)
);
assert_eq!(
exec_command("app \"it's\"").as_deref(),
Some(r"app 'it'\''s'")
);
assert_eq!(exec_command(r"my\sapp").as_deref(), Some("my app"));
}
#[test]
fn exec_keeps_env_wrappers_and_their_variables() {
assert_eq!(
exec_command("env FOO=1 BAR=\"a b\" app --x %f").as_deref(),
Some("env FOO=1 'BAR=a b' app --x")
);
assert_eq!(
exec_command("env -u GTK_THEME app").as_deref(),
Some("env -u GTK_THEME app")
);
assert_eq!(
exec_command("env -i -- app").as_deref(),
Some("env -i -- app")
);
assert_eq!(exec_command("env -u X"), None);
assert_eq!(exec_command("env -i"), None);
}
#[test]
fn cli_app_names_are_quoted() {
assert_eq!(shell_quote("my tool"), "'my tool'");
assert_eq!(shell_quote("rg"), "rg");
assert_eq!(shell_quote("a$b"), "'a$b'");
}
#[test]
fn exec_rejects_empty_or_unterminated_lines() {
assert_eq!(exec_command("%U"), None);
assert_eq!(exec_command(" "), None);
assert_eq!(exec_command("env A=1"), None);
assert_eq!(exec_command(r#"app "open"#), None);
}
}