// 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}; const DEFAULT_WAYLAND_DIRS: &[&str] = &["/usr/share/wayland-sessions"]; const DEFAULT_XSESSION_DIRS: &[&str] = &["/usr/share/xsessions"]; /// 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 { let content = fs::read_to_string(path).ok()?; let mut in_section = false; let mut name: Option = None; let mut exec_cmd: Option = None; 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()); } } 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 { let default_wayland: Vec = DEFAULT_WAYLAND_DIRS.iter().map(PathBuf::from).collect(); let default_xsession: Vec = DEFAULT_XSESSION_DIRS.iter().map(PathBuf::from).collect(); 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 = 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 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"); } }