// Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only #[cfg(feature = "desktop")] use cosmic::desktop; use cosmic::widget; pub use mime_guess::Mime; use std::{ cmp::Ordering, collections::HashMap, env, ffi::OsString, fs, io, path::PathBuf, process, time::Instant, }; pub fn exec_to_command(exec: &str, path_opt: Option) -> Option { let args_vec: Vec = shlex::split(exec)?; let mut args = args_vec.iter(); let mut command = process::Command::new(args.next()?); for arg in args { if arg.starts_with('%') { match arg.as_str() { "%f" | "%F" | "%u" | "%U" => { if let Some(path) = &path_opt { command.arg(path); } } _ => { log::warn!("unsupported Exec code {:?} in {:?}", arg, exec); return None; } } } else { command.arg(arg); } } Some(command) } #[derive(Clone, Debug)] pub struct MimeApp { pub id: String, pub path: Option, pub name: String, pub exec: Option, pub icon: widget::icon::Handle, pub is_default: bool, } impl MimeApp { //TODO: move to libcosmic, support multiple files pub fn command(&self, path_opt: Option) -> Option { exec_to_command(self.exec.as_deref()?, path_opt) } } // This allows usage of MimeApp in a dropdown impl AsRef for MimeApp { fn as_ref(&self) -> &str { &self.name } } #[cfg(feature = "desktop")] impl From<&desktop::DesktopEntryData> for MimeApp { fn from(app: &desktop::DesktopEntryData) -> Self { Self { id: app.id.clone(), path: app.path.clone(), name: app.name.clone(), exec: app.exec.clone(), icon: match &app.icon { desktop::IconSource::Name(name) => { widget::icon::from_name(name.as_str()).size(32).handle() } desktop::IconSource::Path(path) => widget::icon::from_path(path.clone()), }, is_default: false, } } } #[cfg(feature = "desktop")] fn filename_eq(path_opt: &Option, filename: &str) -> bool { path_opt .as_ref() .and_then(|path| path.file_name()) .map(|x| x == filename) .unwrap_or(false) } pub struct MimeAppCache { cache: HashMap>, icons: HashMap>, terminals: Vec, } impl MimeAppCache { pub fn new() -> Self { let mut mime_app_cache = Self { cache: HashMap::new(), icons: HashMap::new(), terminals: Vec::new(), }; mime_app_cache.reload(); mime_app_cache } #[cfg(not(feature = "desktop"))] pub fn reload(&mut self) {} // Only available when using desktop feature of libcosmic, which only works on Unix-likes #[cfg(feature = "desktop")] pub fn reload(&mut self) { use crate::localize::LANGUAGE_SORTER; let start = Instant::now(); self.cache.clear(); self.icons.clear(); self.terminals.clear(); //TODO: get proper locale? let locale = None; // Load desktop applications by supported mime types //TODO: hashmap for all apps by id? let all_apps = desktop::load_applications(locale, false); for app in all_apps.iter() { for mime in app.mime_types.iter() { let apps = self .cache .entry(mime.clone()) .or_insert_with(|| Vec::with_capacity(1)); if !apps.iter().any(|x| x.id == app.id) { apps.push(MimeApp::from(app)); } } for category in app.categories.iter() { if category == "TerminalEmulator" { self.terminals.push(MimeApp::from(app)); break; } } } let desktops: Vec = env::var("XDG_CURRENT_DESKTOP") .unwrap_or_default() .split(':') .map(|x| x.to_ascii_lowercase()) .collect(); // Load mimeapps.list files // https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-latest.html //TODO: ensure correct lookup order let mut mimeapps_paths = Vec::new(); match xdg::BaseDirectories::new() { Ok(xdg_dirs) => { for path in xdg_dirs.find_data_files("applications/mimeapps.list") { mimeapps_paths.push(path); } for desktop in desktops.iter().rev() { for path in xdg_dirs.find_data_files(format!("applications/{desktop}-mimeapps.list")) { mimeapps_paths.push(path); } } for path in xdg_dirs.find_config_files("mimeapps.list") { mimeapps_paths.push(path); } for desktop in desktops.iter().rev() { for path in xdg_dirs.find_config_files(format!("{desktop}-mimeapps.list")) { mimeapps_paths.push(path); } } } Err(err) => { log::warn!("failed to get xdg base directories: {}", err); } } //TODO: handle directory specific behavior for path in mimeapps_paths { let entry = match freedesktop_entry_parser::parse_entry(&path) { Ok(ok) => ok, Err(err) => { log::warn!("failed to parse {:?}: {}", path, err); continue; } }; for attr in entry .section("Added Associations") .attrs() .chain(entry.section("Default Applications").attrs()) { if let Ok(mime) = attr.name.parse::() { if let Some(filenames) = attr.value { for filename in filenames.split_terminator(';') { log::trace!("add {}={}", mime, filename); let apps = self .cache .entry(mime.clone()) .or_insert_with(|| Vec::with_capacity(1)); if !apps.iter().any(|x| filename_eq(&x.path, filename)) { if let Some(app) = all_apps.iter().find(|x| filename_eq(&x.path, filename)) { apps.push(MimeApp::from(app)); } else { log::debug!("failed to add association for {:?}: application {:?} not found", mime, filename); } } } } } } for attr in entry.section("Removed Associations").attrs() { if let Ok(mime) = attr.name.parse::() { if let Some(filenames) = attr.value { for filename in filenames.split_terminator(';') { log::trace!("remove {}={}", mime, filename); if let Some(apps) = self.cache.get_mut(&mime) { apps.retain(|x| !filename_eq(&x.path, filename)); } } } } } for attr in entry.section("Default Applications").attrs() { if let Ok(mime) = attr.name.parse::() { if let Some(filenames) = attr.value { for filename in filenames.split_terminator(';') { log::trace!("default {}={}", mime, filename); if let Some(apps) = self.cache.get_mut(&mime) { let mut found = false; for app in apps.iter_mut() { if filename_eq(&app.path, filename) { app.is_default = true; found = true; } else { app.is_default = false; } } if found { break; } else { log::debug!("failed to set default for {:?}: application {:?} not found", mime, filename); } } } } } } } // Sort apps by name for apps in self.cache.values_mut() { apps.sort_by(|a, b| match (a.is_default, b.is_default) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => LANGUAGE_SORTER.compare(&a.name, &b.name), }); } // Copy icons to special cache //TODO: adjust dropdown API so this is no longer needed for (mime, apps) in self.cache.iter() { self.icons.insert( mime.clone(), apps.iter().map(|app| app.icon.clone()).collect(), ); } let elapsed = start.elapsed(); log::info!("loaded mime app cache in {:?}", elapsed); } pub fn get(&self, key: &Mime) -> &[MimeApp] { static EMPTY: Vec = Vec::new(); self.cache.get(key).unwrap_or(&EMPTY) } pub fn icons(&self, key: &Mime) -> &[widget::icon::Handle] { static EMPTY: Vec = Vec::new(); self.icons.get(key).unwrap_or(&EMPTY) } pub fn terminal(&self) -> Option<&MimeApp> { //TODO: consider rules in https://github.com/Vladimir-csp/xdg-terminal-exec // Look for and return preferred terminals //TODO: fallback order beyond cosmic-term? for id in &["com.system76.CosmicTerm"] { for terminal in self.terminals.iter() { if &terminal.id == id { return Some(terminal); } } } // Return whatever was the first terminal found self.terminals.first() } #[cfg(not(feature = "desktop"))] pub fn set_default(&mut self, mime: Mime, id: String) { log::warn!( "failed to set default handler for {mime:?} to {id:?}: desktop feature not enabled" ); } #[cfg(feature = "desktop")] pub fn set_default(&mut self, mime: Mime, mut id: String) { let Some(path) = cosmic_mime_apps::local_list_path() else { log::warn!("failed to find mimeapps.list path"); return; }; let mut list = cosmic_mime_apps::List::default(); match fs::read_to_string(&path) { Ok(string) => { list.load_from(&string); } Err(err) => { if err.kind() != io::ErrorKind::NotFound { log::warn!("failed to read {path:?}: {err}"); return; } } } let suffix = ".desktop"; if !id.ends_with(suffix) { id.push_str(suffix); } list.set_default_app(mime, id); let mut string = list.to_string(); string.push('\n'); match fs::write(&path, string) { Ok(()) => { self.reload(); } Err(err) => { log::warn!("failed to write {path:?}: {err}"); } } } } impl Default for MimeAppCache { fn default() -> Self { Self::new() } }