Files
k-launcher/crates/plugins/plugin-apps/src/linux.rs

213 lines
6.4 KiB
Rust

use std::path::Path;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
use crate::humanize_category;
pub struct FsDesktopEntrySource;
impl FsDesktopEntrySource {
pub fn new() -> Self {
Self
}
}
impl Default for FsDesktopEntrySource {
fn default() -> Self {
Self::new()
}
}
impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new();
let xdg = xdg::BaseDirectories::new();
if let Some(data_home) = xdg.get_data_home() {
dirs.push(data_home.join("applications"));
}
for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
}
let mut entries = Vec::new();
for dir in &dirs {
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(de) = parse_desktop_file(&path) {
entries.push(de);
}
}
}
}
entries
}
}
pub(crate) fn clean_exec(exec: &str) -> String {
// Tokenize respecting double-quoted strings, then filter field codes.
let mut tokens: Vec<String> = Vec::new();
let mut chars = exec.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch == '"' {
// Consume opening quote
chars.next();
let mut token = String::from('"');
while let Some(&c) = chars.peek() {
chars.next();
if c == '"' {
token.push('"');
break;
}
token.push(c);
}
// Strip embedded field codes like %f inside the quoted string
// (between the quotes, before re-assembling)
let inner = &token[1..token.len().saturating_sub(1)];
let cleaned_inner: String = inner
.split_whitespace()
.filter(|s| !is_field_code(s))
.collect::<Vec<_>>()
.join(" ");
tokens.push(format!("\"{cleaned_inner}\""));
} else {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
chars.next();
token.push(c);
}
if !is_field_code(&token) {
tokens.push(token);
}
}
}
tokens.join(" ")
}
fn is_field_code(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
}
pub fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
// Try linicon freedesktop theme traversal
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
for theme in &themes {
if let Some(icon_path) = linicon::lookup_icon(name)
.from_theme(theme)
.with_size(48)
.find_map(|r| r.ok())
{
return Some(icon_path.path.to_string_lossy().into_owned());
}
}
// Fallback to pixmaps
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"),
];
candidates.into_iter().find(|p| Path::new(p).exists())
}
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut category: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut is_application = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line == "[Desktop Entry]" {
in_section = true;
continue;
}
if line.starts_with('[') {
in_section = false;
continue;
}
if !in_section || line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
match key.trim() {
"Name" if name.is_none() => name = Some(value.trim().to_string()),
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
"Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => {
category = value.trim()
.split(';')
.find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim()));
}
"Keywords" if keywords.is_empty() => {
keywords = value.trim()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
}
_ => {}
}
}
}
if !is_application || no_display {
return None;
}
let exec_clean: String = clean_exec(&exec?);
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
category,
keywords,
})
}
#[cfg(test)]
mod exec_tests {
use super::clean_exec;
#[test]
fn strips_bare_field_code() {
assert_eq!(clean_exec("app --file %f"), "app --file");
}
#[test]
fn strips_multiple_field_codes() {
assert_eq!(clean_exec("app %U --flag"), "app --flag");
}
#[test]
fn preserves_quoted_value() {
assert_eq!(clean_exec(r#"app --arg="value" %U"#), r#"app --arg="value""#);
}
#[test]
fn handles_plain_exec() {
assert_eq!(clean_exec("firefox"), "firefox");
}
}