1mod parse;
5#[cfg(test)]
6mod tests;
7
8use std::cell::RefCell;
9use std::collections::HashSet;
10use std::ffi::OsStr;
11use std::fs::File;
12use std::io::Read;
13use std::os::unix::fs::MetadataExt;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::rc::{Rc, Weak};
17use std::sync::Once;
18use std::time::{Duration, Instant};
19
20use calloop::timer::TimeoutAction;
21use crabshell_reactive::{ReadSignal, Signal, untrack};
22use crabshell_ui::Cx;
23use inotify::EventMask;
24
25use crate::dbus::set_changed;
26use crate::watch::{self, Change, Watch};
27use crate::{Shared, spawn};
28
29const SETTLE: Duration = Duration::from_millis(100);
32const MAX_FILE: u64 = 64 * 1024;
34const MAX_ENTRIES: usize = 10_000;
36
37#[derive(Clone, Debug, PartialEq)]
39pub struct DesktopEntry {
40 pub id: String,
43 pub name: String,
45 pub generic_name: String,
47 pub comment: String,
49 pub icon: String,
52 pub exec: String,
55 pub terminal: bool,
57 pub categories: Vec<String>,
59 pub keywords: Vec<String>,
62 pub no_display: bool,
66 pub startup_wm_class: String,
68 pub working_dir: Option<PathBuf>,
70 pub actions: Vec<DesktopAction>,
73 pub path: PathBuf,
75}
76
77#[derive(Clone, Debug, PartialEq)]
79pub struct DesktopAction {
80 pub id: String,
82 pub name: String,
84 pub icon: String,
86 pub exec: String,
88}
89
90#[derive(Default)]
93pub(crate) struct Sources {
94 pub(crate) dirs: Vec<PathBuf>,
96 pub(crate) desktops: Vec<String>,
98 pub(crate) locale: String,
100 pub(crate) path: Vec<PathBuf>,
103}
104
105impl Sources {
106 pub(crate) fn system() -> Sources {
109 let var = |name| std::env::var_os(name).filter(|value| !value.is_empty());
110 let home = var("XDG_DATA_HOME")
112 .map(PathBuf::from)
113 .filter(|dir| dir.is_absolute())
114 .or_else(|| var("HOME").map(|home| PathBuf::from(home).join(".local/share")));
115 let data_dirs = var("XDG_DATA_DIRS").unwrap_or_else(|| "/usr/local/share:/usr/share".into());
116 let dirs = home.into_iter().chain(std::env::split_paths(&data_dirs));
117 let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
118 let locale = ["LC_ALL", "LC_MESSAGES", "LANG"]
119 .into_iter()
120 .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()));
121 Sources {
122 dirs: dirs
123 .filter(|dir| dir.is_absolute())
124 .map(|dir| dir.join("applications"))
125 .collect(),
126 desktops: desktops
127 .split(':')
128 .filter(|desktop| !desktop.is_empty())
129 .map(str::to_owned)
130 .collect(),
131 locale: locale.unwrap_or_default(),
132 path: var("PATH").map_or_else(Vec::new, |path| std::env::split_paths(&path).collect()),
133 }
134 }
135}
136
137#[derive(Clone)]
140pub struct DesktopEntries {
141 service: Option<Rc<Service>>,
142 applications: ReadSignal<Vec<DesktopEntry>>,
143}
144
145impl DesktopEntries {
146 pub fn applications(&self) -> ReadSignal<Vec<DesktopEntry>> {
149 self.applications
150 }
151
152 pub fn lookup(&self, app_id: &str) -> Option<DesktopEntry> {
160 let found = self
161 .applications
162 .try_with(|apps| lookup(apps, app_id).cloned());
163 found.flatten()
164 }
165
166 pub fn launch(&self, id: &str) {
173 self.launch_with(id, None);
174 }
175
176 pub fn launch_action(&self, id: &str, action: &str) {
179 self.launch_with(id, Some(action));
180 }
181
182 fn launch_with(&self, id: &str, action: Option<&str>) {
183 let Some(service) = &self.service else {
184 return;
185 };
186 let find = |apps: &Vec<DesktopEntry>| apps.iter().find(|app| app.id == id).cloned();
187 match untrack(|| self.applications.try_with(find)).flatten() {
188 Some(app) => launch(&service.shared, &app, action),
189 None => tracing::warn!(id, "no application has this ID, so nothing starts"),
190 }
191 }
192}
193
194fn lookup<'a>(apps: &'a [DesktopEntry], app_id: &str) -> Option<&'a DesktopEntry> {
195 if app_id.is_empty() {
196 return None;
197 }
198 let last_part = |app: &&DesktopEntry| {
199 let part = app.id.rsplit('.').next();
200 part.is_some_and(|part| part.eq_ignore_ascii_case(app_id))
201 };
202 apps
203 .iter()
204 .find(|app| app.id == app_id)
205 .or_else(|| apps.iter().find(|app| app.id.eq_ignore_ascii_case(app_id)))
206 .or_else(|| {
207 apps
208 .iter()
209 .find(|app| app.startup_wm_class.eq_ignore_ascii_case(app_id))
210 })
211 .or_else(|| apps.iter().find(last_part))
212}
213
214fn launch(shared: &Rc<Shared>, app: &DesktopEntry, action: Option<&str>) {
216 let id = app.id.as_str();
217 let exec = match action {
218 None => &app.exec,
219 Some(action) => match app.actions.iter().find(|found| found.id == action) {
220 Some(found) => &found.exec,
221 None => {
222 return tracing::warn!(
223 id,
224 action,
225 "the application has no such action, so nothing starts"
226 );
227 }
228 },
229 };
230 if exec.is_empty() {
231 return tracing::warn!(
234 id,
235 "only D-Bus starts this application, which the shell can't yet, so nothing starts"
236 );
237 }
238 let argv = match parse::command_line(exec, app) {
239 Ok(argv) => argv,
240 Err(why) => return tracing::warn!(id, exec, why, "a malformed Exec, so nothing starts"),
241 };
242 let mut command = if app.terminal {
243 let path = &shared.config.desktop_entries.path;
244 let Some(terminal) = parse::find("xdg-terminal-exec", path) else {
245 return tracing::warn!(
246 id,
247 "a terminal application, and xdg-terminal-exec isn't on PATH, so nothing starts"
248 );
249 };
250 let mut command = Command::new(terminal);
251 command.args(&argv);
252 command
253 } else {
254 let mut command = Command::new(&argv[0]);
255 command.args(&argv[1..]);
256 command
257 };
258 if let Some(dir) = &app.working_dir {
259 command.current_dir(dir);
260 }
261 let _ = spawn::spawn(shared, &mut command);
263}
264
265pub(crate) fn desktop_entries(cx: &Cx) -> DesktopEntries {
267 let Some(service) = cx.service::<Service>() else {
268 static LOGGED: Once = Once::new();
269 LOGGED.call_once(|| {
270 tracing::warn!("cx.desktop_entries() needs the services the shell provides; it stays empty");
271 });
272 return DesktopEntries {
273 service: None,
274 applications: cx.signal(Vec::new()).read_only(),
275 };
276 };
277 service.start()
278}
279
280pub(crate) struct Service {
282 shared: Rc<Shared>,
283 state: RefCell<Option<State>>,
284}
285
286struct State {
287 applications: Signal<Vec<DesktopEntry>>,
288 watches: Vec<Watch>,
291 waiting: bool,
293 changed_at: Option<Instant>,
295 logged: Logged,
297}
298
299#[derive(Clone, Copy, Default)]
300struct Logged {
301 big: bool,
302 long: bool,
303 many: bool,
304}
305
306impl Service {
307 pub(crate) fn new(shared: Rc<Shared>) -> Service {
308 Service {
309 shared,
310 state: RefCell::new(None),
311 }
312 }
313
314 fn start(self: &Rc<Self>) -> DesktopEntries {
315 let started = self.state.borrow().as_ref().map(|state| state.applications);
316 let applications = started.unwrap_or_else(|| {
317 let applications = self.shared.root.signal(Vec::new());
318 *self.state.borrow_mut() = Some(State {
319 applications,
320 watches: Vec::new(),
321 waiting: false,
322 changed_at: None,
323 logged: Logged::default(),
324 });
325 self.read_later();
326 applications
327 });
328 DesktopEntries {
329 service: Some(Rc::clone(self)),
330 applications: applications.read_only(),
331 }
332 }
333
334 fn changed(self: &Rc<Self>) {
336 let waiting = {
337 let mut state = self.state.borrow_mut();
338 let Some(state) = state.as_mut() else {
339 return;
340 };
341 state.changed_at = Some(Instant::now());
342 state.waiting
343 };
344 if !waiting {
345 self.read_later();
346 }
347 }
348
349 fn read_later(self: &Rc<Self>) {
352 let Some(event_loop) = self.shared.event_loop() else {
353 return;
354 };
355 let settled = |state: &State| state.changed_at.map(|at| at + SETTLE);
356 let at = {
357 let mut state = self.state.borrow_mut();
358 let Some(state) = state.as_mut() else {
359 return;
360 };
361 state.waiting = true;
362 settled(state).unwrap_or_else(Instant::now)
363 };
364 let service = Rc::clone(self);
365 let tick = Box::new(move || {
366 let at = service.state.borrow().as_ref().and_then(settled);
367 match at {
368 Some(at) if at > Instant::now() => TimeoutAction::ToInstant(at),
369 _ => {
370 service.read();
371 TimeoutAction::Drop
372 }
373 }
374 });
375 if event_loop.insert_timer(at, tick).is_none()
376 && let Some(state) = self.state.borrow_mut().as_mut()
377 {
378 state.waiting = false;
379 }
380 }
381
382 fn read(self: &Rc<Self>) {
384 let (applications, logged) = {
385 let mut state = self.state.borrow_mut();
386 let Some(state) = state.as_mut() else {
387 return;
388 };
389 state.waiting = false;
390 state.changed_at = None;
391 (state.applications, state.logged)
392 };
393 if applications.try_with(|_| ()).is_none() {
395 return;
396 }
397 let sources = &self.shared.config.desktop_entries;
398 let mut scan = Scan {
399 service: Rc::downgrade(self),
400 shared: &self.shared,
401 sources,
402 locales: parse::locales(&sources.locale),
403 watches: Vec::new(),
404 visited: HashSet::new(),
405 seen: HashSet::new(),
406 entries: Vec::new(),
407 logged,
408 };
409 for root in &sources.dirs {
410 scan.root(root);
411 }
412 let Scan {
413 mut entries,
414 watches,
415 logged,
416 ..
417 } = scan;
418 entries.sort_by_cached_key(|app| (app.name.to_lowercase(), app.id.clone()));
419 let old = {
420 let mut state = self.state.borrow_mut();
421 let Some(state) = state.as_mut() else {
422 return;
423 };
424 state.logged = logged;
425 std::mem::replace(&mut state.watches, watches)
426 };
427 drop(old);
430 set_changed(applications, entries);
431 }
432}
433
434struct Scan<'a> {
436 service: Weak<Service>,
437 shared: &'a Rc<Shared>,
438 sources: &'a Sources,
439 locales: Vec<String>,
440 watches: Vec<Watch>,
441 visited: HashSet<(u64, u64)>,
444 seen: HashSet<String>,
446 entries: Vec<DesktopEntry>,
447 logged: Logged,
448}
449
450impl Scan<'_> {
451 fn root(&mut self, root: &Path) {
452 if root.is_dir() {
453 return self.dir(root, root);
454 }
455 if let (Some(parent), Some(name)) = (
457 root.parent().filter(|parent| parent.is_dir()),
458 root.file_name(),
459 ) {
460 let name = name.to_owned();
461 self.watch(parent, move |change| {
462 change.name.as_ref().is_none_or(|changed| *changed == name)
463 });
464 }
465 }
466
467 fn watch(&mut self, dir: &Path, counts: impl Fn(&Change) -> bool + 'static) {
469 let service = self.service.clone();
470 let on_change = move |change: &Change| {
471 if let Some(service) = service.upgrade().filter(|_| counts(change)) {
472 service.changed();
473 }
474 };
475 match watch::watch(self.shared, dir, on_change) {
476 Ok(watch) => self.watches.push(watch),
477 Err(error) => {
478 tracing::warn!(%error, dir = %dir.display(), "can't watch a directory of applications")
479 }
480 }
481 }
482
483 fn dir(&mut self, root: &Path, dir: &Path) {
484 let Ok(meta) = dir.metadata() else {
485 return;
486 };
487 if !self.visited.insert((meta.dev(), meta.ino())) {
488 return;
489 }
490 self.watch(dir, |change| {
492 let name = change.name.as_ref().map(Path::new);
493 let entry = name.is_some_and(|name| name.extension() == Some(OsStr::new("desktop")));
494 let linked = !change.mask.contains(EventMask::CLOSE_WRITE)
497 && name.is_some_and(|name| name.extension() != Some(OsStr::new("cache")));
498 name.is_none() || entry || linked || change.mask.contains(EventMask::ISDIR)
499 });
500 let Ok(read) = std::fs::read_dir(dir) else {
501 return;
502 };
503 let mut paths: Vec<PathBuf> = read.flatten().map(|entry| entry.path()).collect();
504 paths.sort();
506 for path in paths {
507 match path.metadata() {
509 Ok(meta) if meta.is_dir() => self.dir(root, &path),
510 Ok(meta) if meta.is_file() && path.extension() == Some(OsStr::new("desktop")) => {
512 self.file(root, &path);
513 }
514 _ => {}
515 }
516 }
517 }
518
519 fn file(&mut self, root: &Path, path: &Path) {
520 let Some(id) = id(root, path) else {
522 return;
523 };
524 if self.seen.contains(&id) {
525 return;
526 }
527 let shown = path.display();
528 if self.seen.len() == MAX_ENTRIES {
529 if !std::mem::replace(&mut self.logged.many, true) {
530 tracing::warn!(path = %shown, "over {MAX_ENTRIES} desktop entries; the rest are left out");
531 }
532 return;
533 }
534 self.seen.insert(id.clone());
535 let mut bytes = Vec::new();
536 let read = File::open(path).and_then(|file| file.take(MAX_FILE + 1).read_to_end(&mut bytes));
537 if let Err(error) = read {
538 return tracing::info!(%error, path = %shown, "can't read a desktop entry");
539 }
540 if bytes.len() as u64 > MAX_FILE {
541 if !std::mem::replace(&mut self.logged.big, true) {
542 tracing::warn!(path = %shown, "a desktop entry over 64 KiB is left out");
543 }
544 return;
545 }
546 let Ok(text) = String::from_utf8(bytes) else {
547 return tracing::info!(path = %shown, "a desktop entry that isn't UTF-8 is left out");
548 };
549 let (groups, long) = parse::groups(&text);
550 if long && !std::mem::replace(&mut self.logged.long, true) {
551 tracing::warn!(path = %shown, "a line over 4 KiB in a desktop entry is left out");
552 }
553 let entry = parse::entry(id, path.to_owned(), &groups, self.sources, &self.locales);
554 self.entries.extend(entry);
555 }
556}
557
558fn id(root: &Path, path: &Path) -> Option<String> {
561 let relative = path.strip_prefix(root).ok()?.to_str()?;
562 Some(relative.strip_suffix(".desktop")?.replace('/', "-"))
563}