use std::collections::{HashMap, HashSet};
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use super::exec::{self, Fields};
use super::keyfile::{self, Group};
use super::program::{find_program, is_executable};
use super::{MimeDb, XdgDirs, read_small, warn};
use crate::diagnostics::Diagnostic;
#[cfg(test)]
mod tests;
const DEEPEST: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesktopApp {
pub id: String,
pub name: String,
pub exec: String,
pub terminal: bool,
pub mime_types: Vec<String>,
pub path: PathBuf,
pub icon: Option<String>,
}
impl DesktopApp {
#[must_use]
pub fn command(&self, file: &Path) -> Option<Vec<OsString>> {
exec::expand(&self.exec, &Fields { file, name: &self.name, icon: self.icon.as_deref(), entry: &self.path })
}
}
#[derive(Debug, Clone, Default)]
pub struct Apps {
apps: Vec<DesktopApp>,
lists: Vec<MimeAppsList>,
diagnostics: Vec<Diagnostic>,
}
impl Apps {
#[must_use]
pub fn load(dirs: &XdgDirs, lang: &str, path_var: Option<&OsStr>) -> Self {
let mut seen = HashSet::new();
let mut apps = Vec::new();
let mut diagnostics = Vec::new();
for dir in dirs.data() {
let root = dir.join("applications");
let mut found = Vec::new();
entries(&root, &root, 0, &mut found);
found.sort();
for (id, path) in found {
if seen.insert(id.clone())
&& let Some(app) = read_entry(id, path, lang, path_var, &mut diagnostics)
{
apps.push(app);
}
}
}
let mut lists = Vec::new();
for path in list_paths(dirs) {
if let Some(bytes) = read_small(&path, &mut diagnostics) {
lists.push(MimeAppsList::parse(&bytes, &path, &mut diagnostics));
}
}
Self { apps, lists, diagnostics }
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn all(&self) -> &[DesktopApp] {
&self.apps
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&DesktopApp> {
self.apps.iter().find(|app| app.id == id)
}
#[must_use]
pub fn for_mime(&self, db: &MimeDb, mime: &str) -> Vec<&DesktopApp> {
let mut out: Vec<&DesktopApp> = Vec::new();
for kind in db.ancestors(mime) {
let listed = self.listed(&kind, Section::Default).into_iter().chain(self.listed(&kind, Section::Added));
let declared = self.apps.iter().filter(|app| {
app.mime_types.iter().any(|declared| declared == &kind || db.canonical(declared) == kind)
&& !self.removed_before(self.lists.len(), &kind, &app.id)
});
for app in listed.chain(declared) {
if !out.iter().any(|known| known.id == app.id) {
out.push(app);
}
}
}
out
}
#[must_use]
pub fn default_for(&self, db: &MimeDb, mime: &str) -> Option<&DesktopApp> {
db.ancestors(mime)
.iter()
.find_map(|kind| self.listed(kind, Section::Default).into_iter().next())
.or_else(|| self.for_mime(db, mime).into_iter().next())
}
fn listed(&self, kind: &str, section: Section) -> Vec<&DesktopApp> {
self.lists
.iter()
.enumerate()
.flat_map(|(at, list)| {
list.section(section)
.get(kind)
.into_iter()
.flatten()
.filter(move |id| !self.removed_before(at, kind, id))
})
.filter_map(|id| self.get(id))
.collect()
}
fn removed_before(&self, count: usize, kind: &str, id: &str) -> bool {
self.lists[..count]
.iter()
.any(|list| list.removed.get(kind).is_some_and(|ids| ids.iter().any(|removed| removed == id)))
}
}
#[derive(Debug, Clone, Copy)]
enum Section {
Default,
Added,
}
#[derive(Debug, Clone, Default)]
struct MimeAppsList {
defaults: HashMap<String, Vec<String>>,
added: HashMap<String, Vec<String>>,
removed: HashMap<String, Vec<String>>,
}
impl MimeAppsList {
fn parse(bytes: &[u8], path: &Path, diagnostics: &mut Vec<Diagnostic>) -> Self {
let mut list = Self::default();
for group in keyfile::parse(bytes, path, diagnostics) {
let section = match group.name.as_str() {
"Default Applications" => &mut list.defaults,
"Added Associations" => &mut list.added,
"Removed Associations" => &mut list.removed,
_ => continue,
};
for (kind, ids) in group.entries() {
let ids = keyfile::list(ids);
let known = section.entry(kind.to_owned()).or_default();
for id in ids {
if !known.contains(&id) {
known.push(id);
}
}
}
}
list
}
fn section(&self, section: Section) -> &HashMap<String, Vec<String>> {
match section {
Section::Default => &self.defaults,
Section::Added => &self.added,
}
}
}
fn list_paths(dirs: &XdgDirs) -> Vec<PathBuf> {
let desktops: Vec<&String> =
dirs.desktops.iter().filter(|desktop| !desktop.is_empty() && !desktop.contains('/')).collect();
let folders = dirs.config().cloned().chain(dirs.data().map(|dir| dir.join("applications")));
let mut out = Vec::new();
for folder in folders {
for desktop in &desktops {
out.push(folder.join(format!("{desktop}-mimeapps.list")));
}
out.push(folder.join("mimeapps.list"));
}
out
}
fn entries(root: &Path, folder: &Path, depth: usize, out: &mut Vec<(String, PathBuf)>) {
let Ok(read) = fs::read_dir(folder) else {
return;
};
for entry in read.flatten() {
let path = entry.path();
let Ok(meta) = fs::metadata(&path) else {
continue;
};
if meta.is_dir() {
if depth < DEEPEST {
entries(root, &path, depth + 1, out);
}
} else if meta.is_file()
&& path.extension().is_some_and(|ext| ext == "desktop")
&& let Some(id) = path.strip_prefix(root).ok().and_then(Path::to_str).map(|rel| rel.replace('/', "-"))
{
out.push((id, path));
}
}
}
fn read_entry(
id: String,
path: PathBuf,
lang: &str,
path_var: Option<&OsStr>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<DesktopApp> {
let bytes = read_small(&path, diagnostics)?;
let groups = keyfile::parse(&bytes, &path, diagnostics);
let Some(entry) = groups.iter().find(|group| group.name == "Desktop Entry") else {
warn(diagnostics, &path, 1, "the file has no [Desktop Entry] group, so it is no program");
return None;
};
let flag = |key: &str| entry.get(key).is_some_and(|value| value.trim() == "true");
if entry.get("Type").map(str::trim) != Some("Application") || flag("Hidden") {
return None;
}
if let Some(program) = entry.get("TryExec").map(keyfile::string)
&& find_program(program.trim(), path_var, is_executable).is_none()
{
return None;
}
let Some(name) = localized(entry, "Name", lang).filter(|name| !name.trim().is_empty()) else {
warn(diagnostics, &path, entry.line, "an application needs a Name; the program is skipped");
return None;
};
let Some(exec) = entry.get("Exec").map(keyfile::string) else {
warn(diagnostics, &path, entry.line, "an application needs an Exec line; the program is skipped");
return None;
};
let app = DesktopApp {
id,
name,
exec,
terminal: flag("Terminal"),
mime_types: entry.get("MimeType").map(keyfile::list).unwrap_or_default(),
path,
icon: entry.get("Icon").map(keyfile::string).filter(|icon| !icon.trim().is_empty()),
};
if app.command(Path::new("file")).is_none() {
let line = entry.line_of("Exec").unwrap_or(entry.line);
warn(diagnostics, &app.path, line, "the Exec line gives no command; the program is skipped");
return None;
}
Some(app)
}
fn localized(entry: &Group, key: &str, lang: &str) -> Option<String> {
let (base, modifier) = match lang.split_once('@') {
Some((base, modifier)) => (base, Some(modifier)),
None => (lang, None),
};
let base = base.split('.').next().unwrap_or_default();
let (language, country) = match base.split_once('_') {
Some((language, country)) => (language, Some(country)),
None => (base, None),
};
let mut locales = Vec::new();
if !language.is_empty() && language != "C" && language != "POSIX" {
if let (Some(country), Some(modifier)) = (country, modifier) {
locales.push(format!("{language}_{country}@{modifier}"));
}
if let Some(country) = country {
locales.push(format!("{language}_{country}"));
}
if let Some(modifier) = modifier {
locales.push(format!("{language}@{modifier}"));
}
locales.push(language.to_owned());
}
locales
.iter()
.find_map(|locale| entry.get(&format!("{key}[{locale}]")))
.or_else(|| entry.get(key))
.map(keyfile::string)
}