mod parse;
#[cfg(test)]
mod tests;
use std::cell::RefCell;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::{Rc, Weak};
use std::sync::Once;
use std::time::{Duration, Instant};
use calloop::timer::TimeoutAction;
use crabshell_reactive::{ReadSignal, Signal, untrack};
use crabshell_ui::Cx;
use inotify::EventMask;
use crate::dbus::set_changed;
use crate::watch::{self, Change, Watch};
use crate::{Shared, spawn};
const SETTLE: Duration = Duration::from_millis(100);
const MAX_FILE: u64 = 64 * 1024;
const MAX_ENTRIES: usize = 10_000;
#[derive(Clone, Debug, PartialEq)]
pub struct DesktopEntry {
pub id: String,
pub name: String,
pub generic_name: String,
pub comment: String,
pub icon: String,
pub exec: String,
pub terminal: bool,
pub categories: Vec<String>,
pub keywords: Vec<String>,
pub no_display: bool,
pub startup_wm_class: String,
pub working_dir: Option<PathBuf>,
pub actions: Vec<DesktopAction>,
pub path: PathBuf,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DesktopAction {
pub id: String,
pub name: String,
pub icon: String,
pub exec: String,
}
#[derive(Default)]
pub(crate) struct Sources {
pub(crate) dirs: Vec<PathBuf>,
pub(crate) desktops: Vec<String>,
pub(crate) locale: String,
pub(crate) path: Vec<PathBuf>,
}
impl Sources {
pub(crate) fn system() -> Sources {
let var = |name| std::env::var_os(name).filter(|value| !value.is_empty());
let home = var("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|dir| dir.is_absolute())
.or_else(|| var("HOME").map(|home| PathBuf::from(home).join(".local/share")));
let data_dirs = var("XDG_DATA_DIRS").unwrap_or_else(|| "/usr/local/share:/usr/share".into());
let dirs = home.into_iter().chain(std::env::split_paths(&data_dirs));
let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"]
.into_iter()
.find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()));
Sources {
dirs: dirs
.filter(|dir| dir.is_absolute())
.map(|dir| dir.join("applications"))
.collect(),
desktops: desktops
.split(':')
.filter(|desktop| !desktop.is_empty())
.map(str::to_owned)
.collect(),
locale: locale.unwrap_or_default(),
path: var("PATH").map_or_else(Vec::new, |path| std::env::split_paths(&path).collect()),
}
}
}
#[derive(Clone)]
pub struct DesktopEntries {
service: Option<Rc<Service>>,
applications: ReadSignal<Vec<DesktopEntry>>,
}
impl DesktopEntries {
pub fn applications(&self) -> ReadSignal<Vec<DesktopEntry>> {
self.applications
}
pub fn lookup(&self, app_id: &str) -> Option<DesktopEntry> {
let found = self
.applications
.try_with(|apps| lookup(apps, app_id).cloned());
found.flatten()
}
pub fn launch(&self, id: &str) {
self.launch_with(id, None);
}
pub fn launch_action(&self, id: &str, action: &str) {
self.launch_with(id, Some(action));
}
fn launch_with(&self, id: &str, action: Option<&str>) {
let Some(service) = &self.service else {
return;
};
let find = |apps: &Vec<DesktopEntry>| apps.iter().find(|app| app.id == id).cloned();
match untrack(|| self.applications.try_with(find)).flatten() {
Some(app) => launch(&service.shared, &app, action),
None => tracing::warn!(id, "no application has this ID, so nothing starts"),
}
}
}
fn lookup<'a>(apps: &'a [DesktopEntry], app_id: &str) -> Option<&'a DesktopEntry> {
if app_id.is_empty() {
return None;
}
let last_part = |app: &&DesktopEntry| {
let part = app.id.rsplit('.').next();
part.is_some_and(|part| part.eq_ignore_ascii_case(app_id))
};
apps
.iter()
.find(|app| app.id == app_id)
.or_else(|| apps.iter().find(|app| app.id.eq_ignore_ascii_case(app_id)))
.or_else(|| {
apps
.iter()
.find(|app| app.startup_wm_class.eq_ignore_ascii_case(app_id))
})
.or_else(|| apps.iter().find(last_part))
}
fn launch(shared: &Rc<Shared>, app: &DesktopEntry, action: Option<&str>) {
let id = app.id.as_str();
let exec = match action {
None => &app.exec,
Some(action) => match app.actions.iter().find(|found| found.id == action) {
Some(found) => &found.exec,
None => {
return tracing::warn!(
id,
action,
"the application has no such action, so nothing starts"
);
}
},
};
if exec.is_empty() {
return tracing::warn!(
id,
"only D-Bus starts this application, which the shell can't yet, so nothing starts"
);
}
let argv = match parse::command_line(exec, app) {
Ok(argv) => argv,
Err(why) => return tracing::warn!(id, exec, why, "a malformed Exec, so nothing starts"),
};
let mut command = if app.terminal {
let path = &shared.config.desktop_entries.path;
let Some(terminal) = parse::find("xdg-terminal-exec", path) else {
return tracing::warn!(
id,
"a terminal application, and xdg-terminal-exec isn't on PATH, so nothing starts"
);
};
let mut command = Command::new(terminal);
command.args(&argv);
command
} else {
let mut command = Command::new(&argv[0]);
command.args(&argv[1..]);
command
};
if let Some(dir) = &app.working_dir {
command.current_dir(dir);
}
let _ = spawn::spawn(shared, &mut command);
}
pub(crate) fn desktop_entries(cx: &Cx) -> DesktopEntries {
let Some(service) = cx.service::<Service>() else {
static LOGGED: Once = Once::new();
LOGGED.call_once(|| {
tracing::warn!("cx.desktop_entries() needs the services the shell provides; it stays empty");
});
return DesktopEntries {
service: None,
applications: cx.signal(Vec::new()).read_only(),
};
};
service.start()
}
pub(crate) struct Service {
shared: Rc<Shared>,
state: RefCell<Option<State>>,
}
struct State {
applications: Signal<Vec<DesktopEntry>>,
watches: Vec<Watch>,
waiting: bool,
changed_at: Option<Instant>,
logged: Logged,
}
#[derive(Clone, Copy, Default)]
struct Logged {
big: bool,
long: bool,
many: bool,
}
impl Service {
pub(crate) fn new(shared: Rc<Shared>) -> Service {
Service {
shared,
state: RefCell::new(None),
}
}
fn start(self: &Rc<Self>) -> DesktopEntries {
let started = self.state.borrow().as_ref().map(|state| state.applications);
let applications = started.unwrap_or_else(|| {
let applications = self.shared.root.signal(Vec::new());
*self.state.borrow_mut() = Some(State {
applications,
watches: Vec::new(),
waiting: false,
changed_at: None,
logged: Logged::default(),
});
self.read_later();
applications
});
DesktopEntries {
service: Some(Rc::clone(self)),
applications: applications.read_only(),
}
}
fn changed(self: &Rc<Self>) {
let waiting = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
state.changed_at = Some(Instant::now());
state.waiting
};
if !waiting {
self.read_later();
}
}
fn read_later(self: &Rc<Self>) {
let Some(event_loop) = self.shared.event_loop() else {
return;
};
let settled = |state: &State| state.changed_at.map(|at| at + SETTLE);
let at = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
state.waiting = true;
settled(state).unwrap_or_else(Instant::now)
};
let service = Rc::clone(self);
let tick = Box::new(move || {
let at = service.state.borrow().as_ref().and_then(settled);
match at {
Some(at) if at > Instant::now() => TimeoutAction::ToInstant(at),
_ => {
service.read();
TimeoutAction::Drop
}
}
});
if event_loop.insert_timer(at, tick).is_none()
&& let Some(state) = self.state.borrow_mut().as_mut()
{
state.waiting = false;
}
}
fn read(self: &Rc<Self>) {
let (applications, logged) = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
state.waiting = false;
state.changed_at = None;
(state.applications, state.logged)
};
if applications.try_with(|_| ()).is_none() {
return;
}
let sources = &self.shared.config.desktop_entries;
let mut scan = Scan {
service: Rc::downgrade(self),
shared: &self.shared,
sources,
locales: parse::locales(&sources.locale),
watches: Vec::new(),
visited: HashSet::new(),
seen: HashSet::new(),
entries: Vec::new(),
logged,
};
for root in &sources.dirs {
scan.root(root);
}
let Scan {
mut entries,
watches,
logged,
..
} = scan;
entries.sort_by_cached_key(|app| (app.name.to_lowercase(), app.id.clone()));
let old = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
state.logged = logged;
std::mem::replace(&mut state.watches, watches)
};
drop(old);
set_changed(applications, entries);
}
}
struct Scan<'a> {
service: Weak<Service>,
shared: &'a Rc<Shared>,
sources: &'a Sources,
locales: Vec<String>,
watches: Vec<Watch>,
visited: HashSet<(u64, u64)>,
seen: HashSet<String>,
entries: Vec<DesktopEntry>,
logged: Logged,
}
impl Scan<'_> {
fn root(&mut self, root: &Path) {
if root.is_dir() {
return self.dir(root, root);
}
if let (Some(parent), Some(name)) = (
root.parent().filter(|parent| parent.is_dir()),
root.file_name(),
) {
let name = name.to_owned();
self.watch(parent, move |change| {
change.name.as_ref().is_none_or(|changed| *changed == name)
});
}
}
fn watch(&mut self, dir: &Path, counts: impl Fn(&Change) -> bool + 'static) {
let service = self.service.clone();
let on_change = move |change: &Change| {
if let Some(service) = service.upgrade().filter(|_| counts(change)) {
service.changed();
}
};
match watch::watch(self.shared, dir, on_change) {
Ok(watch) => self.watches.push(watch),
Err(error) => {
tracing::warn!(%error, dir = %dir.display(), "can't watch a directory of applications")
}
}
}
fn dir(&mut self, root: &Path, dir: &Path) {
let Ok(meta) = dir.metadata() else {
return;
};
if !self.visited.insert((meta.dev(), meta.ino())) {
return;
}
self.watch(dir, |change| {
let name = change.name.as_ref().map(Path::new);
let entry = name.is_some_and(|name| name.extension() == Some(OsStr::new("desktop")));
let linked = !change.mask.contains(EventMask::CLOSE_WRITE)
&& name.is_some_and(|name| name.extension() != Some(OsStr::new("cache")));
name.is_none() || entry || linked || change.mask.contains(EventMask::ISDIR)
});
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = read.flatten().map(|entry| entry.path()).collect();
paths.sort();
for path in paths {
match path.metadata() {
Ok(meta) if meta.is_dir() => self.dir(root, &path),
Ok(meta) if meta.is_file() && path.extension() == Some(OsStr::new("desktop")) => {
self.file(root, &path);
}
_ => {}
}
}
}
fn file(&mut self, root: &Path, path: &Path) {
let Some(id) = id(root, path) else {
return;
};
if self.seen.contains(&id) {
return;
}
let shown = path.display();
if self.seen.len() == MAX_ENTRIES {
if !std::mem::replace(&mut self.logged.many, true) {
tracing::warn!(path = %shown, "over {MAX_ENTRIES} desktop entries; the rest are left out");
}
return;
}
self.seen.insert(id.clone());
let mut bytes = Vec::new();
let read = File::open(path).and_then(|file| file.take(MAX_FILE + 1).read_to_end(&mut bytes));
if let Err(error) = read {
return tracing::info!(%error, path = %shown, "can't read a desktop entry");
}
if bytes.len() as u64 > MAX_FILE {
if !std::mem::replace(&mut self.logged.big, true) {
tracing::warn!(path = %shown, "a desktop entry over 64 KiB is left out");
}
return;
}
let Ok(text) = String::from_utf8(bytes) else {
return tracing::info!(path = %shown, "a desktop entry that isn't UTF-8 is left out");
};
let (groups, long) = parse::groups(&text);
if long && !std::mem::replace(&mut self.logged.long, true) {
tracing::warn!(path = %shown, "a line over 4 KiB in a desktop entry is left out");
}
let entry = parse::entry(id, path.to_owned(), &groups, self.sources, &self.locales);
self.entries.extend(entry);
}
}
fn id(root: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?.to_str()?;
Some(relative.strip_suffix(".desktop")?.replace('/', "-"))
}