Files
greetd-moongreet/src/sessions.rs
T
nevaforget 95f9071987 Resolve helper programs and session dirs without FHS paths
systemctl was invoked as /usr/bin/systemctl, and session directories were
fixed to /usr/share. Both assume an FHS layout and fail on NixOS.

Programs now resolve via PATH; session directories walk XDG_DATA_DIRS with
/usr/share as fallback. Behaviour on Arch is unchanged.
2026-08-07 13:24:38 +02:00

324 lines
9.9 KiB
Rust

// ABOUTME: Session detection — discovers available Wayland and X11 sessions.
// ABOUTME: Parses .desktop files from standard session directories.
use std::fs;
use std::path::{Path, PathBuf};
/// Fallback data directory used when XDG_DATA_DIRS is unset or does not
/// contain the session directory.
const FALLBACK_DATA_DIR: &str = "/usr/share";
/// Build the search path for a session directory.
///
/// Walks XDG_DATA_DIRS as specified by the XDG Base Directory spec and
/// appends `/usr/share` as a fallback, so setups that place session files
/// outside the FHS layout are found as well.
fn session_search_dirs(subdir: &str) -> Vec<PathBuf> {
session_search_dirs_from(&std::env::var("XDG_DATA_DIRS").unwrap_or_default(), subdir)
}
/// Build the search path from an explicit XDG_DATA_DIRS value.
fn session_search_dirs_from(data_dirs: &str, subdir: &str) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = data_dirs
.split(':')
.filter(|s| !s.is_empty())
.map(|d| PathBuf::from(d).join(subdir))
.collect();
let fallback = PathBuf::from(FALLBACK_DATA_DIR).join(subdir);
if !dirs.contains(&fallback) {
dirs.push(fallback);
}
dirs
}
/// Represents an available login session.
#[derive(Debug, Clone)]
pub struct Session {
pub name: String,
pub exec_cmd: String,
#[allow(dead_code)] // Retained for future Wayland-only filtering
pub session_type: String,
}
/// Parse a .desktop file and return a Session, or None if invalid.
fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
let content = fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec_cmd: Option<String> = None;
let mut hidden = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') {
in_section = line == "[Desktop Entry]";
continue;
}
if !in_section {
continue;
}
if let Some(value) = line.strip_prefix("Name=")
&& name.is_none()
{
name = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("Exec=")
&& exec_cmd.is_none()
{
exec_cmd = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("Hidden=") {
hidden = value.eq_ignore_ascii_case("true");
} else if let Some(value) = line.strip_prefix("NoDisplay=") {
no_display = value.eq_ignore_ascii_case("true");
}
}
if hidden || no_display {
log::debug!("Skipping {}: Hidden/NoDisplay entry", path.display());
return None;
}
let name = name.filter(|s| !s.is_empty());
let exec_cmd = exec_cmd.filter(|s| !s.is_empty());
if name.is_none() || exec_cmd.is_none() {
log::debug!("Skipping {}: missing Name={} Exec={}", path.display(),
name.is_some(), exec_cmd.is_some());
return None;
}
let name = name?;
let exec_cmd = exec_cmd?;
Some(Session {
name,
exec_cmd,
session_type: session_type.to_string(),
})
}
/// Discover available sessions from .desktop files.
pub fn get_sessions(
wayland_dirs: Option<&[PathBuf]>,
xsession_dirs: Option<&[PathBuf]>,
) -> Vec<Session> {
let default_wayland = session_search_dirs("wayland-sessions");
let default_xsession = session_search_dirs("xsessions");
let wayland = wayland_dirs.unwrap_or(&default_wayland);
let xsession = xsession_dirs.unwrap_or(&default_xsession);
let mut sessions = Vec::new();
for (dirs, session_type) in [(wayland, "wayland"), (xsession, "x11")] {
for directory in dirs {
let entries = match fs::read_dir(directory) {
Ok(e) => {
log::debug!("Scanning session directory: {}", directory.display());
e
}
Err(_) => continue,
};
let mut paths: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "desktop"))
.collect();
paths.sort();
for path in paths {
if let Some(session) = parse_desktop_file(&path, session_type) {
sessions.push(session);
}
}
}
}
log::debug!("Found {} session(s)", sessions.len());
sessions
}
#[cfg(test)]
mod tests {
use super::*;
fn write_desktop(dir: &Path, name: &str, content: &str) {
fs::write(dir.join(name), content).unwrap();
}
#[test]
fn search_dirs_walk_xdg_data_dirs() {
let dirs = session_search_dirs_from(
"/run/current-system/sw/share:/usr/local/share",
"wayland-sessions",
);
assert_eq!(
dirs,
vec![
PathBuf::from("/run/current-system/sw/share/wayland-sessions"),
PathBuf::from("/usr/local/share/wayland-sessions"),
PathBuf::from("/usr/share/wayland-sessions"),
]
);
}
#[test]
fn search_dirs_fall_back_when_unset() {
let dirs = session_search_dirs_from("", "xsessions");
assert_eq!(dirs, vec![PathBuf::from("/usr/share/xsessions")]);
}
#[test]
fn search_dirs_do_not_duplicate_fallback() {
let dirs = session_search_dirs_from("/usr/share:/opt/share", "wayland-sessions");
assert_eq!(
dirs,
vec![
PathBuf::from("/usr/share/wayland-sessions"),
PathBuf::from("/opt/share/wayland-sessions"),
]
);
}
#[test]
fn search_dirs_skip_empty_segments() {
let dirs = session_search_dirs_from("::/opt/share:", "wayland-sessions");
assert_eq!(
dirs,
vec![
PathBuf::from("/opt/share/wayland-sessions"),
PathBuf::from("/usr/share/wayland-sessions"),
]
);
}
#[test]
fn parse_valid_desktop_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.desktop");
fs::write(
&file,
"[Desktop Entry]\nName=Niri\nExec=niri-session\n",
)
.unwrap();
let session = parse_desktop_file(&file, "wayland").unwrap();
assert_eq!(session.name, "Niri");
assert_eq!(session.exec_cmd, "niri-session");
assert_eq!(session.session_type, "wayland");
}
#[test]
fn parse_desktop_file_missing_name() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.desktop");
fs::write(&file, "[Desktop Entry]\nExec=niri-session\n").unwrap();
assert!(parse_desktop_file(&file, "wayland").is_none());
}
#[test]
fn parse_desktop_file_missing_exec() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.desktop");
fs::write(&file, "[Desktop Entry]\nName=Niri\n").unwrap();
assert!(parse_desktop_file(&file, "wayland").is_none());
}
#[test]
fn parse_desktop_file_wrong_section() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.desktop");
fs::write(
&file,
"[Other Section]\nName=Niri\nExec=niri-session\n",
)
.unwrap();
assert!(parse_desktop_file(&file, "wayland").is_none());
}
#[test]
fn get_sessions_finds_wayland_and_x11() {
let wayland_dir = tempfile::tempdir().unwrap();
let x11_dir = tempfile::tempdir().unwrap();
write_desktop(
wayland_dir.path(),
"niri.desktop",
"[Desktop Entry]\nName=Niri\nExec=niri-session\n",
);
write_desktop(
x11_dir.path(),
"i3.desktop",
"[Desktop Entry]\nName=i3\nExec=i3\n",
);
let wayland_paths = vec![wayland_dir.path().to_path_buf()];
let x11_paths = vec![x11_dir.path().to_path_buf()];
let sessions = get_sessions(Some(&wayland_paths), Some(&x11_paths));
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].name, "Niri");
assert_eq!(sessions[0].session_type, "wayland");
assert_eq!(sessions[1].name, "i3");
assert_eq!(sessions[1].session_type, "x11");
}
#[test]
fn get_sessions_skips_missing_dirs() {
let sessions = get_sessions(
Some(&[PathBuf::from("/nonexistent")]),
Some(&[PathBuf::from("/also-nonexistent")]),
);
assert!(sessions.is_empty());
}
#[test]
fn get_sessions_skips_invalid_files() {
let dir = tempfile::tempdir().unwrap();
write_desktop(
dir.path(),
"valid.desktop",
"[Desktop Entry]\nName=Valid\nExec=valid\n",
);
write_desktop(
dir.path(),
"invalid.desktop",
"[Desktop Entry]\nName=Invalid\n",
);
// Non-.desktop file
fs::write(dir.path().join("readme.txt"), "not a session").unwrap();
let paths = vec![dir.path().to_path_buf()];
let sessions = get_sessions(Some(&paths), Some(&[]));
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].name, "Valid");
}
#[test]
fn sessions_sorted_alphabetically() {
let dir = tempfile::tempdir().unwrap();
write_desktop(
dir.path(),
"z-sway.desktop",
"[Desktop Entry]\nName=Sway\nExec=sway\n",
);
write_desktop(
dir.path(),
"a-niri.desktop",
"[Desktop Entry]\nName=Niri\nExec=niri-session\n",
);
let paths = vec![dir.path().to_path_buf()];
let sessions = get_sessions(Some(&paths), Some(&[]));
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].name, "Niri");
assert_eq!(sessions[1].name, "Sway");
}
}