mirror of
https://github.com/Zedfrigg/ironbar.git
synced 2025-08-17 14:51:04 +02:00
refactor: overhaul .desktop
and image resolver systems
Rewrites the desktop file parser code and image resolver code to introduce caching system and make fully async. They should be much faster now. BREAKING CHANGE: The `icon_theme` setting has been moved from per-bar to top-level
This commit is contained in:
parent
ca524f19f6
commit
3e55d87c3a
26 changed files with 1840 additions and 600 deletions
|
@ -1,36 +1,267 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use crate::spawn;
|
||||
use color_eyre::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tracing::warn;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::debug;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
use crate::lock;
|
||||
|
||||
type DesktopFile = HashMap<String, Vec<String>>;
|
||||
|
||||
fn desktop_files() -> &'static Mutex<HashMap<PathBuf, DesktopFile>> {
|
||||
static DESKTOP_FILES: OnceLock<Mutex<HashMap<PathBuf, DesktopFile>>> = OnceLock::new();
|
||||
DESKTOP_FILES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
#[derive(Debug, Clone)]
|
||||
enum DesktopFileRef {
|
||||
Unloaded(PathBuf),
|
||||
Loaded(DesktopFile),
|
||||
}
|
||||
|
||||
fn desktop_files_look_out_keys() -> &'static HashSet<&'static str> {
|
||||
static DESKTOP_FILES_LOOK_OUT_KEYS: OnceLock<HashSet<&'static str>> = OnceLock::new();
|
||||
DESKTOP_FILES_LOOK_OUT_KEYS
|
||||
.get_or_init(|| HashSet::from(["Name", "StartupWMClass", "Exec", "Icon"]))
|
||||
impl DesktopFileRef {
|
||||
async fn get(&mut self) -> Result<DesktopFile> {
|
||||
match self {
|
||||
DesktopFileRef::Unloaded(path) => {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let path = path.clone();
|
||||
|
||||
spawn(async move { tx.send(Self::load(&path).await) });
|
||||
|
||||
let file = rx.await??;
|
||||
*self = DesktopFileRef::Loaded(file.clone());
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
DesktopFileRef::Loaded(file) => Ok(file.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(file_path: &Path) -> Result<DesktopFile> {
|
||||
debug!("loading applications file: {}", file_path.display());
|
||||
|
||||
let file = tokio::fs::File::open(file_path).await?;
|
||||
|
||||
let mut desktop_file = DesktopFile::new(
|
||||
file_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut lines = BufReader::new(file).lines();
|
||||
|
||||
let mut has_name = false;
|
||||
let mut has_type = false;
|
||||
let mut has_wm_class = false;
|
||||
let mut has_exec = false;
|
||||
let mut has_icon = false;
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match key {
|
||||
"Name" => {
|
||||
desktop_file.name = Some(value.to_string());
|
||||
has_name = true;
|
||||
}
|
||||
"Type" => {
|
||||
desktop_file.app_type = Some(value.to_string());
|
||||
has_type = true;
|
||||
}
|
||||
"StartupWMClass" => {
|
||||
desktop_file.startup_wm_class = Some(value.to_string());
|
||||
has_wm_class = true;
|
||||
}
|
||||
"Exec" => {
|
||||
desktop_file.exec = Some(value.to_string());
|
||||
has_exec = true;
|
||||
}
|
||||
"Icon" => {
|
||||
desktop_file.icon = Some(value.to_string());
|
||||
has_icon = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// parsing complete - don't bother with the rest of the lines
|
||||
if has_name && has_type && has_wm_class && has_exec && has_icon {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(desktop_file)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds directories that should contain `.desktop` files
|
||||
/// and exist on the filesystem.
|
||||
fn find_application_dirs() -> Vec<PathBuf> {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopFile {
|
||||
pub file_name: String,
|
||||
pub name: Option<String>,
|
||||
pub app_type: Option<String>,
|
||||
pub startup_wm_class: Option<String>,
|
||||
pub exec: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl DesktopFile {
|
||||
fn new(file_name: String) -> Self {
|
||||
Self {
|
||||
file_name,
|
||||
name: None,
|
||||
app_type: None,
|
||||
startup_wm_class: None,
|
||||
exec: None,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FileMap = HashMap<Box<str>, DesktopFileRef>;
|
||||
|
||||
/// Desktop file cache and resolver.
|
||||
///
|
||||
/// Files are lazy-loaded as required on resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopFiles {
|
||||
files: Arc<Mutex<FileMap>>,
|
||||
}
|
||||
|
||||
impl Default for DesktopFiles {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopFiles {
|
||||
/// Creates a new instance,
|
||||
/// scanning disk to generate a list of (unloaded) file refs in the process.
|
||||
pub fn new() -> Self {
|
||||
let desktop_files: FileMap = dirs()
|
||||
.iter()
|
||||
.flat_map(|path| files(path))
|
||||
.map(|file| {
|
||||
(
|
||||
file.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
.into(),
|
||||
DesktopFileRef::Unloaded(file),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
debug!("resolved {} files", desktop_files.len());
|
||||
|
||||
Self {
|
||||
files: Arc::new(Mutex::new(desktop_files)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to locate a applications file by file name or contents.
|
||||
///
|
||||
/// Input should typically be the app id, app name or icon.
|
||||
pub async fn find(&self, input: &str) -> Result<Option<DesktopFile>> {
|
||||
let mut res = self.find_by_file_name(input).await?;
|
||||
if res.is_none() {
|
||||
res = self.find_by_file_contents(input).await?;
|
||||
}
|
||||
|
||||
debug!("found match for app_id {input}: {}", res.is_some());
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Checks file names for an exact or partial match of the provided input.
|
||||
async fn find_by_file_name(&self, input: &str) -> Result<Option<DesktopFile>> {
|
||||
let mut files = self.files.lock().await;
|
||||
|
||||
let mut file_ref = files
|
||||
.iter_mut()
|
||||
.find(|&(name, _)| name.eq_ignore_ascii_case(input));
|
||||
|
||||
if file_ref.is_none() {
|
||||
file_ref = files.iter_mut().find(
|
||||
|&(name, _)| // this will attempt to find flatpak apps that are in the format
|
||||
// `com.company.app` or `com.app.something`
|
||||
input
|
||||
.split(&[' ', ':', '@', '.', '_'][..])
|
||||
.any(|part| name.eq_ignore_ascii_case(part)),
|
||||
);
|
||||
}
|
||||
|
||||
let file_ref = file_ref.map(|(_, file)| file);
|
||||
|
||||
if let Some(file_ref) = file_ref {
|
||||
let file = file_ref.get().await?;
|
||||
Ok(Some(file))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks file contents for an exact or partial match of the provided input.
|
||||
async fn find_by_file_contents(&self, app_id: &str) -> Result<Option<DesktopFile>> {
|
||||
let mut files = self.files.lock().await;
|
||||
|
||||
// first pass - check name for exact match
|
||||
for (_, file_ref) in files.iter_mut() {
|
||||
let file = file_ref.get().await?;
|
||||
if let Some(name) = &file.name {
|
||||
if name.eq_ignore_ascii_case(app_id) {
|
||||
return Ok(Some(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// second pass - check name for partial match
|
||||
for (_, file_ref) in files.iter_mut() {
|
||||
let file = file_ref.get().await?;
|
||||
if let Some(name) = &file.name {
|
||||
if name.to_lowercase().contains(app_id) {
|
||||
return Ok(Some(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// third pass - check remaining fields for partial match
|
||||
for (_, file_ref) in files.iter_mut() {
|
||||
let file = file_ref.get().await?;
|
||||
|
||||
if let Some(name) = &file.exec {
|
||||
if name.to_lowercase().contains(app_id) {
|
||||
return Ok(Some(file));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = &file.startup_wm_class {
|
||||
if name.to_lowercase().contains(app_id) {
|
||||
return Ok(Some(file));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = &file.icon {
|
||||
if name.to_lowercase().contains(app_id) {
|
||||
return Ok(Some(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a list of paths to all directories
|
||||
/// containing `.applications` files.
|
||||
fn dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = vec![
|
||||
PathBuf::from("/usr/share/applications"), // system installed apps
|
||||
PathBuf::from("/var/lib/flatpak/exports/share/applications"), // flatpak apps
|
||||
];
|
||||
|
||||
let xdg_dirs = env::var_os("XDG_DATA_DIRS");
|
||||
if let Some(xdg_dirs) = xdg_dirs {
|
||||
let xdg_dirs = env::var("XDG_DATA_DIRS");
|
||||
if let Ok(xdg_dirs) = xdg_dirs {
|
||||
for mut xdg_dir in env::split_paths(&xdg_dirs) {
|
||||
xdg_dir.push("applications");
|
||||
dirs.push(xdg_dir);
|
||||
|
@ -43,157 +274,66 @@ fn find_application_dirs() -> Vec<PathBuf> {
|
|||
dirs.push(user_dir);
|
||||
}
|
||||
|
||||
dirs.into_iter().filter(|dir| dir.exists()).collect()
|
||||
dirs.into_iter().filter(|dir| dir.exists()).rev().collect()
|
||||
}
|
||||
|
||||
/// Finds all the desktop files
|
||||
fn find_desktop_files() -> Vec<PathBuf> {
|
||||
let dirs = find_application_dirs();
|
||||
dirs.into_iter()
|
||||
.flat_map(|dir| {
|
||||
WalkDir::new(dir)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.map(DirEntry::into_path)
|
||||
.filter(|file| file.is_file() && file.extension().unwrap_or_default() == "desktop")
|
||||
})
|
||||
/// Gets a list of all `.applications` files in the provided directory.
|
||||
///
|
||||
/// The directory is recursed to a maximum depth of 5.
|
||||
fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
WalkDir::new(dir)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.map(DirEntry::into_path)
|
||||
.filter(|file| file.is_file() && file.extension().unwrap_or_default() == "desktop")
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Attempts to locate a `.desktop` file for an app id
|
||||
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
|
||||
// this is necessary to invalidate the cache
|
||||
let files = find_desktop_files();
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
find_desktop_file_by_filename(app_id, &files)
|
||||
.or_else(|| find_desktop_file_by_filedata(app_id, &files))
|
||||
}
|
||||
|
||||
/// Finds the correct desktop file using a simple condition check
|
||||
fn find_desktop_file_by_filename(app_id: &str, files: &[PathBuf]) -> Option<PathBuf> {
|
||||
let with_names = files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
(
|
||||
f,
|
||||
f.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
with_names
|
||||
.iter()
|
||||
// first pass - check for exact match
|
||||
.find(|(_, name)| name.eq_ignore_ascii_case(app_id))
|
||||
// second pass - check for substring
|
||||
.or_else(|| {
|
||||
with_names.iter().find(|(_, name)| {
|
||||
// this will attempt to find flatpak apps that are in the format
|
||||
// `com.company.app` or `com.app.something`
|
||||
app_id
|
||||
.split(&[' ', ':', '@', '.', '_'][..])
|
||||
.any(|part| name.eq_ignore_ascii_case(part))
|
||||
})
|
||||
})
|
||||
.map(|(file, _)| file.into())
|
||||
}
|
||||
|
||||
/// Finds the correct desktop file using the keys in `DESKTOP_FILES_LOOK_OUT_KEYS`
|
||||
fn find_desktop_file_by_filedata(app_id: &str, files: &[PathBuf]) -> Option<PathBuf> {
|
||||
let app_id = &app_id.to_lowercase();
|
||||
let mut desktop_files_cache = lock!(desktop_files());
|
||||
|
||||
let files = files
|
||||
.iter()
|
||||
.filter_map(|file| {
|
||||
let parsed_desktop_file = parse_desktop_file(file)?;
|
||||
|
||||
desktop_files_cache.insert(file.clone(), parsed_desktop_file.clone());
|
||||
Some((file.clone(), parsed_desktop_file))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let file = files
|
||||
.iter()
|
||||
// first pass - check name key for exact match
|
||||
.find(|(_, desktop_file)| {
|
||||
desktop_file
|
||||
.get("Name")
|
||||
.is_some_and(|names| names.iter().any(|name| name.eq_ignore_ascii_case(app_id)))
|
||||
})
|
||||
// second pass - check name key for substring
|
||||
.or_else(|| {
|
||||
files.iter().find(|(_, desktop_file)| {
|
||||
desktop_file.get("Name").is_some_and(|names| {
|
||||
names
|
||||
.iter()
|
||||
.any(|name| name.to_lowercase().contains(app_id))
|
||||
})
|
||||
})
|
||||
})
|
||||
// third pass - check all keys for substring
|
||||
.or_else(|| {
|
||||
files.iter().find(|(_, desktop_file)| {
|
||||
desktop_file
|
||||
.values()
|
||||
.flatten()
|
||||
.any(|value| value.to_lowercase().contains(app_id))
|
||||
})
|
||||
});
|
||||
|
||||
file.map(|(path, _)| path).cloned()
|
||||
}
|
||||
|
||||
/// Parses a desktop file into a hashmap of keys/vector(values).
|
||||
fn parse_desktop_file(path: &Path) -> Option<DesktopFile> {
|
||||
let Ok(file) = fs::read_to_string(path) else {
|
||||
warn!("Couldn't Open File: {}", path.display());
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut desktop_file: DesktopFile = DesktopFile::new();
|
||||
|
||||
file.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
if desktop_files_look_out_keys().contains(key) {
|
||||
Some((key, value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.for_each(|(key, value)| {
|
||||
desktop_file
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.push(value.to_string());
|
||||
});
|
||||
|
||||
Some(desktop_file)
|
||||
}
|
||||
|
||||
/// Attempts to get the icon name from the app's `.desktop` file.
|
||||
pub fn get_desktop_icon_name(app_id: &str) -> Option<String> {
|
||||
let path = find_desktop_file(app_id)?;
|
||||
|
||||
let mut desktop_files_cache = lock!(desktop_files());
|
||||
|
||||
let desktop_file = match desktop_files_cache.get(&path) {
|
||||
Some(desktop_file) => desktop_file,
|
||||
_ => desktop_files_cache
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| parse_desktop_file(&path).expect("desktop_file")),
|
||||
};
|
||||
|
||||
let mut icons = desktop_file.get("Icon").into_iter().flatten();
|
||||
|
||||
icons.next().map(std::string::ToString::to_string)
|
||||
fn setup() {
|
||||
unsafe {
|
||||
let pwd = env::current_dir().unwrap();
|
||||
env::set_var("XDG_DATA_DIRS", format!("{}/test-configs", pwd.display()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_filename() {
|
||||
setup();
|
||||
|
||||
let desktop_files = DesktopFiles::new();
|
||||
let file = desktop_files.find_by_file_name("firefox").await.unwrap();
|
||||
|
||||
assert!(file.is_some());
|
||||
assert_eq!(file.unwrap().file_name, "firefox.desktop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_file_contents() {
|
||||
setup();
|
||||
|
||||
let desktop_files = DesktopFiles::new();
|
||||
|
||||
let file = desktop_files.find_by_file_contents("427520").await.unwrap();
|
||||
|
||||
assert!(file.is_some());
|
||||
assert_eq!(file.unwrap().file_name, "Factorio.desktop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parser() {
|
||||
let mut file_ref =
|
||||
DesktopFileRef::Unloaded(PathBuf::from("test-configs/applications/firefox.desktop"));
|
||||
let file = file_ref.get().await.unwrap();
|
||||
|
||||
assert_eq!(file.name, Some("Firefox".to_string()));
|
||||
assert_eq!(file.icon, Some("firefox".to_string()));
|
||||
assert_eq!(file.exec, Some("/usr/lib/firefox/firefox %u".to_string()));
|
||||
assert_eq!(file.startup_wm_class, Some("firefox".to_string()));
|
||||
assert_eq!(file.app_type, Some("Application".to_string()));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue