Skip to main content

crabshell_services/
desktop_entries.rs

1//! The installed applications, from their desktop entries: what a
2//! launcher lists, and where a window's app id finds its icon.
3
4mod 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
29/// How long the files stay still after a change before the list is read
30/// again, so a package manager that writes many reads it once.
31const SETTLE: Duration = Duration::from_millis(100);
32/// The largest desktop file read.
33const MAX_FILE: u64 = 64 * 1024;
34/// The most desktop files read. The rest are left out.
35const MAX_ENTRIES: usize = 10_000;
36
37/// An installed application, from its desktop entry.
38#[derive(Clone, Debug, PartialEq)]
39pub struct DesktopEntry {
40  /// Its desktop file ID: the file's path under `applications/`, with
41  /// `/` as `-` and without `.desktop`, such as `org.gnome.Nautilus`.
42  pub id: String,
43  /// Its name, in the user's language when the file has it.
44  pub name: String,
45  /// What kind of program it is, such as `Web Browser`, or `""`.
46  pub generic_name: String,
47  /// A line about it, for a tooltip, or `""`.
48  pub comment: String,
49  /// Its icon, a name in the icon theme or a file's path as the entry
50  /// writes it, or `""`.
51  pub icon: String,
52  /// What it runs, as its `Exec` key writes it, field codes such as `%U`
53  /// and quotes included. `""` for one only D-Bus starts.
54  pub exec: String,
55  /// Whether it runs in a terminal.
56  pub terminal: bool,
57  /// Its categories, such as `Network` and `WebBrowser`.
58  pub categories: Vec<String>,
59  /// More words a search may match, in the user's language when the
60  /// file has them.
61  pub keywords: Vec<String>,
62  /// Whether a launcher leaves it out: for `NoDisplay`, or because
63  /// `OnlyShowIn` or `NotShowIn` leave out this desktop. A window's icon
64  /// may still come from it.
65  pub no_display: bool,
66  /// The window class its windows have, if the entry says, or `""`.
67  pub startup_wm_class: String,
68  /// The directory it runs in, if not the shell's.
69  pub working_dir: Option<PathBuf>,
70  /// What else it can do, such as open a private window, in the
71  /// entry's order.
72  pub actions: Vec<DesktopAction>,
73  /// The desktop file it was read from.
74  pub path: PathBuf,
75}
76
77/// Something more an application can do, as a launcher's menu offers it.
78#[derive(Clone, Debug, PartialEq)]
79pub struct DesktopAction {
80  /// Its name in the desktop file, such as `new-window`.
81  pub id: String,
82  /// What a menu shows, in the user's language when the file has it.
83  pub name: String,
84  /// Its icon, as [`DesktopEntry::icon`], or `""`.
85  pub icon: String,
86  /// What it runs, as [`DesktopEntry::exec`].
87  pub exec: String,
88}
89
90/// Where the desktop entries are, and what picks among them. Tests point
91/// it at their own directories.
92#[derive(Default)]
93pub(crate) struct Sources {
94  /// Each `applications` directory, the user's first.
95  pub(crate) dirs: Vec<PathBuf>,
96  /// The desktops `XDG_CURRENT_DESKTOP` names, such as `sway`.
97  pub(crate) desktops: Vec<String>,
98  /// The locale for messages, from `LC_ALL`, `LC_MESSAGES` or `LANG`.
99  pub(crate) locale: String,
100  /// `PATH`'s directories, where `TryExec` and `xdg-terminal-exec` are
101  /// looked for.
102  pub(crate) path: Vec<PathBuf>,
103}
104
105impl Sources {
106  /// The user's: the XDG data directories, `XDG_CURRENT_DESKTOP`, the
107  /// locale and `PATH`.
108  pub(crate) fn system() -> Sources {
109    let var = |name| std::env::var_os(name).filter(|value| !value.is_empty());
110    // The XDG spec ignores relative paths.
111    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/// The installed applications, from `cx.desktop_entries()`. Cloning it
138/// is cheap.
139#[derive(Clone)]
140pub struct DesktopEntries {
141  service: Option<Rc<Service>>,
142  applications: ReadSignal<Vec<DesktopEntry>>,
143}
144
145impl DesktopEntries {
146  /// Every application, sorted by name ignoring case, then by ID. Empty
147  /// until the loop's next turn after the first `cx.desktop_entries()`.
148  pub fn applications(&self) -> ReadSignal<Vec<DesktopEntry>> {
149    self.applications
150  }
151
152  /// The application a window's app id names, as a task bar finds the
153  /// window's icon: the one with that ID, else one whose ID differs only
154  /// in case, else one whose `StartupWMClass` matches ignoring case,
155  /// else one whose ID ends in that part after a dot, as
156  /// `org.gnome.Nautilus` does for `nautilus`. It reads the list as
157  /// `get` does, so a binding that calls it runs again when the list
158  /// changes.
159  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  /// Starts the application with ID `id`, on its own: in a process
167  /// group of its own, with no input or output, and in its `working_dir`
168  /// if it has one. A terminal app runs through `xdg-terminal-exec`.
169  /// What can't start is logged: an unknown ID, a malformed `Exec`, a
170  /// terminal app without `xdg-terminal-exec` on `PATH`, and an app only
171  /// D-Bus starts, which the shell can't do yet.
172  pub fn launch(&self, id: &str) {
173    self.launch_with(id, None);
174  }
175
176  /// Starts one of an application's actions, named by its
177  /// [`DesktopAction::id`], as [`launch`](Self::launch) does.
178  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
214/// Runs `app`, or its `action`, apart from the shell.
215fn 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    // shortcut: no D-Bus activation yet; it would call
232    // org.freedesktop.Application.Activate on the session bus.
233    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  // A failure is logged there.
262  let _ = spawn::spawn(shared, &mut command);
263}
264
265/// `cx.desktop_entries()`.
266pub(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
280/// The desktop entries of one shell, read from the first ask.
281pub(crate) struct Service {
282  shared: Rc<Shared>,
283  state: RefCell<Option<State>>,
284}
285
286struct State {
287  applications: Signal<Vec<DesktopEntry>>,
288  /// A watch on each directory read, and on the parent of each
289  /// `applications` directory that isn't there yet.
290  watches: Vec<Watch>,
291  /// Whether a read waits on a timer.
292  waiting: bool,
293  /// The last change heard while a read waits.
294  changed_at: Option<Instant>,
295  /// The caps logged so far, as each is logged once.
296  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  /// Something changed in a watched directory.
335  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  /// Reads the list on the loop's next turn, or [`SETTLE`] after the
350  /// last change, on one timer that moves while changes come.
351  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  /// Reads every directory and shows what it holds.
383  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    // Gone with the shell.
394    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    // After the new ones, so a directory still there keeps its kernel
428    // watch.
429    drop(old);
430    set_changed(applications, entries);
431  }
432}
433
434/// One read of every directory.
435struct Scan<'a> {
436  service: Weak<Service>,
437  shared: &'a Rc<Shared>,
438  sources: &'a Sources,
439  locales: Vec<String>,
440  watches: Vec<Watch>,
441  /// Each directory read, by device and inode, so one listed twice or
442  /// reached again through a link is read once.
443  visited: HashSet<(u64, u64)>,
444  /// Each ID found so far, since the first file with an ID decides it.
445  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    // Watched for the directory to appear.
456    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  /// Reads the list again on each change in `dir` that `counts`.
468  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    // Before the read, so a change during it is heard.
491    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      // A link made or removed carries no ISDIR, and may lead to a
495      // directory: anything but a written file or a cache counts.
496      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    // So the same file wins each time when two make one ID.
505    paths.sort();
506    for path in paths {
507      // Followed through links, as packages often link their entries in.
508      match path.metadata() {
509        Ok(meta) if meta.is_dir() => self.dir(root, &path),
510        // A regular file only: opening a FIFO would wait for a writer.
511        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    // A path that isn't UTF-8 has no ID.
521    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
558/// The desktop file ID of `path` under `root`: `kde4/konsole.desktop`
559/// is `kde4-konsole`.
560fn 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}