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:
2026-03-27 22:08:33 +01:00
parent de0b1d40ba
commit 226bbb75e4
39 changed files with 4395 additions and 2768 deletions
+219
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+333
View File
@@ -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
View File
@@ -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
View File
@@ -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: &gtk::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: &gtk::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();
}
-2
View File
@@ -1,2 +0,0 @@
# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4.
# ABOUTME: Part of the Moonarch ecosystem.
-84
View File
@@ -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
View File
@@ -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

-664
View File
@@ -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
-117
View File
@@ -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)
-64
View File
@@ -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)
-162
View File
@@ -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()
-17
View File
@@ -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)
-63
View File
@@ -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
-87
View File
@@ -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);
}
-109
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
}
}