Rewrite moongreet from Python to Rust (v0.3.0)
Complete rewrite of the greetd greeter from Python/PyGObject to Rust/gtk4-rs for consistency with moonset, single binary without Python runtime, and improved security through Rust memory safety. Modules: main, greeter, ipc, config, i18n, users, sessions, power 86 unit tests covering all modules including login_worker IPC flow. Security hardening: shell-word splitting for exec_cmd, absolute path validation for session binaries, session-name sanitization, absolute loginctl path, atomic IPC writes.
This commit is contained in:
+219
@@ -0,0 +1,219 @@
|
||||
// ABOUTME: Configuration loading for the greeter.
|
||||
// ABOUTME: Reads moongreet.toml for wallpaper and GTK theme settings with fallback hierarchy.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
|
||||
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet";
|
||||
|
||||
/// Default config search path: system-wide config.
|
||||
fn default_config_paths() -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/etc/moongreet/moongreet.toml")]
|
||||
}
|
||||
|
||||
/// Raw TOML structure for deserialization.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct TomlConfig {
|
||||
appearance: Option<Appearance>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct Appearance {
|
||||
background: Option<String>,
|
||||
#[serde(rename = "gtk-theme")]
|
||||
gtk_theme: Option<String>,
|
||||
}
|
||||
|
||||
/// Greeter configuration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub background_path: Option<String>,
|
||||
pub gtk_theme: Option<String>,
|
||||
}
|
||||
|
||||
/// Load config from TOML files. Later paths override earlier ones.
|
||||
pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
let default_paths = default_config_paths();
|
||||
let paths = config_paths.unwrap_or(&default_paths);
|
||||
|
||||
let mut merged = Config::default();
|
||||
for path in paths {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(parsed) = toml::from_str::<TomlConfig>(&content) {
|
||||
if let Some(appearance) = parsed.appearance {
|
||||
if let Some(bg) = appearance.background {
|
||||
// Resolve relative paths against config file directory
|
||||
let bg_path = PathBuf::from(&bg);
|
||||
if bg_path.is_absolute() {
|
||||
merged.background_path = Some(bg);
|
||||
} else if let Some(parent) = path.parent() {
|
||||
merged.background_path =
|
||||
Some(parent.join(&bg).to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
if appearance.gtk_theme.is_some() {
|
||||
merged.gtk_theme = appearance.gtk_theme;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// Resolve the wallpaper path using the fallback hierarchy.
|
||||
///
|
||||
/// Priority: config background_path > Moonarch system default > gresource fallback.
|
||||
pub fn resolve_background_path(config: &Config) -> PathBuf {
|
||||
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
|
||||
}
|
||||
|
||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
|
||||
// User-configured path
|
||||
if let Some(ref bg) = config.background_path {
|
||||
let path = PathBuf::from(bg);
|
||||
if path.is_file() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Moonarch ecosystem default
|
||||
if moonarch_wallpaper.is_file() {
|
||||
return moonarch_wallpaper.to_path_buf();
|
||||
}
|
||||
|
||||
// GResource fallback path (loaded from compiled resources at runtime)
|
||||
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_none_fields() {
|
||||
let config = Config::default();
|
||||
assert!(config.background_path.is_none());
|
||||
assert!(config.gtk_theme.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_returns_default_when_no_files_exist() {
|
||||
let paths = vec![PathBuf::from("/nonexistent/moongreet.toml")];
|
||||
let config = load_config(Some(&paths));
|
||||
assert!(config.background_path.is_none());
|
||||
assert!(config.gtk_theme.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_reads_appearance_section() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(
|
||||
&conf,
|
||||
"[appearance]\nbackground = \"/custom/wallpaper.jpg\"\ngtk-theme = \"catppuccin\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let paths = vec![conf];
|
||||
let config = load_config(Some(&paths));
|
||||
assert_eq!(
|
||||
config.background_path.as_deref(),
|
||||
Some("/custom/wallpaper.jpg")
|
||||
);
|
||||
assert_eq!(config.gtk_theme.as_deref(), Some("catppuccin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_resolves_relative_background() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(&conf, "[appearance]\nbackground = \"bg.jpg\"\n").unwrap();
|
||||
let paths = vec![conf];
|
||||
let config = load_config(Some(&paths));
|
||||
let expected = dir.path().join("bg.jpg").to_string_lossy().to_string();
|
||||
assert_eq!(config.background_path.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_later_paths_override_earlier() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf1 = dir.path().join("first.toml");
|
||||
let conf2 = dir.path().join("second.toml");
|
||||
fs::write(
|
||||
&conf1,
|
||||
"[appearance]\nbackground = \"/first.jpg\"\ngtk-theme = \"first\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&conf2,
|
||||
"[appearance]\nbackground = \"/second.jpg\"\ngtk-theme = \"second\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let paths = vec![conf1, conf2];
|
||||
let config = load_config(Some(&paths));
|
||||
assert_eq!(config.background_path.as_deref(), Some("/second.jpg"));
|
||||
assert_eq!(config.gtk_theme.as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_skips_missing_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("exists.toml");
|
||||
fs::write(
|
||||
&conf,
|
||||
"[appearance]\nbackground = \"/exists.jpg\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let paths = vec![PathBuf::from("/nonexistent.toml"), conf];
|
||||
let config = load_config(Some(&paths));
|
||||
assert_eq!(config.background_path.as_deref(), Some("/exists.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_config_path_when_file_exists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wallpaper = dir.path().join("custom.jpg");
|
||||
fs::write(&wallpaper, "fake").unwrap();
|
||||
let config = Config {
|
||||
background_path: Some(wallpaper.to_str().unwrap().to_string()),
|
||||
gtk_theme: None,
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_background_path_with(&config, Path::new("/nonexistent")),
|
||||
wallpaper
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ignores_config_path_when_file_missing() {
|
||||
let config = Config {
|
||||
background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
|
||||
gtk_theme: None,
|
||||
};
|
||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
||||
assert!(result.to_str().unwrap().contains("moongreet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_moonarch_wallpaper_as_second_fallback() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let moonarch_wp = dir.path().join("wallpaper.jpg");
|
||||
fs::write(&moonarch_wp, "fake").unwrap();
|
||||
let config = Config::default();
|
||||
assert_eq!(
|
||||
resolve_background_path_with(&config, &moonarch_wp),
|
||||
moonarch_wp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_gresource_fallback_as_last_resort() {
|
||||
let config = Config::default();
|
||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
||||
assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
|
||||
}
|
||||
}
|
||||
+1415
File diff suppressed because it is too large
Load Diff
+333
@@ -0,0 +1,333 @@
|
||||
// ABOUTME: Locale detection and string lookup for the greeter UI.
|
||||
// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
||||
|
||||
/// All user-visible strings for the greeter UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Strings {
|
||||
// UI labels
|
||||
pub password_placeholder: &'static str,
|
||||
pub reboot_tooltip: &'static str,
|
||||
pub shutdown_tooltip: &'static str,
|
||||
|
||||
// Error messages
|
||||
pub no_session_selected: &'static str,
|
||||
pub greetd_sock_not_set: &'static str,
|
||||
pub greetd_sock_not_absolute: &'static str,
|
||||
pub greetd_sock_not_socket: &'static str,
|
||||
pub greetd_sock_unreachable: &'static str,
|
||||
pub auth_failed: &'static str,
|
||||
pub wrong_password: &'static str,
|
||||
pub multi_stage_unsupported: &'static str,
|
||||
pub invalid_session_command: &'static str,
|
||||
pub session_start_failed: &'static str,
|
||||
pub reboot_failed: &'static str,
|
||||
pub shutdown_failed: &'static str,
|
||||
pub connection_error: &'static str,
|
||||
pub socket_error: &'static str,
|
||||
|
||||
// Templates (use .replace("{n}", &count.to_string()))
|
||||
pub faillock_attempts_remaining: &'static str,
|
||||
pub faillock_locked: &'static str,
|
||||
}
|
||||
|
||||
const STRINGS_DE: Strings = Strings {
|
||||
password_placeholder: "Passwort",
|
||||
reboot_tooltip: "Neustart",
|
||||
shutdown_tooltip: "Herunterfahren",
|
||||
no_session_selected: "Keine Session ausgewählt",
|
||||
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
|
||||
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
|
||||
greetd_sock_not_socket: "GREETD_SOCK zeigt nicht auf einen Socket",
|
||||
greetd_sock_unreachable: "GREETD_SOCK nicht erreichbar",
|
||||
auth_failed: "Authentifizierung fehlgeschlagen",
|
||||
wrong_password: "Falsches Passwort",
|
||||
multi_stage_unsupported: "Mehrstufige Authentifizierung wird nicht unterstützt",
|
||||
invalid_session_command: "Ungültiger Session-Befehl",
|
||||
session_start_failed: "Session konnte nicht gestartet werden",
|
||||
reboot_failed: "Neustart fehlgeschlagen",
|
||||
shutdown_failed: "Herunterfahren fehlgeschlagen",
|
||||
connection_error: "Verbindungsfehler",
|
||||
socket_error: "Socket-Fehler",
|
||||
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
|
||||
faillock_locked: "Konto ist möglicherweise gesperrt",
|
||||
};
|
||||
|
||||
const STRINGS_EN: Strings = Strings {
|
||||
password_placeholder: "Password",
|
||||
reboot_tooltip: "Reboot",
|
||||
shutdown_tooltip: "Shut down",
|
||||
no_session_selected: "No session selected",
|
||||
greetd_sock_not_set: "GREETD_SOCK not set",
|
||||
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
|
||||
greetd_sock_not_socket: "GREETD_SOCK does not point to a socket",
|
||||
greetd_sock_unreachable: "GREETD_SOCK unreachable",
|
||||
auth_failed: "Authentication failed",
|
||||
wrong_password: "Wrong password",
|
||||
multi_stage_unsupported: "Multi-stage authentication is not supported",
|
||||
invalid_session_command: "Invalid session command",
|
||||
session_start_failed: "Failed to start session",
|
||||
reboot_failed: "Reboot failed",
|
||||
shutdown_failed: "Shutdown failed",
|
||||
connection_error: "Connection error",
|
||||
socket_error: "Socket error",
|
||||
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
|
||||
faillock_locked: "Account may be locked",
|
||||
};
|
||||
|
||||
/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de".
|
||||
/// Returns "en" for empty, "C", or "POSIX" values.
|
||||
fn parse_lang_prefix(lang: &str) -> String {
|
||||
if lang.is_empty() || lang == "C" || lang == "POSIX" {
|
||||
return "en".to_string();
|
||||
}
|
||||
|
||||
let prefix = lang
|
||||
.split('_')
|
||||
.next()
|
||||
.unwrap_or(lang)
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(lang)
|
||||
.to_lowercase();
|
||||
|
||||
if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() {
|
||||
prefix
|
||||
} else {
|
||||
"en".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the LANG= value from a locale.conf file.
|
||||
fn read_lang_from_conf(path: &Path) -> Option<String> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix("LANG=") {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Determine the system language from LANG env var or /etc/locale.conf.
|
||||
pub fn detect_locale() -> String {
|
||||
let lang = env::var("LANG")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
|
||||
|
||||
match lang {
|
||||
Some(l) => parse_lang_prefix(&l),
|
||||
None => "en".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the string table for the given locale, defaulting to English.
|
||||
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
||||
let locale = match locale {
|
||||
Some(l) => l.to_string(),
|
||||
None => detect_locale(),
|
||||
};
|
||||
|
||||
match locale.as_str() {
|
||||
"de" => &STRINGS_DE,
|
||||
_ => &STRINGS_EN,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a faillock warning for the given attempt count.
|
||||
/// Returns None if no warning is needed yet.
|
||||
pub fn faillock_warning(attempt_count: u32, strings: &Strings) -> Option<String> {
|
||||
const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
if attempt_count >= FAILLOCK_MAX_ATTEMPTS {
|
||||
return Some(strings.faillock_locked.to_string());
|
||||
}
|
||||
|
||||
let remaining = FAILLOCK_MAX_ATTEMPTS - attempt_count;
|
||||
if remaining == 1 {
|
||||
return Some(
|
||||
strings
|
||||
.faillock_attempts_remaining
|
||||
.replace("{n}", &remaining.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
// -- parse_lang_prefix tests --
|
||||
|
||||
#[test]
|
||||
fn parse_german_locale() {
|
||||
assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_english_locale() {
|
||||
assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_c_falls_back_to_english() {
|
||||
assert_eq!(parse_lang_prefix("C"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_posix_falls_back_to_english() {
|
||||
assert_eq!(parse_lang_prefix("POSIX"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_falls_back_to_english() {
|
||||
assert_eq!(parse_lang_prefix(""), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unsupported_returns_prefix() {
|
||||
assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bare_language_code() {
|
||||
assert_eq!(parse_lang_prefix("de"), "de");
|
||||
}
|
||||
|
||||
// -- read_lang_from_conf tests --
|
||||
|
||||
#[test]
|
||||
fn read_conf_extracts_lang() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
||||
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conf_returns_none_for_missing_file() {
|
||||
assert_eq!(
|
||||
read_lang_from_conf(Path::new("/nonexistent/locale.conf")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conf_returns_none_for_empty_lang() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LANG=").unwrap();
|
||||
assert_eq!(read_lang_from_conf(&conf), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conf_skips_non_lang_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LC_ALL=en_US.UTF-8").unwrap();
|
||||
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
||||
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
||||
}
|
||||
|
||||
// -- load_strings tests --
|
||||
|
||||
#[test]
|
||||
fn load_strings_german() {
|
||||
let strings = load_strings(Some("de"));
|
||||
assert_eq!(strings.password_placeholder, "Passwort");
|
||||
assert_eq!(strings.reboot_tooltip, "Neustart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_strings_english() {
|
||||
let strings = load_strings(Some("en"));
|
||||
assert_eq!(strings.password_placeholder, "Password");
|
||||
assert_eq!(strings.reboot_tooltip, "Reboot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_strings_unknown_falls_back_to_english() {
|
||||
let strings = load_strings(Some("fr"));
|
||||
assert_eq!(strings.password_placeholder, "Password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_string_fields_nonempty() {
|
||||
for locale in &["de", "en"] {
|
||||
let s = load_strings(Some(locale));
|
||||
assert!(!s.password_placeholder.is_empty(), "{locale}: password_placeholder");
|
||||
assert!(!s.reboot_tooltip.is_empty(), "{locale}: reboot_tooltip");
|
||||
assert!(!s.shutdown_tooltip.is_empty(), "{locale}: shutdown_tooltip");
|
||||
assert!(!s.no_session_selected.is_empty(), "{locale}: no_session_selected");
|
||||
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
|
||||
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
|
||||
assert!(!s.wrong_password.is_empty(), "{locale}: wrong_password");
|
||||
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed");
|
||||
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed");
|
||||
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
|
||||
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
|
||||
}
|
||||
}
|
||||
|
||||
// -- faillock_warning tests --
|
||||
|
||||
#[test]
|
||||
fn faillock_no_warning_at_zero_attempts() {
|
||||
let s = load_strings(Some("en"));
|
||||
assert!(faillock_warning(0, s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_no_warning_at_first_attempt() {
|
||||
let s = load_strings(Some("en"));
|
||||
assert!(faillock_warning(1, s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_warning_at_second_attempt() {
|
||||
let s = load_strings(Some("en"));
|
||||
let warning = faillock_warning(2, s);
|
||||
assert!(warning.is_some());
|
||||
assert!(warning.unwrap().contains("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_locked_at_third_attempt() {
|
||||
let s = load_strings(Some("en"));
|
||||
let warning = faillock_warning(3, s);
|
||||
assert!(warning.is_some());
|
||||
assert_eq!(warning.unwrap(), "Account may be locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_locked_beyond_max() {
|
||||
let s = load_strings(Some("en"));
|
||||
let warning = faillock_warning(5, s);
|
||||
assert!(warning.is_some());
|
||||
assert_eq!(warning.unwrap(), "Account may be locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_german_strings() {
|
||||
let s = load_strings(Some("de"));
|
||||
let warning = faillock_warning(2, s).unwrap();
|
||||
assert!(warning.contains("Kontosperrung"));
|
||||
let locked = faillock_warning(3, s).unwrap();
|
||||
assert!(locked.contains("gesperrt"));
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
// ABOUTME: greetd IPC protocol implementation — communicates via Unix socket.
|
||||
// ABOUTME: Uses length-prefixed JSON encoding as specified by the greetd IPC protocol.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
const MAX_PAYLOAD_SIZE: usize = 65536;
|
||||
|
||||
/// Errors from greetd IPC communication.
|
||||
#[derive(Debug)]
|
||||
pub enum IpcError {
|
||||
Io(io::Error),
|
||||
PayloadTooLarge(usize),
|
||||
Json(serde_json::Error),
|
||||
ConnectionClosed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpcError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IpcError::Io(e) => write!(f, "IPC I/O error: {e}"),
|
||||
IpcError::PayloadTooLarge(size) => {
|
||||
write!(f, "Payload too large: {size} bytes (max {MAX_PAYLOAD_SIZE})")
|
||||
}
|
||||
IpcError::Json(e) => write!(f, "IPC JSON error: {e}"),
|
||||
IpcError::ConnectionClosed => write!(f, "Connection closed while reading data"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IpcError {}
|
||||
|
||||
impl From<io::Error> for IpcError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
IpcError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for IpcError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
IpcError::Json(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exactly 4 bytes (length header) from the stream into a stack array.
|
||||
fn recv_header(stream: &mut UnixStream) -> Result<[u8; 4], IpcError> {
|
||||
let mut buf = [0u8; 4];
|
||||
let mut filled = 0;
|
||||
|
||||
while filled < 4 {
|
||||
let bytes_read = stream.read(&mut buf[filled..])?;
|
||||
if bytes_read == 0 {
|
||||
return Err(IpcError::ConnectionClosed);
|
||||
}
|
||||
filled += bytes_read;
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Receive exactly n bytes from the stream, looping on partial reads.
|
||||
fn recv_payload(stream: &mut UnixStream, n: usize) -> Result<Vec<u8>, IpcError> {
|
||||
let mut buf = vec![0u8; n];
|
||||
let mut filled = 0;
|
||||
|
||||
while filled < n {
|
||||
let bytes_read = stream.read(&mut buf[filled..])?;
|
||||
if bytes_read == 0 {
|
||||
return Err(IpcError::ConnectionClosed);
|
||||
}
|
||||
filled += bytes_read;
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Send a length-prefixed JSON message to the greetd socket.
|
||||
/// Header and payload are sent in a single write for atomicity.
|
||||
pub fn send_message(
|
||||
stream: &mut UnixStream,
|
||||
msg: &serde_json::Value,
|
||||
) -> Result<(), IpcError> {
|
||||
let payload = serde_json::to_vec(msg)?;
|
||||
if payload.len() > MAX_PAYLOAD_SIZE {
|
||||
return Err(IpcError::PayloadTooLarge(payload.len()));
|
||||
}
|
||||
|
||||
let header = (payload.len() as u32).to_le_bytes();
|
||||
let mut buf = Vec::with_capacity(4 + payload.len());
|
||||
buf.extend_from_slice(&header);
|
||||
buf.extend_from_slice(&payload);
|
||||
stream.write_all(&buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive a length-prefixed JSON message from the greetd socket.
|
||||
pub fn recv_message(stream: &mut UnixStream) -> Result<serde_json::Value, IpcError> {
|
||||
let header = recv_header(stream)?;
|
||||
let length = u32::from_le_bytes(header) as usize;
|
||||
|
||||
if length > MAX_PAYLOAD_SIZE {
|
||||
return Err(IpcError::PayloadTooLarge(length));
|
||||
}
|
||||
|
||||
let payload = recv_payload(stream, length)?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&payload)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Send a create_session request to greetd and return the response.
|
||||
pub fn create_session(
|
||||
stream: &mut UnixStream,
|
||||
username: &str,
|
||||
) -> Result<serde_json::Value, IpcError> {
|
||||
let msg = serde_json::json!({
|
||||
"type": "create_session",
|
||||
"username": username,
|
||||
});
|
||||
send_message(stream, &msg)?;
|
||||
recv_message(stream)
|
||||
}
|
||||
|
||||
/// Send an authentication response (e.g. password) to greetd.
|
||||
pub fn post_auth_response(
|
||||
stream: &mut UnixStream,
|
||||
response: Option<&str>,
|
||||
) -> Result<serde_json::Value, IpcError> {
|
||||
let msg = serde_json::json!({
|
||||
"type": "post_auth_message_response",
|
||||
"response": response,
|
||||
});
|
||||
send_message(stream, &msg)?;
|
||||
recv_message(stream)
|
||||
}
|
||||
|
||||
/// Send a start_session request to launch the user's session.
|
||||
pub fn start_session(
|
||||
stream: &mut UnixStream,
|
||||
cmd: &[String],
|
||||
) -> Result<serde_json::Value, IpcError> {
|
||||
let msg = serde_json::json!({
|
||||
"type": "start_session",
|
||||
"cmd": cmd,
|
||||
});
|
||||
send_message(stream, &msg)?;
|
||||
recv_message(stream)
|
||||
}
|
||||
|
||||
/// Cancel the current authentication session.
|
||||
pub fn cancel_session(stream: &mut UnixStream) -> Result<serde_json::Value, IpcError> {
|
||||
let msg = serde_json::json!({"type": "cancel_session"});
|
||||
send_message(stream, &msg)?;
|
||||
recv_message(stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
/// Create a connected pair of Unix sockets for testing.
|
||||
fn socket_pair() -> (UnixStream, UnixStream) {
|
||||
UnixStream::pair().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_and_receive_message() {
|
||||
let (mut client, mut server) = socket_pair();
|
||||
|
||||
let msg = serde_json::json!({"type": "create_session", "username": "test"});
|
||||
send_message(&mut client, &msg).unwrap();
|
||||
|
||||
let received = recv_message(&mut server).unwrap();
|
||||
assert_eq!(received["type"], "create_session");
|
||||
assert_eq!(received["username"], "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_session_roundtrip() {
|
||||
let (mut client, mut server) = socket_pair();
|
||||
|
||||
// Simulate greetd response in a thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
let msg = recv_message(&mut server).unwrap();
|
||||
assert_eq!(msg["type"], "create_session");
|
||||
assert_eq!(msg["username"], "alice");
|
||||
|
||||
let response = serde_json::json!({
|
||||
"type": "auth_message",
|
||||
"auth_message_type": "visible",
|
||||
"auth_message": "Password: ",
|
||||
});
|
||||
send_message(&mut server, &response).unwrap();
|
||||
});
|
||||
|
||||
let response = create_session(&mut client, "alice").unwrap();
|
||||
assert_eq!(response["type"], "auth_message");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_auth_response_roundtrip() {
|
||||
let (mut client, mut server) = socket_pair();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let msg = recv_message(&mut server).unwrap();
|
||||
assert_eq!(msg["type"], "post_auth_message_response");
|
||||
assert_eq!(msg["response"], "secret123");
|
||||
|
||||
let response = serde_json::json!({"type": "success"});
|
||||
send_message(&mut server, &response).unwrap();
|
||||
});
|
||||
|
||||
let response = post_auth_response(&mut client, Some("secret123")).unwrap();
|
||||
assert_eq!(response["type"], "success");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_session_roundtrip() {
|
||||
let (mut client, mut server) = socket_pair();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let msg = recv_message(&mut server).unwrap();
|
||||
assert_eq!(msg["type"], "start_session");
|
||||
assert_eq!(msg["cmd"], serde_json::json!(["niri-session"]));
|
||||
|
||||
let response = serde_json::json!({"type": "success"});
|
||||
send_message(&mut server, &response).unwrap();
|
||||
});
|
||||
|
||||
let cmd = vec!["niri-session".to_string()];
|
||||
let response = start_session(&mut client, &cmd).unwrap();
|
||||
assert_eq!(response["type"], "success");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_session_roundtrip() {
|
||||
let (mut client, mut server) = socket_pair();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let msg = recv_message(&mut server).unwrap();
|
||||
assert_eq!(msg["type"], "cancel_session");
|
||||
|
||||
let response = serde_json::json!({"type": "success"});
|
||||
send_message(&mut server, &response).unwrap();
|
||||
});
|
||||
|
||||
let response = cancel_session(&mut client).unwrap();
|
||||
assert_eq!(response["type"], "success");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_closed_returns_error() {
|
||||
let (mut client, server) = socket_pair();
|
||||
drop(server);
|
||||
|
||||
let result = recv_message(&mut client);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_payload_rejected_on_send() {
|
||||
let (mut client, _server) = socket_pair();
|
||||
|
||||
let big_string = "x".repeat(MAX_PAYLOAD_SIZE + 1);
|
||||
let msg = serde_json::json!({"data": big_string});
|
||||
let result = send_message(&mut client, &msg);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_payload_rejected_on_receive() {
|
||||
let (mut client, mut server) = socket_pair();
|
||||
|
||||
// Manually send a header claiming a huge payload
|
||||
let fake_length: u32 = (MAX_PAYLOAD_SIZE as u32) + 1;
|
||||
server.write_all(&fake_length.to_le_bytes()).unwrap();
|
||||
|
||||
let result = recv_message(&mut client);
|
||||
assert!(matches!(result, Err(IpcError::PayloadTooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipc_error_display() {
|
||||
let err = IpcError::ConnectionClosed;
|
||||
assert_eq!(err.to_string(), "Connection closed while reading data");
|
||||
|
||||
let err = IpcError::PayloadTooLarge(99999);
|
||||
assert!(err.to_string().contains("99999"));
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// ABOUTME: Entry point for Moongreet — greetd greeter for Wayland.
|
||||
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
|
||||
|
||||
mod config;
|
||||
mod greeter;
|
||||
mod i18n;
|
||||
mod ipc;
|
||||
mod power;
|
||||
mod sessions;
|
||||
mod users;
|
||||
|
||||
use gdk4 as gdk;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{self as gtk, gio};
|
||||
use gtk4_layer_shell::LayerShell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn load_css(display: &gdk::Display) {
|
||||
let css_provider = gtk::CssProvider::new();
|
||||
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
display,
|
||||
&css_provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
|
||||
fn setup_layer_shell(window: >k::ApplicationWindow, keyboard: bool) {
|
||||
window.init_layer_shell();
|
||||
window.set_layer(gtk4_layer_shell::Layer::Top);
|
||||
window.set_exclusive_zone(-1);
|
||||
if keyboard {
|
||||
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
|
||||
}
|
||||
// Anchor to all edges for fullscreen
|
||||
window.set_anchor(gtk4_layer_shell::Edge::Top, true);
|
||||
window.set_anchor(gtk4_layer_shell::Edge::Bottom, true);
|
||||
window.set_anchor(gtk4_layer_shell::Edge::Left, true);
|
||||
window.set_anchor(gtk4_layer_shell::Edge::Right, true);
|
||||
}
|
||||
|
||||
fn activate(app: >k::Application) {
|
||||
let display = match gdk::Display::default() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
log::error!("No display available — cannot start greeter UI");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
load_css(&display);
|
||||
|
||||
// Load config and resolve wallpaper
|
||||
let config = config::load_config(None);
|
||||
let bg_path = config::resolve_background_path(&config);
|
||||
|
||||
// Main greeter window (login UI) — compositor picks focused monitor
|
||||
let greeter_window = greeter::create_greeter_window(&bg_path, &config, app);
|
||||
setup_layer_shell(&greeter_window, true);
|
||||
greeter_window.present();
|
||||
|
||||
// Wallpaper-only windows on all monitors
|
||||
let monitors = display.monitors();
|
||||
for i in 0..monitors.n_items() {
|
||||
if let Some(monitor) = monitors
|
||||
.item(i)
|
||||
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
|
||||
{
|
||||
let wallpaper = greeter::create_wallpaper_window(&bg_path, app);
|
||||
setup_layer_shell(&wallpaper, false);
|
||||
wallpaper.set_monitor(Some(&monitor));
|
||||
wallpaper.present();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_logging() {
|
||||
let mut builder = env_logger::Builder::from_default_env();
|
||||
builder.filter_level(log::LevelFilter::Info);
|
||||
|
||||
// Try file logging to /var/cache/moongreet/ — fall back to stderr
|
||||
let log_dir = PathBuf::from("/var/cache/moongreet");
|
||||
if log_dir.is_dir() {
|
||||
let log_file = log_dir.join("moongreet.log");
|
||||
if let Ok(file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
{
|
||||
builder.target(env_logger::Target::Pipe(Box::new(file)));
|
||||
}
|
||||
}
|
||||
|
||||
builder.init();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
setup_logging();
|
||||
log::info!("Moongreet starting");
|
||||
|
||||
// Register compiled GResources
|
||||
gio::resources_register_include!("moongreet.gresource").expect("Failed to register resources");
|
||||
|
||||
let app = gtk::Application::builder()
|
||||
.application_id("dev.moonarch.moongreet")
|
||||
.build();
|
||||
|
||||
app.connect_activate(activate);
|
||||
app.run();
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4.
|
||||
# ABOUTME: Part of the Moonarch ecosystem.
|
||||
@@ -1,84 +0,0 @@
|
||||
# ABOUTME: Configuration loading from moongreet.toml.
|
||||
# ABOUTME: Parses appearance and behavior settings with wallpaper path resolution.
|
||||
|
||||
import tomllib
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import as_file, files
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_CONFIG_PATHS = [
|
||||
Path("/etc/moongreet/moongreet.toml"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Greeter configuration loaded from moongreet.toml."""
|
||||
|
||||
background: Path | None = None
|
||||
gtk_theme: str | None = None
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None) -> Config:
|
||||
"""Load configuration from a TOML file.
|
||||
|
||||
Relative paths in the config are resolved against the config file's directory.
|
||||
"""
|
||||
if config_path is None:
|
||||
for path in DEFAULT_CONFIG_PATHS:
|
||||
if path.exists():
|
||||
config_path = path
|
||||
break
|
||||
if config_path is None:
|
||||
return Config()
|
||||
|
||||
if not config_path.exists():
|
||||
return Config()
|
||||
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, OSError):
|
||||
return Config()
|
||||
|
||||
config = Config()
|
||||
appearance = data.get("appearance", {})
|
||||
|
||||
bg = appearance.get("background")
|
||||
if bg:
|
||||
bg_path = Path(bg)
|
||||
if not bg_path.is_absolute():
|
||||
bg_path = config_path.parent / bg_path
|
||||
config.background = bg_path
|
||||
|
||||
gtk_theme = appearance.get("gtk-theme")
|
||||
if gtk_theme:
|
||||
config.gtk_theme = gtk_theme
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_PACKAGE_DATA = files("moongreet") / "data"
|
||||
_DEFAULT_WALLPAPER_PATH = _PACKAGE_DATA / "wallpaper.jpg"
|
||||
|
||||
|
||||
def resolve_wallpaper_path(
|
||||
config: Config,
|
||||
) -> tuple[Path, AbstractContextManager | None]:
|
||||
"""Resolve the wallpaper path from config or fall back to the package default.
|
||||
|
||||
Returns (path, context_manager). The context_manager is non-None when a
|
||||
package resource was extracted to a temporary file — the caller must keep
|
||||
it alive and call __exit__ when done.
|
||||
"""
|
||||
if config.background and config.background.exists():
|
||||
return config.background, None
|
||||
|
||||
ctx = as_file(_DEFAULT_WALLPAPER_PATH)
|
||||
try:
|
||||
path = ctx.__enter__()
|
||||
except Exception:
|
||||
ctx.__exit__(None, None, None)
|
||||
raise
|
||||
return path, ctx
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#PLACEHOLDER" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#222222" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB |
@@ -1,664 +0,0 @@
|
||||
# ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter.
|
||||
# ABOUTME: Handles user selection, session choice, password entry, and power actions.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
|
||||
|
||||
from moongreet.config import Config, load_config, resolve_wallpaper_path
|
||||
from moongreet.i18n import load_strings, Strings
|
||||
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
|
||||
from moongreet.users import User, get_users, get_avatar_path
|
||||
from moongreet.sessions import Session, get_sessions
|
||||
from moongreet.power import reboot, shutdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
|
||||
LAST_SESSION_DIR = Path("/var/cache/moongreet/last-session")
|
||||
FAILLOCK_MAX_ATTEMPTS = 3
|
||||
VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$")
|
||||
MAX_USERNAME_LENGTH = 256
|
||||
PACKAGE_DATA = files("moongreet") / "data"
|
||||
DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg"
|
||||
AVATAR_SIZE = 128
|
||||
MAX_AVATAR_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str | None:
|
||||
"""Return a warning if the user is approaching or has reached the faillock limit."""
|
||||
if strings is None:
|
||||
strings = load_strings()
|
||||
remaining = FAILLOCK_MAX_ATTEMPTS - attempt_count
|
||||
if remaining <= 0:
|
||||
return strings.faillock_locked
|
||||
if remaining == 1:
|
||||
return strings.faillock_attempts_remaining.format(n=remaining)
|
||||
return None
|
||||
|
||||
|
||||
def _build_wallpaper_widget(bg_path: Path | None) -> Gtk.Widget:
|
||||
"""Create a wallpaper widget that fills the available space."""
|
||||
if bg_path and bg_path.exists():
|
||||
background = Gtk.Picture()
|
||||
background.set_filename(str(bg_path))
|
||||
background.set_content_fit(Gtk.ContentFit.COVER)
|
||||
background.set_hexpand(True)
|
||||
background.set_vexpand(True)
|
||||
return background
|
||||
background = Gtk.Box()
|
||||
background.set_hexpand(True)
|
||||
background.set_vexpand(True)
|
||||
return background
|
||||
|
||||
|
||||
class WallpaperWindow(Gtk.ApplicationWindow):
|
||||
"""A window that shows only the wallpaper — used for secondary monitors."""
|
||||
|
||||
def __init__(self, bg_path: Path | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.add_css_class("greeter")
|
||||
self.set_default_size(1920, 1080)
|
||||
self.set_child(_build_wallpaper_widget(bg_path))
|
||||
|
||||
|
||||
class GreeterWindow(Gtk.ApplicationWindow):
|
||||
"""The main greeter window with login UI."""
|
||||
|
||||
def __init__(self, bg_path: Path | None = None, config: Config | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.add_css_class("greeter")
|
||||
self.set_default_size(1920, 1080)
|
||||
|
||||
self._config = config if config is not None else load_config()
|
||||
self._strings = load_strings()
|
||||
self._users = get_users()
|
||||
self._sessions = get_sessions()
|
||||
self._selected_user: User | None = None
|
||||
self._greetd_sock: socket.socket | None = None
|
||||
self._greetd_sock_lock = threading.Lock()
|
||||
self._login_cancelled = threading.Event()
|
||||
self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None
|
||||
self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {}
|
||||
self._failed_attempts: dict[str, int] = {}
|
||||
self._bg_path = bg_path
|
||||
|
||||
self._apply_global_theme()
|
||||
self._build_ui()
|
||||
self._setup_keyboard_navigation()
|
||||
# Defer initial user selection until the window is realized,
|
||||
# so get_color() returns the actual theme foreground for SVG tinting
|
||||
self.connect("realize", self._on_realize)
|
||||
|
||||
def _on_realize(self, widget: Gtk.Widget) -> None:
|
||||
"""Called when the window is realized — select initial user.
|
||||
|
||||
Deferred from __init__ so get_color() returns actual theme values
|
||||
for SVG tinting. Uses idle_add so the first frame renders before
|
||||
avatar loading blocks the main loop.
|
||||
"""
|
||||
GLib.idle_add(self._select_initial_user)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build the complete greeter UI layout."""
|
||||
# Root overlay for layering
|
||||
overlay = Gtk.Overlay()
|
||||
self.set_child(overlay)
|
||||
|
||||
# Background wallpaper
|
||||
overlay.set_child(_build_wallpaper_widget(self._bg_path))
|
||||
|
||||
# Main layout: 3 rows (top spacer, center login, bottom bar)
|
||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
main_box.set_hexpand(True)
|
||||
main_box.set_vexpand(True)
|
||||
overlay.add_overlay(main_box)
|
||||
|
||||
# Top spacer
|
||||
top_spacer = Gtk.Box()
|
||||
top_spacer.set_vexpand(True)
|
||||
main_box.append(top_spacer)
|
||||
|
||||
# Center: login box
|
||||
center_box = self._build_login_box()
|
||||
center_box.set_halign(Gtk.Align.CENTER)
|
||||
main_box.append(center_box)
|
||||
|
||||
# Bottom spacer
|
||||
bottom_spacer = Gtk.Box()
|
||||
bottom_spacer.set_vexpand(True)
|
||||
main_box.append(bottom_spacer)
|
||||
|
||||
# Bottom bar overlay (user list left, power buttons right)
|
||||
bottom_bar = self._build_bottom_bar()
|
||||
bottom_bar.set_valign(Gtk.Align.END)
|
||||
overlay.add_overlay(bottom_bar)
|
||||
|
||||
def _build_login_box(self) -> Gtk.Box:
|
||||
"""Build the central login area with avatar, name, session, password."""
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
box.add_css_class("login-box")
|
||||
box.set_halign(Gtk.Align.CENTER)
|
||||
box.set_valign(Gtk.Align.CENTER)
|
||||
box.set_spacing(12)
|
||||
|
||||
# Avatar — wrapped in a clipping frame for round shape
|
||||
avatar_frame = Gtk.Box()
|
||||
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE)
|
||||
avatar_frame.set_halign(Gtk.Align.CENTER)
|
||||
avatar_frame.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
avatar_frame.add_css_class("avatar")
|
||||
self._avatar_image = Gtk.Image()
|
||||
self._avatar_image.set_pixel_size(AVATAR_SIZE)
|
||||
avatar_frame.append(self._avatar_image)
|
||||
box.append(avatar_frame)
|
||||
|
||||
# Username label
|
||||
self._username_label = Gtk.Label(label="")
|
||||
self._username_label.add_css_class("username-label")
|
||||
box.append(self._username_label)
|
||||
|
||||
# Session dropdown
|
||||
self._session_dropdown = Gtk.DropDown()
|
||||
self._session_dropdown.add_css_class("session-dropdown")
|
||||
self._session_dropdown.set_hexpand(True)
|
||||
if self._sessions:
|
||||
session_names = [s.name for s in self._sessions]
|
||||
string_list = Gtk.StringList.new(session_names)
|
||||
self._session_dropdown.set_model(string_list)
|
||||
box.append(self._session_dropdown)
|
||||
|
||||
# Password entry
|
||||
self._password_entry = Gtk.PasswordEntry()
|
||||
self._password_entry.set_hexpand(True)
|
||||
self._password_entry.set_property("placeholder-text", self._strings.password_placeholder)
|
||||
self._password_entry.set_property("show-peek-icon", True)
|
||||
self._password_entry.add_css_class("password-entry")
|
||||
self._password_entry.connect("activate", self._on_login_activate)
|
||||
box.append(self._password_entry)
|
||||
|
||||
# Error label (hidden by default)
|
||||
self._error_label = Gtk.Label(label="")
|
||||
self._error_label.add_css_class("error-label")
|
||||
self._error_label.set_visible(False)
|
||||
box.append(self._error_label)
|
||||
|
||||
return box
|
||||
|
||||
def _build_bottom_bar(self) -> Gtk.Box:
|
||||
"""Build the bottom bar with user list (left) and power buttons (right)."""
|
||||
bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
bar.set_hexpand(True)
|
||||
bar.set_margin_start(16)
|
||||
bar.set_margin_end(16)
|
||||
bar.set_margin_bottom(16)
|
||||
|
||||
# User list (left)
|
||||
user_list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
user_list_box.add_css_class("user-list")
|
||||
user_list_box.set_halign(Gtk.Align.START)
|
||||
user_list_box.set_valign(Gtk.Align.END)
|
||||
|
||||
for user in self._users:
|
||||
btn = Gtk.Button(label=user.display_name)
|
||||
btn.add_css_class("user-list-item")
|
||||
btn.connect("clicked", self._on_user_clicked, user)
|
||||
user_list_box.append(btn)
|
||||
|
||||
bar.append(user_list_box)
|
||||
|
||||
# Spacer
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
bar.append(spacer)
|
||||
|
||||
# Power buttons (right)
|
||||
power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
power_box.set_halign(Gtk.Align.END)
|
||||
power_box.set_valign(Gtk.Align.END)
|
||||
power_box.set_spacing(8)
|
||||
|
||||
reboot_btn = Gtk.Button()
|
||||
reboot_btn.set_icon_name("system-reboot-symbolic")
|
||||
reboot_btn.add_css_class("power-button")
|
||||
reboot_btn.set_tooltip_text(self._strings.reboot_tooltip)
|
||||
reboot_btn.connect("clicked", self._on_reboot_clicked)
|
||||
power_box.append(reboot_btn)
|
||||
|
||||
shutdown_btn = Gtk.Button()
|
||||
shutdown_btn.set_icon_name("system-shutdown-symbolic")
|
||||
shutdown_btn.add_css_class("power-button")
|
||||
shutdown_btn.set_tooltip_text(self._strings.shutdown_tooltip)
|
||||
shutdown_btn.connect("clicked", self._on_shutdown_clicked)
|
||||
power_box.append(shutdown_btn)
|
||||
|
||||
bar.append(power_box)
|
||||
|
||||
return bar
|
||||
|
||||
def _select_initial_user(self) -> bool:
|
||||
"""Select the last user or the first available user.
|
||||
|
||||
Returns False to deregister from GLib.idle_add after a single invocation.
|
||||
"""
|
||||
if not self._users:
|
||||
return False
|
||||
|
||||
# Try to load last user
|
||||
last_username = self._load_last_user()
|
||||
target_user = None
|
||||
|
||||
if last_username:
|
||||
for user in self._users:
|
||||
if user.username == last_username:
|
||||
target_user = user
|
||||
break
|
||||
|
||||
if target_user is None:
|
||||
target_user = self._users[0]
|
||||
|
||||
self._switch_to_user(target_user)
|
||||
return False
|
||||
|
||||
def _switch_to_user(self, user: User) -> None:
|
||||
"""Update the UI to show the selected user."""
|
||||
self._selected_user = user
|
||||
self._username_label.set_text(user.display_name)
|
||||
self._password_entry.set_text("")
|
||||
self._error_label.set_visible(False)
|
||||
|
||||
# Update avatar (use cache if available)
|
||||
if user.username in self._avatar_cache:
|
||||
self._avatar_image.set_from_pixbuf(self._avatar_cache[user.username])
|
||||
else:
|
||||
avatar_path = get_avatar_path(
|
||||
user.username, home_dir=user.home
|
||||
)
|
||||
if avatar_path and avatar_path.exists():
|
||||
self._set_avatar_from_file(avatar_path, user.username)
|
||||
else:
|
||||
# Default avatar — _set_default_avatar uses Traversable.read_text()
|
||||
# which works in ZIP wheels too, no exists() check needed
|
||||
self._set_default_avatar()
|
||||
|
||||
# Pre-select last used session for this user
|
||||
self._select_last_session(user)
|
||||
|
||||
# Focus password entry
|
||||
self._password_entry.grab_focus()
|
||||
|
||||
def _apply_global_theme(self) -> None:
|
||||
"""Apply the GTK theme from moongreet.toml configuration."""
|
||||
theme_name = self._config.gtk_theme
|
||||
if not theme_name:
|
||||
return
|
||||
|
||||
settings = Gtk.Settings.get_default()
|
||||
if settings is None:
|
||||
return
|
||||
|
||||
settings.set_property("gtk-theme-name", theme_name)
|
||||
|
||||
def _get_foreground_color(self) -> str:
|
||||
"""Get the current GTK theme foreground color as a hex string."""
|
||||
rgba = self.get_color()
|
||||
r = int(rgba.red * 255)
|
||||
g = int(rgba.green * 255)
|
||||
b = int(rgba.blue * 255)
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
def _set_default_avatar(self) -> None:
|
||||
"""Load the default avatar SVG, tinted with the GTK foreground color."""
|
||||
if self._default_avatar_pixbuf:
|
||||
self._avatar_image.set_from_pixbuf(self._default_avatar_pixbuf)
|
||||
return
|
||||
try:
|
||||
svg_text = DEFAULT_AVATAR_PATH.read_text()
|
||||
fg_color = self._get_foreground_color()
|
||||
svg_text = svg_text.replace("#PLACEHOLDER", fg_color)
|
||||
svg_bytes = svg_text.encode("utf-8")
|
||||
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
|
||||
loader.set_size(AVATAR_SIZE, AVATAR_SIZE)
|
||||
loader.write(svg_bytes)
|
||||
loader.close()
|
||||
pixbuf = loader.get_pixbuf()
|
||||
if pixbuf:
|
||||
self._default_avatar_pixbuf = pixbuf
|
||||
self._avatar_image.set_from_pixbuf(pixbuf)
|
||||
except (GLib.Error, OSError):
|
||||
self._avatar_image.set_from_icon_name("avatar-default-symbolic")
|
||||
|
||||
def _set_avatar_from_file(self, path: Path, username: str | None = None) -> None:
|
||||
"""Load an image file and set it as the avatar, scaled to AVATAR_SIZE."""
|
||||
try:
|
||||
if path.stat().st_size > MAX_AVATAR_FILE_SIZE:
|
||||
self._avatar_image.set_from_icon_name("avatar-default-symbolic")
|
||||
return
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(path), AVATAR_SIZE, AVATAR_SIZE, True
|
||||
)
|
||||
if username:
|
||||
self._avatar_cache[username] = pixbuf
|
||||
self._avatar_image.set_from_pixbuf(pixbuf)
|
||||
except GLib.Error:
|
||||
self._avatar_image.set_from_icon_name("avatar-default-symbolic")
|
||||
|
||||
def _setup_keyboard_navigation(self) -> None:
|
||||
"""Set up keyboard shortcuts."""
|
||||
controller = Gtk.EventControllerKey()
|
||||
controller.connect("key-pressed", self._on_key_pressed)
|
||||
self.add_controller(controller)
|
||||
|
||||
def _on_key_pressed(
|
||||
self,
|
||||
controller: Gtk.EventControllerKey,
|
||||
keyval: int,
|
||||
keycode: int,
|
||||
state: Gdk.ModifierType,
|
||||
) -> bool:
|
||||
"""Handle global key presses."""
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
self._password_entry.set_text("")
|
||||
self._error_label.set_visible(False)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_user_clicked(self, button: Gtk.Button, user: User) -> None:
|
||||
"""Handle user selection from the user list."""
|
||||
self._cancel_pending_session()
|
||||
self._switch_to_user(user)
|
||||
|
||||
def _on_login_activate(self, entry: Gtk.PasswordEntry) -> None:
|
||||
"""Handle Enter key in the password field — attempt login."""
|
||||
if not self._selected_user:
|
||||
return
|
||||
|
||||
password = entry.get_text()
|
||||
session = self._get_selected_session()
|
||||
if not session:
|
||||
self._show_error(self._strings.no_session_selected)
|
||||
return
|
||||
|
||||
self._attempt_login(self._selected_user, password, session)
|
||||
|
||||
def _validate_greetd_sock(self, sock_path: str) -> bool:
|
||||
"""Validate that GREETD_SOCK points to an absolute path and a real socket."""
|
||||
path = Path(sock_path)
|
||||
if not path.is_absolute():
|
||||
self._show_error(self._strings.greetd_sock_not_absolute)
|
||||
return False
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
if not stat.S_ISSOCK(mode):
|
||||
self._show_error(self._strings.greetd_sock_not_socket)
|
||||
return False
|
||||
except OSError:
|
||||
self._show_error(self._strings.greetd_sock_unreachable)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _close_greetd_sock(self) -> None:
|
||||
"""Close the greetd socket and reset the reference."""
|
||||
with self._greetd_sock_lock:
|
||||
if self._greetd_sock:
|
||||
try:
|
||||
self._greetd_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._greetd_sock = None
|
||||
|
||||
def _set_login_sensitive(self, sensitive: bool) -> None:
|
||||
"""Enable or disable login controls during authentication."""
|
||||
self._password_entry.set_sensitive(sensitive)
|
||||
self._session_dropdown.set_sensitive(sensitive)
|
||||
|
||||
def _attempt_login(self, user: User, password: str, session: Session) -> None:
|
||||
"""Attempt to authenticate and start a session via greetd IPC."""
|
||||
sock_path = os.environ.get("GREETD_SOCK")
|
||||
if not sock_path:
|
||||
self._show_error(self._strings.greetd_sock_not_set)
|
||||
return
|
||||
|
||||
if not self._validate_greetd_sock(sock_path):
|
||||
return
|
||||
|
||||
# Disable UI while authenticating — the IPC runs in a background thread
|
||||
self._login_cancelled.clear()
|
||||
self._set_login_sensitive(False)
|
||||
thread = threading.Thread(
|
||||
target=self._login_worker,
|
||||
args=(user, password, session, sock_path),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _login_worker(self, user: User, password: str, session: Session, sock_path: str) -> None:
|
||||
"""Run greetd IPC in a background thread to avoid blocking the GTK main loop."""
|
||||
try:
|
||||
if self._login_cancelled.is_set():
|
||||
return
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(10.0)
|
||||
sock.connect(sock_path)
|
||||
with self._greetd_sock_lock:
|
||||
self._greetd_sock = sock
|
||||
|
||||
# Step 1: Create session — if a stale session exists, cancel it and retry
|
||||
response = create_session(sock, user.username)
|
||||
|
||||
if self._login_cancelled.is_set():
|
||||
return
|
||||
|
||||
if response.get("type") == "error":
|
||||
cancel_session(sock)
|
||||
response = create_session(sock, user.username)
|
||||
if self._login_cancelled.is_set():
|
||||
return
|
||||
if response.get("type") == "error":
|
||||
GLib.idle_add(self._on_login_error, response, self._strings.auth_failed)
|
||||
return
|
||||
|
||||
# Step 2: Send password if auth message received
|
||||
if response.get("type") == "auth_message":
|
||||
response = post_auth_response(sock, password)
|
||||
|
||||
if self._login_cancelled.is_set():
|
||||
return
|
||||
|
||||
if response.get("type") == "error":
|
||||
self._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1
|
||||
warning = faillock_warning(self._failed_attempts[user.username], self._strings)
|
||||
cancel_session(sock)
|
||||
GLib.idle_add(self._on_login_auth_error, response, warning)
|
||||
return
|
||||
|
||||
if response.get("type") == "auth_message":
|
||||
# Multi-stage auth (e.g. TOTP) is not supported
|
||||
cancel_session(sock)
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.multi_stage_unsupported)
|
||||
return
|
||||
|
||||
# Step 3: Start session
|
||||
if response.get("type") == "success":
|
||||
cmd = shlex.split(session.exec_cmd)
|
||||
if not cmd or not shutil.which(cmd[0]):
|
||||
cancel_session(sock)
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.invalid_session_command)
|
||||
return
|
||||
response = start_session(sock, cmd)
|
||||
|
||||
if self._login_cancelled.is_set():
|
||||
return
|
||||
|
||||
if response.get("type") == "success":
|
||||
self._save_last_user(user.username)
|
||||
self._save_last_session(user.username, session.name)
|
||||
GLib.idle_add(self.get_application().quit)
|
||||
return
|
||||
else:
|
||||
GLib.idle_add(self._on_login_error, response, self._strings.session_start_failed)
|
||||
return
|
||||
|
||||
except (ConnectionError, OSError, ValueError) as e:
|
||||
if self._login_cancelled.is_set():
|
||||
# Socket was closed by _cancel_pending_session — exit silently
|
||||
return
|
||||
logger.error("greetd IPC error: %s", e)
|
||||
if isinstance(e, ConnectionError):
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.connection_error)
|
||||
else:
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.socket_error)
|
||||
finally:
|
||||
self._close_greetd_sock()
|
||||
|
||||
def _on_login_error(self, response: dict | None, message: str) -> None:
|
||||
"""Handle login error on the GTK main thread."""
|
||||
if response:
|
||||
self._show_greetd_error(response, message)
|
||||
else:
|
||||
self._show_error(message)
|
||||
self._set_login_sensitive(True)
|
||||
|
||||
def _on_login_auth_error(self, response: dict, warning: str | None) -> None:
|
||||
"""Handle authentication failure with optional faillock warning on the GTK main thread."""
|
||||
self._show_greetd_error(response, self._strings.wrong_password)
|
||||
if warning:
|
||||
current = self._error_label.get_text()
|
||||
self._error_label.set_text(f"{current}\n{warning}")
|
||||
self._set_login_sensitive(True)
|
||||
|
||||
def _cancel_pending_session(self) -> None:
|
||||
"""Cancel any in-progress greetd session.
|
||||
|
||||
Sets the cancellation event and closes the socket to interrupt
|
||||
any blocking I/O in the login worker. The worker checks the
|
||||
event and exits silently instead of showing an error.
|
||||
"""
|
||||
self._login_cancelled.set()
|
||||
self._close_greetd_sock()
|
||||
|
||||
def _get_selected_session(self) -> Session | None:
|
||||
"""Get the currently selected session from the dropdown."""
|
||||
if not self._sessions:
|
||||
return None
|
||||
idx = self._session_dropdown.get_selected()
|
||||
if idx < len(self._sessions):
|
||||
return self._sessions[idx]
|
||||
return None
|
||||
|
||||
def _select_last_session(self, user: User) -> None:
|
||||
"""Pre-select the last used session for a user in the dropdown."""
|
||||
if not self._sessions:
|
||||
return
|
||||
last_session_name = self._load_last_session(user.username)
|
||||
if not last_session_name:
|
||||
return
|
||||
for i, session in enumerate(self._sessions):
|
||||
if session.name == last_session_name:
|
||||
self._session_dropdown.set_selected(i)
|
||||
return
|
||||
|
||||
MAX_GREETD_ERROR_LENGTH = 200
|
||||
|
||||
def _show_greetd_error(self, response: dict, fallback: str) -> None:
|
||||
"""Display an error from greetd, using a fallback for missing or oversized descriptions."""
|
||||
description = response.get("description", "")
|
||||
if description and len(description) <= self.MAX_GREETD_ERROR_LENGTH:
|
||||
self._show_error(description)
|
||||
else:
|
||||
self._show_error(fallback)
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
"""Display an error message below the password field."""
|
||||
self._error_label.set_text(message)
|
||||
self._error_label.set_visible(True)
|
||||
self._password_entry.set_text("")
|
||||
self._password_entry.grab_focus()
|
||||
|
||||
def _on_reboot_clicked(self, button: Gtk.Button) -> None:
|
||||
"""Handle reboot button click."""
|
||||
button.set_sensitive(False)
|
||||
threading.Thread(
|
||||
target=self._power_worker, args=(reboot, self._strings.reboot_failed),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _on_shutdown_clicked(self, button: Gtk.Button) -> None:
|
||||
"""Handle shutdown button click."""
|
||||
button.set_sensitive(False)
|
||||
threading.Thread(
|
||||
target=self._power_worker, args=(shutdown, self._strings.shutdown_failed),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _power_worker(self, action, error_msg: str) -> None:
|
||||
"""Run a power action in a background thread to avoid blocking the GTK main loop."""
|
||||
try:
|
||||
action()
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
GLib.idle_add(self._show_error, error_msg)
|
||||
|
||||
@staticmethod
|
||||
def _load_last_user() -> str | None:
|
||||
"""Load the last logged-in username from cache."""
|
||||
if LAST_USER_PATH.exists():
|
||||
try:
|
||||
username = LAST_USER_PATH.read_text().strip()
|
||||
except OSError:
|
||||
return None
|
||||
if len(username) > MAX_USERNAME_LENGTH or not VALID_USERNAME.match(username):
|
||||
return None
|
||||
return username
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _save_last_user(username: str) -> None:
|
||||
"""Save the last logged-in username to cache."""
|
||||
try:
|
||||
LAST_USER_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
LAST_USER_PATH.write_text(username)
|
||||
except OSError:
|
||||
pass # Non-critical — cache dir may not be writable
|
||||
|
||||
MAX_SESSION_NAME_LENGTH = 256
|
||||
|
||||
@staticmethod
|
||||
def _save_last_session(username: str, session_name: str) -> None:
|
||||
"""Save the last used session name for a user to cache."""
|
||||
if not VALID_USERNAME.match(username) or len(username) > MAX_USERNAME_LENGTH:
|
||||
return
|
||||
if not session_name or len(session_name) > GreeterWindow.MAX_SESSION_NAME_LENGTH:
|
||||
return
|
||||
try:
|
||||
LAST_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(LAST_SESSION_DIR / username).write_text(session_name)
|
||||
except OSError:
|
||||
pass # Non-critical — cache dir may not be writable
|
||||
|
||||
@staticmethod
|
||||
def _load_last_session(username: str) -> str | None:
|
||||
"""Load the last used session name for a user from cache."""
|
||||
session_file = LAST_SESSION_DIR / username
|
||||
if not session_file.exists():
|
||||
return None
|
||||
try:
|
||||
name = session_file.read_text().strip()
|
||||
except OSError:
|
||||
return None
|
||||
if not name or len(name) > GreeterWindow.MAX_SESSION_NAME_LENGTH:
|
||||
return None
|
||||
return name
|
||||
@@ -1,117 +0,0 @@
|
||||
# ABOUTME: Locale detection and string lookup for the greeter UI.
|
||||
# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_LOCALE_CONF = Path("/etc/locale.conf")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Strings:
|
||||
"""All user-visible strings for the greeter UI."""
|
||||
|
||||
# UI labels
|
||||
password_placeholder: str
|
||||
reboot_tooltip: str
|
||||
shutdown_tooltip: str
|
||||
|
||||
# Error messages
|
||||
no_session_selected: str
|
||||
greetd_sock_not_set: str
|
||||
greetd_sock_not_absolute: str
|
||||
greetd_sock_not_socket: str
|
||||
greetd_sock_unreachable: str
|
||||
auth_failed: str
|
||||
wrong_password: str
|
||||
multi_stage_unsupported: str
|
||||
invalid_session_command: str
|
||||
session_start_failed: str
|
||||
reboot_failed: str
|
||||
shutdown_failed: str
|
||||
|
||||
# Error messages (continued)
|
||||
connection_error: str
|
||||
socket_error: str
|
||||
|
||||
# Templates (use .format())
|
||||
faillock_attempts_remaining: str
|
||||
faillock_locked: str
|
||||
|
||||
|
||||
_STRINGS_DE = Strings(
|
||||
password_placeholder="Passwort",
|
||||
reboot_tooltip="Neustart",
|
||||
shutdown_tooltip="Herunterfahren",
|
||||
no_session_selected="Keine Session ausgewählt",
|
||||
greetd_sock_not_set="GREETD_SOCK nicht gesetzt",
|
||||
greetd_sock_not_absolute="GREETD_SOCK ist kein absoluter Pfad",
|
||||
greetd_sock_not_socket="GREETD_SOCK zeigt nicht auf einen Socket",
|
||||
greetd_sock_unreachable="GREETD_SOCK nicht erreichbar",
|
||||
auth_failed="Authentifizierung fehlgeschlagen",
|
||||
wrong_password="Falsches Passwort",
|
||||
multi_stage_unsupported="Mehrstufige Authentifizierung wird nicht unterstützt",
|
||||
invalid_session_command="Ungültiger Session-Befehl",
|
||||
session_start_failed="Session konnte nicht gestartet werden",
|
||||
reboot_failed="Neustart fehlgeschlagen",
|
||||
shutdown_failed="Herunterfahren fehlgeschlagen",
|
||||
connection_error="Verbindungsfehler",
|
||||
socket_error="Socket-Fehler",
|
||||
faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!",
|
||||
faillock_locked="Konto ist möglicherweise gesperrt",
|
||||
)
|
||||
|
||||
_STRINGS_EN = Strings(
|
||||
password_placeholder="Password",
|
||||
reboot_tooltip="Reboot",
|
||||
shutdown_tooltip="Shut down",
|
||||
no_session_selected="No session selected",
|
||||
greetd_sock_not_set="GREETD_SOCK not set",
|
||||
greetd_sock_not_absolute="GREETD_SOCK is not an absolute path",
|
||||
greetd_sock_not_socket="GREETD_SOCK does not point to a socket",
|
||||
greetd_sock_unreachable="GREETD_SOCK unreachable",
|
||||
auth_failed="Authentication failed",
|
||||
wrong_password="Wrong password",
|
||||
multi_stage_unsupported="Multi-stage authentication is not supported",
|
||||
invalid_session_command="Invalid session command",
|
||||
session_start_failed="Failed to start session",
|
||||
reboot_failed="Reboot failed",
|
||||
shutdown_failed="Shutdown failed",
|
||||
connection_error="Connection error",
|
||||
socket_error="Socket error",
|
||||
faillock_attempts_remaining="{n} attempt(s) remaining before lockout!",
|
||||
faillock_locked="Account may be locked",
|
||||
)
|
||||
|
||||
_LOCALE_MAP: dict[str, Strings] = {
|
||||
"de": _STRINGS_DE,
|
||||
"en": _STRINGS_EN,
|
||||
}
|
||||
|
||||
|
||||
def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
|
||||
"""Determine the system language from LANG env var or /etc/locale.conf."""
|
||||
lang = os.environ.get("LANG")
|
||||
|
||||
if not lang and locale_conf_path.exists():
|
||||
for line in locale_conf_path.read_text().splitlines():
|
||||
if line.startswith("LANG="):
|
||||
lang = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
if not lang or lang in ("C", "POSIX"):
|
||||
return "en"
|
||||
|
||||
# Extract language prefix: "de_DE.UTF-8" → "de"
|
||||
lang = lang.split("_")[0].split(".")[0].lower()
|
||||
if not lang.isalpha():
|
||||
return "en"
|
||||
return lang
|
||||
|
||||
|
||||
def load_strings(locale: str | None = None) -> Strings:
|
||||
"""Return the string table for the given locale, defaulting to English."""
|
||||
if locale is None:
|
||||
locale = detect_locale()
|
||||
return _LOCALE_MAP.get(locale, _STRINGS_EN)
|
||||
@@ -1,64 +0,0 @@
|
||||
# ABOUTME: greetd IPC protocol implementation — communicates via Unix socket.
|
||||
# ABOUTME: Uses length-prefixed JSON encoding as specified by the greetd IPC protocol.
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
MAX_PAYLOAD_SIZE = 65536
|
||||
|
||||
|
||||
def _recvall(sock: Any, n: int) -> bytes:
|
||||
"""Receive exactly n bytes from socket, looping on partial reads."""
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed while reading data")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def send_message(sock: Any, msg: dict) -> None:
|
||||
"""Send a length-prefixed JSON message to the greetd socket."""
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
if len(payload) > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"Payload too large: {len(payload)} bytes (max {MAX_PAYLOAD_SIZE})")
|
||||
header = struct.pack("=I", len(payload))
|
||||
sock.sendall(header + payload)
|
||||
|
||||
|
||||
def recv_message(sock: Any) -> dict:
|
||||
"""Receive a length-prefixed JSON message from the greetd socket."""
|
||||
header = _recvall(sock, 4)
|
||||
length = struct.unpack("=I", header)[0]
|
||||
|
||||
if length > MAX_PAYLOAD_SIZE:
|
||||
raise ConnectionError(f"Payload too large: {length} bytes (max {MAX_PAYLOAD_SIZE})")
|
||||
|
||||
payload = _recvall(sock, length)
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def create_session(sock: Any, username: str) -> dict:
|
||||
"""Send a create_session request to greetd and return the response."""
|
||||
send_message(sock, {"type": "create_session", "username": username})
|
||||
return recv_message(sock)
|
||||
|
||||
|
||||
def post_auth_response(sock: Any, response: str | None) -> dict:
|
||||
"""Send an authentication response (e.g. password) to greetd."""
|
||||
send_message(sock, {"type": "post_auth_message_response", "response": response})
|
||||
return recv_message(sock)
|
||||
|
||||
|
||||
def start_session(sock: Any, cmd: list[str]) -> dict:
|
||||
"""Send a start_session request to launch the user's session."""
|
||||
send_message(sock, {"type": "start_session", "cmd": cmd})
|
||||
return recv_message(sock)
|
||||
|
||||
|
||||
def cancel_session(sock: Any) -> dict:
|
||||
"""Cancel the current authentication session."""
|
||||
send_message(sock, {"type": "cancel_session"})
|
||||
return recv_message(sock)
|
||||
@@ -1,162 +0,0 @@
|
||||
# ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell.
|
||||
# ABOUTME: Handles multi-monitor setup: login UI on primary, wallpaper on secondary monitors.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from moongreet.config import load_config, resolve_wallpaper_path
|
||||
from moongreet.greeter import GreeterWindow, WallpaperWindow
|
||||
|
||||
# gtk4-layer-shell is optional for development/testing
|
||||
try:
|
||||
gi.require_version("Gtk4LayerShell", "1.0")
|
||||
from gi.repository import Gtk4LayerShell
|
||||
HAS_LAYER_SHELL = True
|
||||
except (ValueError, ImportError):
|
||||
HAS_LAYER_SHELL = False
|
||||
|
||||
LOG_DIR = Path("/var/cache/moongreet")
|
||||
LOG_FILE = LOG_DIR / "moongreet.log"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Configure logging to file and stderr."""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
)
|
||||
|
||||
# Always log to stderr
|
||||
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||
stderr_handler.setLevel(logging.INFO)
|
||||
stderr_handler.setFormatter(formatter)
|
||||
root.addHandler(stderr_handler)
|
||||
|
||||
# Log to file if the directory is writable
|
||||
if LOG_DIR.is_dir():
|
||||
try:
|
||||
file_handler = logging.FileHandler(LOG_FILE)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
except PermissionError:
|
||||
logger.warning("Cannot write to %s", LOG_FILE)
|
||||
|
||||
|
||||
class MoongreetApp(Gtk.Application):
|
||||
"""GTK Application for the Moongreet greeter."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id="dev.moonarch.moongreet")
|
||||
self._wallpaper_ctx = None
|
||||
self._secondary_windows: list[WallpaperWindow] = []
|
||||
|
||||
def do_activate(self) -> None:
|
||||
"""Create and present greeter windows on all monitors."""
|
||||
display = Gdk.Display.get_default()
|
||||
if display is None:
|
||||
logger.error("No display available — cannot start greeter UI")
|
||||
return
|
||||
|
||||
self._register_icons(display)
|
||||
self._load_css(display)
|
||||
|
||||
# Resolve wallpaper once, share across all windows
|
||||
config = load_config()
|
||||
bg_path, self._wallpaper_ctx = resolve_wallpaper_path(config)
|
||||
|
||||
monitors = display.get_monitors()
|
||||
primary_monitor = None
|
||||
|
||||
# Find primary monitor — fall back to first available
|
||||
for i in range(monitors.get_n_items()):
|
||||
monitor = monitors.get_item(i)
|
||||
if hasattr(monitor, 'is_primary') and monitor.is_primary():
|
||||
primary_monitor = monitor
|
||||
break
|
||||
if primary_monitor is None and monitors.get_n_items() > 0:
|
||||
primary_monitor = monitors.get_item(0)
|
||||
|
||||
# Main greeter window (login UI) on primary monitor
|
||||
greeter = GreeterWindow(bg_path=bg_path, config=config, application=self)
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(greeter, keyboard=True)
|
||||
if primary_monitor is not None:
|
||||
Gtk4LayerShell.set_monitor(greeter, primary_monitor)
|
||||
greeter.present()
|
||||
|
||||
# Wallpaper-only windows on secondary monitors
|
||||
for i in range(monitors.get_n_items()):
|
||||
monitor = monitors.get_item(i)
|
||||
if monitor == primary_monitor:
|
||||
continue
|
||||
wallpaper_win = WallpaperWindow(bg_path=bg_path, application=self)
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(wallpaper_win, keyboard=False)
|
||||
Gtk4LayerShell.set_monitor(wallpaper_win, monitor)
|
||||
wallpaper_win.present()
|
||||
self._secondary_windows.append(wallpaper_win)
|
||||
|
||||
def do_shutdown(self) -> None:
|
||||
"""Clean up wallpaper context manager on exit."""
|
||||
if self._wallpaper_ctx is not None:
|
||||
self._wallpaper_ctx.__exit__(None, None, None)
|
||||
self._wallpaper_ctx = None
|
||||
Gtk.Application.do_shutdown(self)
|
||||
|
||||
def _register_icons(self, display: Gdk.Display) -> None:
|
||||
"""Register custom icons from the package data/icons directory."""
|
||||
icons_dir = files("moongreet") / "data" / "icons"
|
||||
icon_theme = Gtk.IconTheme.get_for_display(display)
|
||||
icon_theme.add_search_path(str(icons_dir))
|
||||
|
||||
def _load_css(self, display: Gdk.Display) -> None:
|
||||
"""Load the CSS stylesheet for the greeter."""
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_path = files("moongreet") / "style.css"
|
||||
css_provider.load_from_path(str(css_path))
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
display,
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
def _setup_layer_shell(self, window: Gtk.Window, keyboard: bool = False) -> None:
|
||||
"""Configure gtk4-layer-shell for fullscreen display."""
|
||||
Gtk4LayerShell.init_for_window(window)
|
||||
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP)
|
||||
if keyboard:
|
||||
Gtk4LayerShell.set_keyboard_mode(
|
||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||
)
|
||||
# Anchor to all edges for fullscreen
|
||||
for edge in [
|
||||
Gtk4LayerShell.Edge.TOP,
|
||||
Gtk4LayerShell.Edge.BOTTOM,
|
||||
Gtk4LayerShell.Edge.LEFT,
|
||||
Gtk4LayerShell.Edge.RIGHT,
|
||||
]:
|
||||
Gtk4LayerShell.set_anchor(window, edge, True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the Moongreet application."""
|
||||
_setup_logging()
|
||||
logger.info("Moongreet starting")
|
||||
app = MoongreetApp()
|
||||
app.run(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,17 +0,0 @@
|
||||
# ABOUTME: Power actions — reboot and shutdown via loginctl.
|
||||
# ABOUTME: Simple wrappers around system commands for the greeter UI.
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
POWER_TIMEOUT = 30
|
||||
|
||||
|
||||
def reboot() -> None:
|
||||
"""Reboot the system via loginctl."""
|
||||
subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Shut down the system via loginctl."""
|
||||
subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT)
|
||||
@@ -1,63 +0,0 @@
|
||||
# ABOUTME: Session detection — discovers available Wayland and X11 sessions.
|
||||
# ABOUTME: Parses .desktop files from standard session directories.
|
||||
|
||||
import configparser
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_WAYLAND_DIRS = (Path("/usr/share/wayland-sessions"),)
|
||||
DEFAULT_XSESSION_DIRS = (Path("/usr/share/xsessions"),)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Represents an available login session."""
|
||||
|
||||
name: str
|
||||
exec_cmd: str
|
||||
session_type: str # "wayland" or "x11"
|
||||
|
||||
|
||||
def _parse_desktop_file(path: Path, session_type: str) -> Session | None:
|
||||
"""Parse a .desktop file and return a Session, or None if invalid."""
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read(path)
|
||||
|
||||
section = "Desktop Entry"
|
||||
if not config.has_section(section):
|
||||
return None
|
||||
|
||||
name = config.get(section, "Name", fallback=None)
|
||||
exec_cmd = config.get(section, "Exec", fallback=None)
|
||||
|
||||
if not name or not exec_cmd:
|
||||
return None
|
||||
|
||||
return Session(name=name, exec_cmd=exec_cmd, session_type=session_type)
|
||||
|
||||
|
||||
def get_sessions(
|
||||
wayland_dirs: Sequence[Path] = DEFAULT_WAYLAND_DIRS,
|
||||
xsession_dirs: Sequence[Path] = DEFAULT_XSESSION_DIRS,
|
||||
) -> list[Session]:
|
||||
"""Discover available sessions from .desktop files."""
|
||||
sessions: list[Session] = []
|
||||
|
||||
for directory in wayland_dirs:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for desktop_file in sorted(directory.glob("*.desktop")):
|
||||
session = _parse_desktop_file(desktop_file, "wayland")
|
||||
if session:
|
||||
sessions.append(session)
|
||||
|
||||
for directory in xsession_dirs:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for desktop_file in sorted(directory.glob("*.desktop")):
|
||||
session = _parse_desktop_file(desktop_file, "x11")
|
||||
if session:
|
||||
sessions.append(session)
|
||||
|
||||
return sessions
|
||||
@@ -1,87 +0,0 @@
|
||||
/* ABOUTME: GTK4 CSS stylesheet for the Moongreet greeter. */
|
||||
/* ABOUTME: Defines styling for the login screen layout. */
|
||||
|
||||
/* Main window background */
|
||||
window.greeter {
|
||||
background-color: #1a1a2e;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Central login area */
|
||||
.login-box {
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Round avatar image — size is set via set_size_request() in code */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
min-width: 128px;
|
||||
min-height: 128px;
|
||||
background-color: @theme_selected_bg_color;
|
||||
border: 3px solid alpha(white, 0.3);
|
||||
}
|
||||
|
||||
/* Username label */
|
||||
.username-label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Session dropdown */
|
||||
.session-dropdown {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
/* Password entry field */
|
||||
.password-entry {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
/* Error message label */
|
||||
.error-label {
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* User list on the bottom left */
|
||||
.user-list {
|
||||
background-color: transparent;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.user-list-item {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-list-item:hover {
|
||||
background-color: alpha(white, 0.15);
|
||||
}
|
||||
|
||||
.user-list-item:selected {
|
||||
background-color: alpha(white, 0.2);
|
||||
}
|
||||
|
||||
/* Power buttons on the bottom right */
|
||||
.power-button {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 0px;
|
||||
border-radius: 24px;
|
||||
background-color: alpha(white, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.power-button:hover {
|
||||
background-color: alpha(white, 0.25);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
# ABOUTME: User detection — parses /etc/passwd for login users, finds avatars and GTK themes.
|
||||
# ABOUTME: Provides User dataclass and helper functions for the greeter UI.
|
||||
|
||||
import configparser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"}
|
||||
MIN_UID = 1000
|
||||
MAX_UID = 65533
|
||||
|
||||
DEFAULT_PASSWD = Path("/etc/passwd")
|
||||
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Represents a system user suitable for login."""
|
||||
|
||||
username: str
|
||||
uid: int
|
||||
gecos: str
|
||||
home: Path
|
||||
shell: str
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Return gecos if available, otherwise username."""
|
||||
return self.gecos if self.gecos else self.username
|
||||
|
||||
|
||||
def get_users(passwd_path: Path = DEFAULT_PASSWD) -> list[User]:
|
||||
"""Parse /etc/passwd and return users with UID in the login range."""
|
||||
users: list[User] = []
|
||||
|
||||
if not passwd_path.exists():
|
||||
return users
|
||||
|
||||
for line in passwd_path.read_text().splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
|
||||
username, _, uid_str, _, gecos, home, shell = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
|
||||
|
||||
try:
|
||||
uid = int(uid_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if uid < MIN_UID or uid > MAX_UID:
|
||||
continue
|
||||
if shell in NOLOGIN_SHELLS:
|
||||
continue
|
||||
if "/" in username or username.startswith("."):
|
||||
continue
|
||||
|
||||
users.append(User(
|
||||
username=username,
|
||||
uid=uid,
|
||||
gecos=gecos,
|
||||
home=Path(home),
|
||||
shell=shell,
|
||||
))
|
||||
|
||||
return users
|
||||
|
||||
|
||||
def get_avatar_path(
|
||||
username: str,
|
||||
accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR,
|
||||
home_dir: Path | None = None,
|
||||
) -> Path | None:
|
||||
"""Find avatar for a user: AccountsService icon → ~/.face → None."""
|
||||
# AccountsService icon
|
||||
icon = accountsservice_dir / username
|
||||
if icon.exists() and not icon.is_symlink():
|
||||
return icon
|
||||
|
||||
# ~/.face fallback
|
||||
if home_dir is not None:
|
||||
face = home_dir / ".face"
|
||||
if face.exists() and not face.is_symlink():
|
||||
return face
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_gtk_theme(config_dir: Path | None = None) -> str | None:
|
||||
"""Read the GTK theme name from a user's gtk-4.0/settings.ini."""
|
||||
if config_dir is None:
|
||||
return None
|
||||
|
||||
settings_file = config_dir / "settings.ini"
|
||||
if not settings_file.exists():
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
config.read(settings_file)
|
||||
except configparser.Error:
|
||||
return None
|
||||
|
||||
if config.has_option("Settings", "gtk-theme-name"):
|
||||
theme = config.get("Settings", "gtk-theme-name")
|
||||
if theme:
|
||||
return theme
|
||||
|
||||
return None
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// ABOUTME: Power actions — reboot and shutdown via loginctl.
|
||||
// ABOUTME: Wrappers around system commands for the greeter UI.
|
||||
|
||||
use std::fmt;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PowerError {
|
||||
CommandFailed { action: &'static str, message: String },
|
||||
Timeout { action: &'static str },
|
||||
}
|
||||
|
||||
impl fmt::Display for PowerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PowerError::CommandFailed { action, message } => {
|
||||
write!(f, "{action} failed: {message}")
|
||||
}
|
||||
PowerError::Timeout { action } => {
|
||||
write!(f, "{action} timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PowerError {}
|
||||
|
||||
/// Run a command and return a PowerError on failure.
|
||||
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
|
||||
let child = Command::new(program)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| PowerError::CommandFailed {
|
||||
action,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| PowerError::CommandFailed {
|
||||
action,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(PowerError::CommandFailed {
|
||||
action,
|
||||
message: format!("exit code {}: {}", output.status, stderr.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reboot the system via loginctl.
|
||||
pub fn reboot() -> Result<(), PowerError> {
|
||||
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
|
||||
}
|
||||
|
||||
/// Shut down the system via loginctl.
|
||||
pub fn shutdown() -> Result<(), PowerError> {
|
||||
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn power_error_command_failed_display() {
|
||||
let err = PowerError::CommandFailed {
|
||||
action: "reboot",
|
||||
message: "No such file or directory".to_string(),
|
||||
};
|
||||
assert_eq!(err.to_string(), "reboot failed: No such file or directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_error_timeout_display() {
|
||||
let err = PowerError::Timeout { action: "shutdown" };
|
||||
assert_eq!(err.to_string(), "shutdown timed out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_returns_error_for_missing_binary() {
|
||||
let result = run_command("test", "nonexistent-binary-xyz", &[]);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, PowerError::CommandFailed { action: "test", .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_returns_error_on_nonzero_exit() {
|
||||
let result = run_command("test", "false", &[]);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, PowerError::CommandFailed { action: "test", .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_succeeds_for_true() {
|
||||
let result = run_command("test", "true", &[]);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_passes_args() {
|
||||
let result = run_command("test", "echo", &["hello", "world"]);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// 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,
|
||||
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;
|
||||
|
||||
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=") {
|
||||
if name.is_none() {
|
||||
name = Some(value.to_string());
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Exec=") {
|
||||
if 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())?;
|
||||
|
||||
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: Vec<PathBuf> =
|
||||
DEFAULT_WAYLAND_DIRS.iter().map(PathBuf::from).collect();
|
||||
let default_xsession: Vec<PathBuf> =
|
||||
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) => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
// ABOUTME: User detection — parses /etc/passwd for login users and finds avatars.
|
||||
// ABOUTME: Provides User struct and helpers for the greeter UI.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MIN_UID: u32 = 1000;
|
||||
const MAX_UID: u32 = 65533;
|
||||
|
||||
const DEFAULT_PASSWD: &str = "/etc/passwd";
|
||||
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
|
||||
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet";
|
||||
|
||||
/// Shells that indicate a user cannot log in.
|
||||
const NOLOGIN_SHELLS: &[&str] = &[
|
||||
"/usr/sbin/nologin",
|
||||
"/sbin/nologin",
|
||||
"/bin/false",
|
||||
"/usr/bin/nologin",
|
||||
];
|
||||
|
||||
/// Represents a system user suitable for login.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub username: String,
|
||||
pub uid: u32,
|
||||
pub gecos: String,
|
||||
pub home: PathBuf,
|
||||
pub shell: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Return the display name (GECOS if available, otherwise username).
|
||||
pub fn display_name(&self) -> &str {
|
||||
if self.gecos.is_empty() {
|
||||
&self.username
|
||||
} else {
|
||||
&self.gecos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse /etc/passwd and return users with UID in the login range.
|
||||
pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
|
||||
let path = passwd_path.unwrap_or(Path::new(DEFAULT_PASSWD));
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut users = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() < 7 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let username = parts[0];
|
||||
let uid_str = parts[2];
|
||||
let gecos = parts[4];
|
||||
let home = parts[5];
|
||||
let shell = parts[6];
|
||||
|
||||
let uid = match uid_str.parse::<u32>() {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if uid < MIN_UID || uid > MAX_UID {
|
||||
continue;
|
||||
}
|
||||
if NOLOGIN_SHELLS.contains(&shell) {
|
||||
continue;
|
||||
}
|
||||
// Path traversal prevention
|
||||
if username.contains('/') || username.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
users.push(User {
|
||||
username: username.to_string(),
|
||||
uid,
|
||||
gecos: gecos.to_string(),
|
||||
home: PathBuf::from(home),
|
||||
shell: shell.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
users
|
||||
}
|
||||
|
||||
/// Find avatar for a user: AccountsService icon > ~/.face > None.
|
||||
/// Rejects symlinks to prevent path traversal.
|
||||
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
|
||||
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
||||
}
|
||||
|
||||
/// Find avatar with configurable AccountsService dir (for testing).
|
||||
pub fn get_avatar_path_with(
|
||||
username: &str,
|
||||
home: &Path,
|
||||
accountsservice_dir: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
// AccountsService icon takes priority
|
||||
if accountsservice_dir.exists() {
|
||||
let icon = accountsservice_dir.join(username);
|
||||
if icon.exists() && !icon.is_symlink() {
|
||||
return Some(icon);
|
||||
}
|
||||
}
|
||||
|
||||
// ~/.face fallback
|
||||
let face = home.join(".face");
|
||||
if face.exists() && !face.is_symlink() {
|
||||
return Some(face);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the GResource path to the default avatar SVG.
|
||||
pub fn get_default_avatar_path() -> String {
|
||||
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_passwd(dir: &Path, content: &str) -> PathBuf {
|
||||
let path = dir.join("passwd");
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_normal_user() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(
|
||||
dir.path(),
|
||||
"testuser:x:1000:1000:Test User:/home/testuser:/bin/bash\n",
|
||||
);
|
||||
let users = get_users(Some(&path));
|
||||
assert_eq!(users.len(), 1);
|
||||
assert_eq!(users[0].username, "testuser");
|
||||
assert_eq!(users[0].uid, 1000);
|
||||
assert_eq!(users[0].display_name(), "Test User");
|
||||
assert_eq!(users[0].home, PathBuf::from("/home/testuser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_system_users() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(dir.path(), "root:x:0:0:root:/root:/bin/bash\n");
|
||||
let users = get_users(Some(&path));
|
||||
assert!(users.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_nologin_users() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(
|
||||
dir.path(),
|
||||
"nobody:x:1000:1000::/home/nobody:/usr/sbin/nologin\n",
|
||||
);
|
||||
let users = get_users(Some(&path));
|
||||
assert!(users.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_users_with_slash_in_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(
|
||||
dir.path(),
|
||||
"bad/user:x:1000:1000::/home/bad:/bin/bash\n",
|
||||
);
|
||||
let users = get_users(Some(&path));
|
||||
assert!(users.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_users_starting_with_dot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(
|
||||
dir.path(),
|
||||
".hidden:x:1000:1000::/home/hidden:/bin/bash\n",
|
||||
);
|
||||
let users = get_users(Some(&path));
|
||||
assert!(users.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_gecos_uses_username() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(
|
||||
dir.path(),
|
||||
"testuser:x:1000:1000::/home/testuser:/bin/bash\n",
|
||||
);
|
||||
let users = get_users(Some(&path));
|
||||
assert_eq!(users[0].display_name(), "testuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_users() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = make_passwd(
|
||||
dir.path(),
|
||||
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n\
|
||||
bob:x:1001:1001:Bob:/home/bob:/bin/zsh\n",
|
||||
);
|
||||
let users = get_users(Some(&path));
|
||||
assert_eq!(users.len(), 2);
|
||||
assert_eq!(users[0].username, "alice");
|
||||
assert_eq!(users[1].username, "bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_empty_for_missing_file() {
|
||||
let users = get_users(Some(Path::new("/nonexistent/passwd")));
|
||||
assert!(users.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accountsservice_icon_takes_priority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let icons_dir = dir.path().join("icons");
|
||||
fs::create_dir(&icons_dir).unwrap();
|
||||
let icon = icons_dir.join("testuser");
|
||||
fs::write(&icon, "fake image").unwrap();
|
||||
|
||||
let home = dir.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let face = home.join(".face");
|
||||
fs::write(&face, "fake face").unwrap();
|
||||
|
||||
let path = get_avatar_path_with("testuser", &home, &icons_dir);
|
||||
assert_eq!(path, Some(icon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_file_used_when_no_accountsservice() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let face = home.join(".face");
|
||||
fs::write(&face, "fake face").unwrap();
|
||||
|
||||
let path = get_avatar_path_with("testuser", &home, Path::new("/nonexistent"));
|
||||
assert_eq!(path, Some(face));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_avatar() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = get_avatar_path_with("testuser", dir.path(), Path::new("/nonexistent"));
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_avatar() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let real_file = dir.path().join("real-avatar");
|
||||
fs::write(&real_file, "fake").unwrap();
|
||||
std::os::unix::fs::symlink(&real_file, home.join(".face")).unwrap();
|
||||
|
||||
let path = get_avatar_path_with("testuser", &home, Path::new("/nonexistent"));
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_avatar_path_is_gresource() {
|
||||
let path = get_default_avatar_path();
|
||||
assert!(path.contains("default-avatar.svg"));
|
||||
assert!(path.starts_with("/dev/moonarch/moongreet"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user