use std::{
collections::HashMap,
io::{self, BufRead, BufReader, Read},
num::NonZeroU32,
os::unix::ffi::OsStrExt,
path::{Path, PathBuf},
rc::Rc,
sync::OnceLock,
};
mod env;
fn executable_exceptions() -> &'static HashMap<&'static str, &'static str> {
static EXECUTABLE_EXCEPTIONS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
EXECUTABLE_EXCEPTIONS.get_or_init(|| {
HashMap::from([
("firefox-bin", "firefox"),
("oosplash", "libreoffice"),
("soffice.bin", "libreoffice"),
("resources-processes", "resources"),
("gnome-terminal-server", "gnome-terminal"),
("chrome", "google-chrome-stable"),
])
})
}
pub trait Process {
fn pid(&self) -> NonZeroU32;
fn executable_path(&self) -> Option<PathBuf>;
fn name(&self) -> &str;
}
#[derive(Clone, Debug)]
pub struct ApplicationEntry {
pub id: Rc<str>,
pub name: Rc<str>,
pub exec: Option<Rc<str>>,
pub icon: Option<Rc<str>>,
}
pub fn installed_apps() -> HashMap<Rc<str>, ApplicationEntry> {
installed_apps_impl(env::xdg_data_dirs(), env::path())
}
pub fn running_apps<'a, P: Process + 'a>(
available_applications: &'a HashMap<Rc<str>, ApplicationEntry>,
processes: impl IntoIterator<Item = &'a P>,
) -> Vec<(&'a ApplicationEntry, Vec<NonZeroU32>)> {
let mut running_apps = running_xdg_conformant_apps(available_applications);
let mut apps_by_proc = running_apps_by_process(available_applications, processes);
for (app_id, (app, pids)) in apps_by_proc.drain() {
if !running_apps.contains_key(&app_id) {
running_apps.insert(app_id, (app, pids));
}
}
running_apps
.values_mut()
.map(|(app, pids)| (*app, std::mem::take(pids)))
.collect()
}
fn installed_apps_impl(
xdg_data_dirs: &[String],
env_path: &[String],
) -> HashMap<Rc<str>, ApplicationEntry> {
let mut result = HashMap::new();
let mut buffer = String::new();
for dir in xdg_data_dirs {
let dir = Path::new(&dir).join("applications");
if dir.exists() {
for entry in dir.read_dir().unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path
.extension()
.map(|ext| ext == "desktop")
.unwrap_or(false)
{
match extract_application_info(&path, env_path, &mut buffer) {
Ok(app) => {
result.insert(app.id.clone(), app);
}
_ => {}
}
}
}
}
}
result
}
fn extract_application_info(
path: &Path,
env_path: &[String],
buffer: &mut String,
) -> io::Result<ApplicationEntry> {
buffer.clear();
std::fs::File::options()
.read(true)
.open(path)?
.read_to_string(buffer)?;
let desktop_file_content = buffer;
let mut name = None;
let mut exec = None;
let mut icon = None;
let mut desktop_entry_group_found = false;
for line in desktop_file_content.split('\n') {
if line != "[Desktop Entry]" && !desktop_entry_group_found {
continue;
}
if line == "[Desktop Entry]" {
desktop_entry_group_found = true;
continue;
}
if line.starts_with("NoDisplay=true") {
name = None;
break;
}
if line.starts_with("Type=") {
if line[5..].trim() != "Application" {
name = None;
break;
}
}
if line.starts_with("Name=") {
name = Some(&line[5..]);
} else if line.starts_with("Exec=") {
exec = Some(&line[5..]);
} else if line.starts_with("Icon=") {
icon = Some(&line[5..]);
} else if line.starts_with("[") {
break;
}
}
if let (Some(name), Some(exec)) = (name, exec) {
let id = Rc::<str>::from(
path.file_name()
.unwrap()
.to_string_lossy()
.trim_end_matches(".desktop"),
);
return Ok(ApplicationEntry {
id,
name: Rc::from(name),
exec: sanitize_exec(exec, env_path),
icon: icon.map(|i| Rc::from(i)),
});
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Desktop file does not describe a valid user-facing application",
))
}
fn sanitize_exec(exec: &str, env_path: &[String]) -> Option<Rc<str>> {
const CMDLINE_PROGRAMS: &[&str] = &[
"sh",
"ash",
"bash",
"dash",
"fish",
"zsh",
"powershell",
"awk",
"ruby",
"perl",
"lua",
"php",
"python",
"python2",
"python2.7",
"python3",
"node",
"nodejs",
"java",
"dotnet",
"arch",
"cp",
"stty",
"base32",
"date",
"base64",
"dd",
"basename",
"df",
"basenc",
"expr",
"cat",
"install",
"chcon",
"join",
"chgrp",
"ls",
"chmod",
"more",
"chown",
"numfmt",
"chroot",
"od",
"cksum",
"pr",
"comm",
"printf",
"csplit",
"sort",
"cut",
"split",
"dircolors",
"tac",
"dirname",
"tail",
"du",
"test",
"echo",
"env",
"expand",
"factor",
"false",
"fmt",
"fold",
"groups",
"hashsum",
"head",
"hostid",
"hostname",
"id",
"kill",
"link",
"ln",
"logname",
"md5sum",
"sha1sum",
"sha224sum",
"sha256sum",
"sha384sum",
"sha512sum",
"mkdir",
"mkfifo",
"mknod",
"mktemp",
"mv",
"nice",
"nl",
"nohup",
"nproc",
"paste",
"pathchk",
"pinky",
"printenv",
"ptx",
"pwd",
"readlink",
"realpath",
"relpath",
"rm",
"rmdir",
"runcon",
"seq",
"shred",
"shuf",
"sleep",
"stat",
"stdbuf",
"sum",
"sync",
"tee",
"timeout",
"touch",
"tr",
"true",
"truncate",
"tsort",
"tty",
"uname",
"unexpand",
"uniq",
"unlink",
"uptime",
"users",
"wc",
"who",
"whoami",
"yes",
];
const LAUNCHERS: &[&str] = &[
"distrobox",
"distrobox-enter",
"toolbx",
"toolbx-enter",
"toolbox",
"toolbox-enter",
"flatpak",
"snap",
"env",
];
for cmd in exec
.split_ascii_whitespace()
.map(|item| item.trim())
.filter(|item| !item.is_empty() && !item.starts_with('-'))
.map(|item| {
let path = Path::new(item.trim_start_matches('"').trim_end_matches('"'));
return if path.is_absolute() {
Some(path.to_owned())
} else {
for dir in env_path.into_iter() {
let path = Path::new(&dir).join(item);
if path.exists() {
return Some(path);
}
}
None
};
})
.filter_map(|path| path.and_then(|p| p.canonicalize().ok()))
.skip_while(|item| {
LAUNCHERS.contains(
&item
.file_name()
.unwrap_or_default()
.to_string_lossy()
.as_ref(),
)
})
{
let file_name = cmd.file_name().unwrap_or_default().to_string_lossy();
if CMDLINE_PROGRAMS.contains(&file_name.as_ref()) {
return None;
}
return Some(Rc::from(cmd.to_string_lossy()));
}
None
}
fn find_pids_for_cgroup(cgroup_path: &Path) -> Vec<NonZeroU32> {
fn find_pids_for_cgroup(cgroup_path: &Path, result: &mut Vec<NonZeroU32>) {
let procs = cgroup_path.join("cgroup.procs");
if let Ok(file) = std::fs::File::open(procs) {
for line in BufReader::new(file).lines() {
if let Ok(pid_str) = line.as_ref().map(|l| l.trim()) {
if let Ok(pid) = pid_str.parse::<u32>() {
if let Some(pid) = NonZeroU32::new(pid) {
result.push(pid);
}
}
}
}
}
let cgroup_entries = match cgroup_path.read_dir() {
Ok(r) => r,
Err(_) => {
return;
}
};
for entry in cgroup_entries.filter_map(|e| e.ok()) {
if let Ok(kind) = entry.file_type() {
if kind.is_dir() {
find_pids_for_cgroup(&entry.path(), result);
}
}
}
}
let mut result = vec![];
find_pids_for_cgroup(cgroup_path, &mut result);
result.sort_unstable();
result
}
fn app_id(path: &Path) -> Option<Rc<str>> {
let dir_name = path.file_name()?.to_string_lossy();
if dir_name.starts_with("snap.") {
let mut app_id = String::new();
for part in dir_name.split('.').skip(1) {
if part == "scope" {
break;
}
if !app_id.is_empty() {
app_id.push('_');
}
let mut uuid_pos = part.len();
let mut counter = 0;
for (i, c) in part.as_bytes().iter().enumerate().rev() {
if *c == b'-' {
counter += 1;
}
if counter == 5 {
uuid_pos = i;
break;
}
}
app_id.push_str(&part[..uuid_pos]);
}
Some(Rc::from(app_id))
} else if dir_name.starts_with("app-") {
let extension = path.extension()?.to_string_lossy();
let extension = &dir_name[dir_name.len() - extension.len() - 1..];
let mut app_id: Option<&str> = None;
for part in dir_name.split('-').skip(1).filter(|p| !p.is_empty()) {
if app_id.is_some() && part.ends_with(extension) {
break;
}
app_id = Some(part.trim_end_matches(extension));
}
app_id.map(|s| Rc::from(s.replace("\\x2d", "-")))
} else {
None
}
}
fn running_xdg_conformant_apps(
available_apps: &HashMap<Rc<str>, ApplicationEntry>,
) -> HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> {
let uid = nix::unistd::getuid();
let app_slice_dir =
format!("/sys/fs/cgroup/user.slice/user-{uid}.slice/user@{uid}.service/app.slice");
let app_slice_dir = match Path::new(&app_slice_dir).read_dir() {
Ok(r) => r,
Err(e) => {
log::error!(
"Error reading cgroup information from {}: {}",
app_slice_dir,
e
);
return HashMap::new();
}
};
let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
result.reserve(available_apps.len());
for entry in app_slice_dir.filter_map(|e| e.ok()).filter(|e| {
let file_name = e.file_name();
let file_name = file_name.as_bytes();
file_name.ends_with(b".slice")
|| file_name.ends_with(b".scope")
|| file_name.ends_with(b".service")
}) {
let path = entry.path();
if let Some(app_id) = app_id(&path) {
let mut pids = find_pids_for_cgroup(&path);
if !pids.is_empty() {
if let Some(app) = available_apps.get(&app_id) {
if let Some((_, existing_pids)) = result.get_mut(&app.id) {
existing_pids.extend(pids);
existing_pids.sort_unstable();
} else {
pids.sort_unstable();
result.insert(app.id.clone(), (app, pids));
}
}
}
}
}
result
}
fn running_apps_by_process<'a, P: Process + 'a>(
available_apps: &'a HashMap<Rc<str>, ApplicationEntry>,
processes: impl IntoIterator<Item = &'a P>,
) -> HashMap<Rc<str>, (&'a ApplicationEntry, Vec<NonZeroU32>)> {
let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
result.reserve(available_apps.len());
let apps_by_exec = available_apps
.values()
.filter_map(|app| {
app.exec
.as_ref()
.map(|exec| (Path::new(exec.as_ref()), app))
})
.collect::<HashMap<&Path, &ApplicationEntry>>();
for process in processes.into_iter() {
let proc_exec = if let Some(proc_exe) = process.executable_path().clone() {
proc_exe
} else {
env::path()
.iter()
.filter_map(|dir| {
let mut path = Path::new(dir).join(process.name());
if !path.exists() {
if let Some(alternate_name) = executable_exceptions().get(process.name()) {
path = Path::new(dir).join(alternate_name);
}
}
if path.exists() {
if let Ok(exec) = path.canonicalize() {
return Some(exec);
}
}
None
})
.next()
.unwrap_or(PathBuf::new())
};
if let Some(app) = apps_by_exec.get(&proc_exec.as_path()) {
let app_id = &app.id;
if let Some((_, pids)) = result.get_mut(app_id) {
pids.push(process.pid());
pids.sort_unstable();
} else {
result.insert(app_id.clone(), (app, vec![process.pid()]));
}
}
}
result
}
#[cfg(test)]
mod tests {
use crate::env;
use super::*;
#[test]
fn test_available_applications() {
let result = installed_apps_impl(env::xdg_data_dirs(), env::path());
dbg!(&result);
assert!(!result.is_empty());
}
#[test]
fn test_find_pids_for_cgroup() {
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"));
dbg!(&result);
}
#[test]
fn test_running_applications_xdg() {
let available_apps = installed_apps();
let result = running_xdg_conformant_apps(&available_apps);
dbg!(&result);
}
#[test]
fn test_running_applications_process() {
#[derive(Debug)]
struct MyProcess {
pid: NonZeroU32,
name: Rc<str>,
exe: Option<Rc<str>>,
}
impl Process for MyProcess {
fn pid(&self) -> NonZeroU32 {
self.pid
}
fn executable_path(&self) -> Option<PathBuf> {
self.exe.as_ref().map(|e| Path::new(e.as_ref()).to_owned())
}
fn name(&self) -> &str {
self.name.as_ref()
}
}
fn running_processes() -> Vec<MyProcess> {
let mut result = vec![];
let readdir = match Path::new("/proc").read_dir() {
Ok(r) => r,
Err(_) => {
return vec![];
}
};
for entry in readdir.filter_map(|e| e.ok()) {
let path = entry.path();
let mut exe = Rc::<str>::from("");
if let Some(pid) = path
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.parse().ok())
{
let bin_path = path.join("exe");
if let Ok(bin_path) =
std::fs::read_link(&bin_path).and_then(|p| p.canonicalize())
{
if bin_path.exists() {
exe = Rc::from(bin_path.to_string_lossy());
}
} else {
if let Some(bin_path) = std::fs::read_to_string(path.join("cmdline"))
.ok()
.and_then(|s| match s.split('\0').next() {
Some("") => None,
Some(s) => Some(s.to_owned()),
None => None,
})
.map(|s| Path::new(&s).to_owned())
.and_then(|p| p.canonicalize().ok())
{
if bin_path.exists() && bin_path.is_file() && bin_path.is_absolute() {
exe = Rc::from(bin_path.to_string_lossy());
}
}
}
let proc_name = path.join("comm");
if let Ok(name) = std::fs::read_to_string(&proc_name) {
result.push(MyProcess {
pid,
exe: Some(exe),
name: Rc::from(name.trim()),
});
}
}
}
result
}
let available_apps = installed_apps();
let processes = running_processes();
let result = running_apps_by_process(&available_apps, &processes);
dbg!(&result);
}
}