1use std::{
2 collections::HashMap,
3 io::{self, BufRead, BufReader, Read},
4 num::NonZeroU32,
5 os::unix::ffi::OsStrExt,
6 path::{Path, PathBuf},
7 rc::Rc,
8 sync::OnceLock,
9};
10
11mod env;
12
13fn executable_exceptions() -> &'static HashMap<&'static str, &'static str> {
15 static EXECUTABLE_EXCEPTIONS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
16
17 EXECUTABLE_EXCEPTIONS.get_or_init(|| {
18 HashMap::from([
19 ("firefox-bin", "firefox"),
20 ("oosplash", "libreoffice"),
21 ("soffice.bin", "libreoffice"),
22 ("resources-processes", "resources"),
23 ("gnome-terminal-server", "gnome-terminal"),
24 ("chrome", "google-chrome-stable"),
25 ])
26 })
27}
28
29fn app_id_replacement() -> &'static HashMap<&'static str, &'static str> {
30 static APP_ID_REPLACEMENT: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
31
32 APP_ID_REPLACEMENT
33 .get_or_init(|| HashMap::from([("gnome-system-monitor-kde", "org.gnome.SystemMonitor")]))
34}
35
36pub trait Process {
41 fn pid(&self) -> NonZeroU32;
43 fn executable_path(&self) -> Option<PathBuf>;
47 fn name(&self) -> &str;
51}
52
53#[derive(Clone, Debug)]
57pub struct ApplicationEntry {
58 pub id: Rc<str>,
63 pub name: Rc<str>,
67 pub exec: Option<Rc<str>>,
72 pub icon: Option<Rc<str>>,
76}
77
78pub fn installed_apps() -> HashMap<Rc<str>, ApplicationEntry> {
83 installed_apps_impl(env::xdg_data_dirs(), env::path())
84}
85
86pub fn running_apps<'a, P: Process + 'a>(
108 available_applications: &'a HashMap<Rc<str>, ApplicationEntry>,
109 processes: impl IntoIterator<Item = &'a P>,
110) -> Vec<(&'a ApplicationEntry, Vec<NonZeroU32>)> {
111 fn normalize_app_id(id: &str) -> &str {
112 if let Some(real_id) = app_id_replacement().get(id) {
113 *real_id
114 } else {
115 id
116 }
117 }
118
119 let mut running_apps = running_xdg_conformant_apps(available_applications);
120
121 let mut apps_by_proc = running_apps_by_process(available_applications, processes);
122 for (app_id, (app, pids)) in apps_by_proc.drain() {
123 let normalized_id = normalize_app_id(app_id.as_ref());
124 if !running_apps.contains_key(normalized_id) {
125 running_apps.insert(app_id, (app, pids));
126 }
127 }
128 running_apps
129 .values_mut()
130 .map(|(app, pids)| (*app, std::mem::take(pids)))
131 .collect()
132}
133
134fn installed_apps_impl(
135 xdg_data_dirs: &[String],
136 env_path: &[String],
137) -> HashMap<Rc<str>, ApplicationEntry> {
138 let mut result = HashMap::new();
139 let mut buffer = String::new();
140
141 for dir in xdg_data_dirs {
142 let dir = Path::new(&dir).join("applications");
143 if dir.exists() {
144 let dir = match dir.read_dir() {
145 Ok(dc) => dc,
146 Err(e) => {
147 log::warn!("Failed to read directory '{}': {:?}", dir.display(), e);
148 continue;
149 }
150 };
151
152 for entry in dir {
153 let entry = match entry {
154 Ok(e) => e,
155 Err(e) => {
156 log::warn!("Failed to read entry directory entry: {:?}", e);
157 continue;
158 }
159 };
160
161 let path = entry.path();
162 if path
163 .extension()
164 .map(|ext| ext == "desktop")
165 .unwrap_or(false)
166 {
167 match extract_application_info(&path, env_path, &mut buffer) {
168 Ok(app) => {
169 result.insert(app.id.clone(), app);
170 }
171 _ => {}
172 }
173 }
174 }
175 }
176 }
177
178 result
179}
180
181fn extract_application_info(
182 path: &Path,
183 env_path: &[String],
184 buffer: &mut String,
185) -> io::Result<ApplicationEntry> {
186 buffer.clear();
187 std::fs::File::options()
188 .read(true)
189 .open(path)?
190 .read_to_string(buffer)?;
191
192 let desktop_file_content = buffer;
193
194 let mut name = None;
195 let mut exec = None;
196 let mut icon = None;
197
198 let mut desktop_entry_group_found = false;
199 for line in desktop_file_content.split('\n') {
200 if line != "[Desktop Entry]" && !desktop_entry_group_found {
201 continue;
202 }
203
204 if line == "[Desktop Entry]" {
205 desktop_entry_group_found = true;
206 continue;
207 }
208
209 if line.starts_with("NoDisplay=true") {
210 name = None;
211 break;
212 }
213
214 if line.starts_with("Type=") {
215 if line[5..].trim() != "Application" {
216 name = None;
217 break;
218 }
219 }
220
221 if line.starts_with("Name=") {
222 name = Some(&line[5..]);
223 } else if line.starts_with("Exec=") {
224 exec = Some(&line[5..]);
225 } else if line.starts_with("Icon=") {
226 icon = Some(&line[5..]);
227 } else if line.starts_with("[") {
228 break;
229 }
230 }
231
232 if let (Some(name), Some(exec)) = (name, exec) {
233 let Some(file_name) = path.file_name() else {
234 return Err(io::Error::new(
235 io::ErrorKind::NotFound,
236 "The file name of the Desktop file could not be determined",
237 ));
238 };
239 let file_name = file_name.to_string_lossy();
240
241 let app_id = file_name
242 .strip_suffix(".desktop")
243 .map(|id| Rc::<str>::from(id))
244 .unwrap_or(Rc::<str>::from(file_name));
245
246 return Ok(ApplicationEntry {
247 id: app_id,
248 name: Rc::from(name),
249 exec: sanitize_exec(exec, env_path),
250 icon: icon.map(|i| Rc::from(i)),
251 });
252 }
253
254 Err(io::Error::new(
255 io::ErrorKind::InvalidInput,
256 "Desktop file does not describe a valid user-facing application",
257 ))
258}
259
260fn sanitize_exec(exec: &str, env_path: &[String]) -> Option<Rc<str>> {
261 const CMDLINE_PROGRAMS: &[&str] = &[
262 "sh",
263 "ash",
264 "bash",
265 "dash",
266 "fish",
267 "zsh",
268 "powershell",
269 "awk",
270 "ruby",
271 "perl",
272 "lua",
273 "php",
274 "python",
275 "python2",
276 "python2.7",
277 "python3",
278 "node",
279 "nodejs",
280 "java",
281 "dotnet",
282 "arch",
284 "cp",
285 "stty",
286 "base32",
287 "date",
288 "base64",
289 "dd",
290 "basename",
291 "df",
292 "basenc",
293 "expr",
294 "cat",
295 "install",
296 "chcon",
297 "join",
298 "chgrp",
299 "ls",
300 "chmod",
301 "more",
302 "chown",
303 "numfmt",
304 "chroot",
305 "od",
306 "cksum",
307 "pr",
308 "comm",
309 "printf",
310 "csplit",
311 "sort",
312 "cut",
313 "split",
314 "dircolors",
315 "tac",
316 "dirname",
317 "tail",
318 "du",
319 "test",
320 "echo",
321 "env",
322 "expand",
323 "factor",
324 "false",
325 "fmt",
326 "fold",
327 "groups",
328 "hashsum",
329 "head",
330 "hostid",
331 "hostname",
332 "id",
333 "kill",
334 "link",
335 "ln",
336 "logname",
337 "md5sum",
338 "sha1sum",
339 "sha224sum",
340 "sha256sum",
341 "sha384sum",
342 "sha512sum",
343 "mkdir",
344 "mkfifo",
345 "mknod",
346 "mktemp",
347 "mv",
348 "nice",
349 "nl",
350 "nohup",
351 "nproc",
352 "paste",
353 "pathchk",
354 "pinky",
355 "printenv",
356 "ptx",
357 "pwd",
358 "readlink",
359 "realpath",
360 "relpath",
361 "rm",
362 "rmdir",
363 "runcon",
364 "seq",
365 "shred",
366 "shuf",
367 "sleep",
368 "stat",
369 "stdbuf",
370 "sum",
371 "sync",
372 "tee",
373 "timeout",
374 "touch",
375 "tr",
376 "true",
377 "truncate",
378 "tsort",
379 "tty",
380 "uname",
381 "unexpand",
382 "uniq",
383 "unlink",
384 "uptime",
385 "users",
386 "wc",
387 "who",
388 "whoami",
389 "yes",
390 ];
391
392 const LAUNCHERS: &[&str] = &[
393 "distrobox",
394 "distrobox-enter",
395 "toolbx",
396 "toolbx-enter",
397 "toolbox",
398 "toolbox-enter",
399 "flatpak",
400 "snap",
401 "env",
402 ];
403
404 for cmd in exec
405 .split_ascii_whitespace()
406 .map(|item| item.trim())
407 .filter(|item| !item.is_empty() && !item.starts_with('-'))
408 .map(|item| {
409 let path = Path::new(item.trim_start_matches('"').trim_end_matches('"'));
410 return if path.is_absolute() {
411 Some(path.to_owned())
412 } else {
413 for dir in env_path.into_iter() {
414 let path = Path::new(&dir).join(item);
415 if path.exists() {
416 return Some(path);
417 }
418 }
419 None
420 };
421 })
422 .filter_map(|path| path.and_then(|p| p.canonicalize().ok()))
423 .skip_while(|item| {
424 LAUNCHERS.contains(
425 &item
426 .file_name()
427 .unwrap_or_default()
428 .to_string_lossy()
429 .as_ref(),
430 )
431 })
432 {
433 let file_name = cmd.file_name().unwrap_or_default().to_string_lossy();
434 if CMDLINE_PROGRAMS.contains(&file_name.as_ref()) {
435 return None;
436 }
437
438 return Some(Rc::from(cmd.to_string_lossy()));
439 }
440
441 None
442}
443
444fn find_pids_for_cgroup(cgroup_path: &Path) -> Vec<NonZeroU32> {
445 fn find_pids_for_cgroup(cgroup_path: &Path, result: &mut Vec<NonZeroU32>) {
446 let procs = cgroup_path.join("cgroup.procs");
447 if let Ok(file) = std::fs::File::open(procs) {
448 for line in BufReader::new(file).lines() {
449 if let Ok(pid_str) = line.as_ref().map(|l| l.trim()) {
450 if let Ok(pid) = pid_str.parse::<u32>() {
451 if let Some(pid) = NonZeroU32::new(pid) {
452 result.push(pid);
453 }
454 }
455 }
456 }
457 }
458
459 let cgroup_entries = match cgroup_path.read_dir() {
460 Ok(r) => r,
461 Err(_) => {
462 return;
463 }
464 };
465
466 for entry in cgroup_entries.filter_map(|e| e.ok()) {
467 if let Ok(kind) = entry.file_type() {
468 if kind.is_dir() {
469 find_pids_for_cgroup(&entry.path(), result);
470 }
471 }
472 }
473 }
474
475 let mut result = vec![];
476 find_pids_for_cgroup(cgroup_path, &mut result);
477 result.sort_unstable();
478 result
479}
480
481fn app_id(path: &Path) -> Option<Rc<str>> {
482 let dir_name = path.file_name()?.to_string_lossy();
485
486 if dir_name.starts_with("snap.") {
487 let mut app_id = String::new();
488
489 for part in dir_name.split('.').skip(1) {
494 if part == "scope" {
495 break;
496 }
497
498 if !app_id.is_empty() {
499 app_id.push('_');
500 }
501
502 let mut uuid_pos = part.len();
505 let mut counter = 0;
506 for (i, c) in part.as_bytes().iter().enumerate().rev() {
507 if *c == b'-' {
508 counter += 1;
509 }
510
511 if counter == 5 {
512 uuid_pos = i;
513 break;
514 }
515 }
516 app_id.push_str(&part[..uuid_pos]);
517 }
518
519 Some(Rc::from(app_id))
520 } else if dir_name.starts_with("app-") {
521 let extension = path.extension()?.to_string_lossy();
522 let extension = &dir_name[dir_name.len() - extension.len() - 1..];
524 let mut app_id: Option<&str> = None;
525
526 for part in dir_name.split('-').skip(1).filter(|p| !p.is_empty()) {
527 if app_id.is_some() && part.ends_with(extension) {
528 break;
529 }
530
531 app_id = Some(part.trim_end_matches(extension));
532 }
533
534 app_id.map(|s| Rc::from(s.replace("\\x2d", "-")))
535 } else {
536 None
537 }
538}
539
540fn running_xdg_conformant_apps(
541 available_apps: &HashMap<Rc<str>, ApplicationEntry>,
542) -> HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> {
543 let uid = nix::unistd::getuid();
545 let app_slice_dir =
546 format!("/sys/fs/cgroup/user.slice/user-{uid}.slice/user@{uid}.service/app.slice");
547 let app_slice_dir = match Path::new(&app_slice_dir).read_dir() {
548 Ok(r) => r,
549 Err(e) => {
550 log::warn!(
551 "Error reading cgroup information from {}: {}",
552 app_slice_dir,
553 e
554 );
555 return HashMap::new();
556 }
557 };
558
559 let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
560 result.reserve(available_apps.len());
561
562 for entry in app_slice_dir.filter_map(|e| e.ok()).filter(|e| {
563 let file_name = e.file_name();
564 let file_name = file_name.as_bytes();
565 file_name.ends_with(b".slice")
566 || file_name.ends_with(b".scope")
567 || file_name.ends_with(b".service")
568 }) {
569 let path = entry.path();
570
571 if let Some(app_id) = app_id(&path) {
572 let mut pids = find_pids_for_cgroup(&path);
573 if !pids.is_empty() {
574 if let Some(app) = available_apps.get(&app_id) {
575 if let Some((_, existing_pids)) = result.get_mut(&app.id) {
576 existing_pids.extend(pids);
577 existing_pids.sort_unstable();
578 } else {
579 pids.sort_unstable();
580 result.insert(app.id.clone(), (app, pids));
581 }
582 }
583 }
584 }
585 }
586
587 result
588}
589
590fn running_apps_by_process<'a, P: Process + 'a>(
591 available_apps: &'a HashMap<Rc<str>, ApplicationEntry>,
592 processes: impl IntoIterator<Item = &'a P>,
593) -> HashMap<Rc<str>, (&'a ApplicationEntry, Vec<NonZeroU32>)> {
594 let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
595 result.reserve(available_apps.len());
596
597 let apps_by_exec = available_apps
598 .values()
599 .filter_map(|app| {
600 app.exec
601 .as_ref()
602 .map(|exec| (Path::new(exec.as_ref()), app))
603 })
604 .collect::<HashMap<&Path, &ApplicationEntry>>();
605
606 for process in processes.into_iter() {
607 let proc_exec = if let Some(proc_exe) = process.executable_path().clone() {
608 proc_exe
609 } else {
610 env::path()
611 .iter()
612 .filter_map(|dir| {
613 let mut path = Path::new(dir).join(process.name());
614 if !path.exists() {
615 if let Some(alternate_name) = executable_exceptions().get(process.name()) {
616 path = Path::new(dir).join(alternate_name);
617 }
618 }
619
620 if path.exists() {
621 if let Ok(exec) = path.canonicalize() {
622 return Some(exec);
623 }
624 }
625
626 None
627 })
628 .next()
629 .unwrap_or(PathBuf::new())
630 };
631
632 if let Some(app) = apps_by_exec.get(&proc_exec.as_path()) {
633 let app_id = &app.id;
634 if let Some((_, pids)) = result.get_mut(app_id) {
635 pids.push(process.pid());
636 pids.sort_unstable();
637 } else {
638 result.insert(app_id.clone(), (app, vec![process.pid()]));
639 }
640 }
641 }
642
643 result
644}
645
646#[cfg(test)]
647mod tests {
648 use crate::env;
649
650 use super::*;
651
652 #[test]
653 fn test_available_applications() {
654 let result = installed_apps_impl(env::xdg_data_dirs(), env::path());
655 dbg!(&result);
656 }
657
658 #[test]
659 fn test_find_pids_for_cgroup() {
660 let result = find_pids_for_cgroup(Path::new("/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice"));
661 dbg!(&result);
662 }
663
664 #[test]
665 fn test_running_applications_xdg() {
666 let available_apps = installed_apps();
667 let result = running_xdg_conformant_apps(&available_apps);
668 dbg!(&result);
669 }
670
671 #[test]
672 fn test_running_applications_process() {
673 #[derive(Debug)]
674 struct MyProcess {
675 pid: NonZeroU32,
676 name: Rc<str>,
677 exe: Option<Rc<str>>,
678 }
679
680 impl Process for MyProcess {
681 fn pid(&self) -> NonZeroU32 {
682 self.pid
683 }
684
685 fn executable_path(&self) -> Option<PathBuf> {
686 self.exe.as_ref().map(|e| Path::new(e.as_ref()).to_owned())
687 }
688
689 fn name(&self) -> &str {
690 self.name.as_ref()
691 }
692 }
693
694 fn running_processes() -> Vec<MyProcess> {
695 let mut result = vec![];
696
697 let readdir = match Path::new("/proc").read_dir() {
698 Ok(r) => r,
699 Err(_) => {
700 return vec![];
701 }
702 };
703
704 for entry in readdir.filter_map(|e| e.ok()) {
705 let path = entry.path();
706 let mut exe = Rc::<str>::from("");
707 if let Some(pid) = path
708 .file_name()
709 .and_then(|f| f.to_str())
710 .and_then(|f| f.parse().ok())
711 {
712 let bin_path = path.join("exe");
713 if let Ok(bin_path) =
714 std::fs::read_link(&bin_path).and_then(|p| p.canonicalize())
715 {
716 if bin_path.exists() {
717 exe = Rc::from(bin_path.to_string_lossy());
718 }
719 } else {
720 if let Some(bin_path) = std::fs::read_to_string(path.join("cmdline"))
721 .ok()
722 .and_then(|s| match s.split('\0').next() {
723 Some("") => None,
724 Some(s) => Some(s.to_owned()),
725 None => None,
726 })
727 .map(|s| Path::new(&s).to_owned())
728 .and_then(|p| p.canonicalize().ok())
729 {
730 if bin_path.exists() && bin_path.is_file() && bin_path.is_absolute() {
731 exe = Rc::from(bin_path.to_string_lossy());
732 }
733 }
734 }
735
736 let proc_name = path.join("comm");
737 if let Ok(name) = std::fs::read_to_string(&proc_name) {
738 result.push(MyProcess {
739 pid,
740 exe: Some(exe),
741 name: Rc::from(name.trim()),
742 });
743 }
744 }
745 }
746
747 result
748 }
749
750 let available_apps = installed_apps();
751 let processes = running_processes();
752 let result = running_apps_by_process(&available_apps, &processes);
753 dbg!(&result);
754 }
755}