Compare commits

..

4 Commits

Author SHA1 Message Date
ce9f2196ca refactor: single greeter window, drop per-output keyboard grab (v0.10.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
The v0.8.0 → v0.8.5 multi-monitor story (login widget on primary only,
wallpaper-only on secondaries, KeyboardMode::Exclusive on the primary
surface, hotplug handler) was a pile of workarounds that kept breaking
on real hardware — after switching the greeter compositor to niri, the
pointer could not cross onto the primary output and keyboard tab did
not reach the UI. Niri in a normal user session has none of these
issues; the bug was moongreet's own output-scoped policy.

Back to basics: one layer-shell window on the built-in display,
KeyboardMode::OnDemand. Secondary outputs stay under compositor
control. No hotplug callbacks, no wallpaper-only windows, no DisplayLink
phantom workarounds.

-81 / +26 lines.
2026-04-24 11:27:48 +02:00
29ce185886 feat: apply cursor theme via GtkSettings instead of XCURSOR_THEME env (v0.9.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
GTK4 under greetd does not honour XCURSOR_THEME reliably — the env-prefix
hack in /etc/greetd/config.toml only reached the wlroots pointer in cage,
while GTK widgets kept using the default fallback cursor. Mirror the
existing gtk-theme handling: new cursor-theme + cursor-size fields in the
[appearance] section, applied via gtk::Settings::set_gtk_cursor_theme_*.
Keeps the fix scoped to the greeter, no system-wide GTK config changes.
2026-04-24 08:56:41 +02:00
91b4289748 fix: wallpaper-only windows on secondary monitors (v0.8.5)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
The v0.8.4 keyboard fix only half-worked: keys were still dropped
until the pointer moved to the built-in panel. Niri scopes layer-shell
keyboard routing per active output, so a single Exclusive surface is
not enough when another output is active.

Revert 2026-04-08 partially: only the built-in panel shows the login
widget, other monitors get a wallpaper-only window with
KeyboardMode::None. Hotplugged monitors also get wallpaper-only.
Compositor-agnostic — no Niri IPC.
2026-04-23 14:06:12 +02:00
97165d94f8 fix: keyboard focus on built-in display to avoid evdi phantom grab (v0.8.4)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
DisplayLink/evdi virtual displays enumerate as DVI-I-* before eDP-1 and
were stealing the KeyboardMode::Exclusive grab on the first enumerated
monitor, leaving the visible greeter surfaces without keyboard input.

Introduce pick_primary_monitor_index() that prefers eDP/LVDS/DSI
connectors for the keyboard grab and falls back to index 0 when no
built-in panel is present. Pure, unit-tested; hotplug path unchanged.
2026-04-23 11:02:24 +02:00
12 changed files with 244 additions and 183 deletions

View File

@ -43,9 +43,9 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- `power.rs` — Reboot/Shutdown via loginctl
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200)
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor mit Hotplug via `items-changed` auf Monitor-ListModel (one greeter window per monitor, first gets keyboard), systemd-journal-logger
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, cursor-theme, cursor-size, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200) + Cursor-Size-Validierung (range 1256)
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files).
- `main.rs` — Entry Point, GTK App, Layer Shell Setup. Ein einziges Greeter-Fenster, verankert am Built-in-Display (via `pick_primary_monitor_index`), `KeyboardMode::OnDemand` — moongreet ist ein normaler layer-shell-client, keine output-scoped policies. Sekundäre Monitore bleiben unter Compositor-Kontrolle. Systemd-journal-logger.
- `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions
@ -60,9 +60,11 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
- **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt
- **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config
- **Cursor-Theme via GtkSettings**: GTK4 unter greetd liest `XCURSOR_THEME` env nicht zuverlässig — Cursor wird via `gtk::Settings::set_gtk_cursor_theme_name()` gesetzt, analog zu `gtk-theme`. Gleiche Validierung (`is_valid_gtk_theme`) gegen Path-Traversal.
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
- **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor
- **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade → `None`
- **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal
- **Single Greeter Window, keine Output-Policies**: Ein einziges layer-shell-fenster auf dem Built-in-Display, `KeyboardMode::OnDemand`. Sekundäre Outputs bleiben unter Compositor-Kontrolle. Grund: Die output-scoped policies aus v0.8.0v0.8.5 (Exclusive-Keyboard auf Primary, Wallpaper-Only auf Secondaries, Hotplug-Callbacks) haben den Greeter bei realen Multi-Monitor-Setups wiederholt kaputt gemacht (Pointer kommt nicht zum Primary, Keyboard tabt nicht zur UI). Im User-Session-Niri gibt es diese Probleme nicht — moongreet verhält sich jetzt wie jeder normale layer-shell-client.

2
Cargo.lock generated
View File

@ -575,7 +575,7 @@ dependencies = [
[[package]]
name = "moongreet"
version = "0.8.6"
version = "0.9.0"
dependencies = [
"gdk-pixbuf",
"gdk4",

View File

@ -1,6 +1,6 @@
[package]
name = "moongreet"
version = "0.8.6"
version = "0.10.0"
edition = "2024"
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
license = "MIT"

View File

@ -1,25 +1,32 @@
# Decisions
## 2026-04-24 Audit LOW fixes: stdout null, utf-8 path, debug value, hidden sessions (v0.8.6)
## 2026-04-24 Single greeter window, no per-output keyboard grab (v0.10.0)
- **Who**: ClaudeCode, Dom
- **Why**: Four LOW findings cleared in a single pass. (1) `power::run_command` piped stdout it never read — structurally fragile even though current callers stay well under the pipe buffer. (2) Relative wallpaper paths were resolved via `to_string_lossy`, silently substituting `U+FFFD` for non-UTF-8 bytes and producing a path that cannot be opened. (3) `MOONGREET_DEBUG` escalated log verbosity on mere presence, so an empty variable leaked auth metadata into the journal. (4) `Hidden=true` and `NoDisplay=true` `.desktop` entries appeared in the session dropdown even though they mark disabled or stub sessions.
- **Tradeoffs**: Gating debug on the literal value `"1"` is slightly stricter than most tools but matches the security-first posture. Filtering Hidden/NoDisplay means legitimately hidden but functional sessions are now unselectable from the greeter — acceptable, that is the convention these keys signal.
- **How**: (1) `.stdout(Stdio::null())` replaces the unused pipe. (2) `to_string_lossy().to_string()` replaced by `to_str().map(|s| s.to_string())` with a `log::warn!` fallback for non-UTF-8 paths. (3) `match std::env::var("MOONGREET_DEBUG").ok().as_deref()``Some("1")` selects Debug, everything else Info. (4) `parse_desktop_file` reads `Hidden=` and `NoDisplay=`, returns `None` if either is `true`.
- **Why**: The v0.8.0 → v0.8.4 → v0.8.5 sequence accumulated multi-monitor logic (login widget on primary, wallpaper-only on secondaries, `KeyboardMode::Exclusive` on the primary surface) to work around keyboard-routing on specific hardware setups. After the moonarch greeter switched to niri, the symptoms returned: the UI was on eDP-1 but the pointer could not cross onto it, and keyboard tab did not reach the login widget. Niri in a normal user session never behaves like this — the issue was our greeter's self-imposed per-output scope, not the compositor. Every earlier "fix" made it more bespoke instead of making moongreet a well-behaved layer-shell client.
- **Tradeoffs**: Reverts the multi-output story entirely. Secondary monitors get nothing from moongreet — the compositor decides what renders there (black, its own wallpaper, whatever). The "wallpaper on every screen" look is gone. In exchange, cursor and keyboard follow normal niri focus rules, nothing is grabbed, no hotplug callbacks, no DisplayLink phantom workarounds.
- **How**: `main.rs::activate` builds **one** greeter window, anchors it to the built-in display picked by `pick_primary_monitor_index`, and calls `setup_layer_shell` with `KeyboardMode::OnDemand`. The hotplug `connect_items_changed` handler is gone. `create_wallpaper_window` is removed. `setup_layer_shell` no longer takes a `keyboard: bool` — there is only one policy.
## 2026-04-24 Audit MEDIUM fixes: FP double-init, async avatar, symlink, FD leak (v0.8.5)
## 2026-04-24 Cursor theme via config instead of env (v0.9.0)
- **Who**: ClaudeCode, Dom
- **Why**: Six MEDIUM findings: (1) i18n test `all_string_fields_nonempty` missed four string fields — future locales could ship empty strings unnoticed. (2) Fast user-switch could spawn two parallel fprintd `init_async` calls because both coroutines saw `fingerprint_probe = None` before either stored its probe. (3) Synchronous avatar decode via `Pixbuf::from_file_at_scale` on the GTK main thread, stalling clicks. (4) Wallpaper `MAX_WALLPAPER_FILE_SIZE = 50 MB` bounded decode at up to ~2 s. (5) Fallback wallpaper path used `is_file()` which follows symlinks, inconsistent with the symlink-rejecting user-config path. (6) After a failed login the cloned `greetd_sock` descriptor remained in shared state until the next user switch, accumulating stale FDs across retries.
- **Tradeoffs**: The init-race guard uses a bool flag on `GreeterState` + a 25 ms polling yield — cheap and race-free, but introduces a very short latency when a second probe waits. Lowering `MAX_WALLPAPER_FILE_SIZE` to 10 MB and `MAX_AVATAR_FILE_SIZE` to 5 MB caps worst-case decode but rejects legitimately huge (4K raw) wallpapers; acceptable for a greeter. Async avatar decode shows the default icon for a frame or two on cache miss.
- **How**: (1) Four new `assert!` lines in `i18n::tests::all_string_fields_nonempty`. (2) New `fingerprint_probe_initializing: bool` on `GreeterState`, atomic check-and-set under `borrow_mut`, losing coroutines yield via `glib::timeout_future` until the winning init completes. (3) `set_avatar_from_file` uses `gio::File::read_future` + `Pixbuf::from_stream_at_scale_future` inside a `glib::spawn_future_local`, sets the default icon first, swaps on success. (4) Lower both size constants. (5) `resolve_background_path_with` now applies the same `symlink_metadata` + `!is_symlink` check to the Moonarch fallback. (6) After the login worker returns, `state.greetd_sock.lock().take()` drops the stale clone regardless of login outcome.
- **Why**: Cursor theme in the greeter was the default fallback even with `XCURSOR_THEME=Sweet-cursors` in `/etc/greetd/config.toml`'s `env` prefix. Cage forwards the env, but GTK4 does not honour `XCURSOR_THEME` reliably under greetd — it picks up the theme from `gtk-cursor-theme-name` on `GtkSettings`, and without a session-level settings.ini or GSettings override in the greeter user's home, that property stays at the GTK default. Adding an env-var hack worked for the wlroots pointer rendered by cage, but GTK widgets (button hover, text input) used their own wrong cursor.
- **Tradeoffs**: Adds two config fields (`cursor-theme`, `cursor-size`) — symmetric with the existing `gtk-theme` field and justified by the same cause (GTK4 under greetd ignores the usual discovery paths). Alternative would have been a system-wide `/etc/gtk-4.0/settings.ini` with `gtk-cursor-theme-name=`, but that couples moongreet's appearance to the host system's GTK config and affects every GTK4 app running as any user.
- **How**: `config.rs` gains `cursor_theme: Option<String>` and `cursor_size: Option<i32>` (range-validated 1256). `greeter.rs::create_greeter_window` applies them via `gtk::Settings::set_gtk_cursor_theme_name()` and `set_gtk_cursor_theme_size()` directly after the existing `gtk-theme` handling, reusing `is_valid_gtk_theme()` for name validation. Moonarch's deployed config gains `cursor-theme = "Sweet-cursors"` + `cursor-size = 24`. The env-prefix hack in `/etc/greetd/config.toml` is now redundant.
## 2026-04-24 Audit fix: shrink password-in-memory window (v0.8.4)
## 2026-04-23 Wallpaper-only windows on secondary monitors (v0.8.5)
- **Who**: ClaudeCode, Dom
- **Why**: Security audit flagged the GTK password path as holding more copies of the plaintext password in memory than necessary. `attempt_login` wrapped the already-`Zeroizing<String>` caller value into a second `Zeroizing<String>` (`password.to_string()`), and the GTK `GString` backing `entry.text()` persisted in libc malloc'd memory until the allocator reused the page.
- **Tradeoffs**: The GTK `GString` and the libc `strdup` copy on the PAM FFI boundary remain non-zeroizable — this is an inherent GTK/libc limitation, already documented in CLAUDE.md. This change reduces the Rust-owned copies to one and clears the `PasswordEntry` text field immediately after extraction to shorten the GTK-side window.
- **How**: (1) `attempt_login` now takes `password: Zeroizing<String>` by value instead of `&str`, moving ownership into the `spawn_blocking` closure. (2) The redundant `Zeroizing::new(password.to_string())` inside `attempt_login` is removed. (3) `password_entry.set_text("")` is called right after the password is extracted from the activate handler, shortening the lifetime of the GTK-internal buffer.
- **Why**: The v0.8.4 fix (keyboard grab on the built-in panel) only half-worked. The greeter still rejected keystrokes until the user moved the mouse to eDP-1 — Niri scopes layer-shell keyboard routing by active output, so even though the primary window was the sole `KeyboardMode::Exclusive` surface, keys went nowhere when another output was active. Hardcoding a compositor focus call (e.g. `niri msg action focus-monitor`) would tie moongreet to a specific compositor.
- **Tradeoffs**: Reverts part of 2026-04-08: only the built-in panel shows the full greeter UI, other monitors go back to wallpaper-only. Users with multiple monitors lose the symmetric "login widget on every screen" look, but gain a reliable keyboard path regardless of which output the compositor considers active at startup. Compositor-agnostic — no Niri-specific IPC.
- **How**: New `create_wallpaper_window()` in `greeter.rs` builds a minimal `ApplicationWindow` with the shared background `Picture` (same `blur_cache` as the primary) and no login widgets. `main.rs` uses `create_greeter_window()` for the index returned by `pick_primary_monitor_index()` and `create_wallpaper_window()` for the rest. Hotplugged monitors also get wallpaper-only windows. Both variants use `Layer::Top`; only the primary sets `KeyboardMode::Exclusive`.
## 2026-04-23 Keyboard focus on built-in display, not first enumerated monitor (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: With a DisplayLink dock attached, the greeter showed its UI on all monitors but the password entry accepted no input. `display.monitors()` enumerated evdi phantom connectors (`DVI-I-*`) before the laptop panel (`eDP-1`); the v0.8.0 logic gave `KeyboardMode::Exclusive` to index 0, so the keyboard grab landed on an invisible surface. Symptom showed up on 2026-04-23 after kernel 6.19.11 → 6.19.12 + moongreet 0.8.0 → 0.8.2 changed evdi enumeration timing — previous Thursdays with the same dock worked.
- **Tradeoffs**: Prefers built-in displays by connector-name pattern (`eDP*`/`LVDS*`/`DSI*`) rather than a generic "primary monitor" concept — Wayland has no portable primary signal, and gdk4's `primary_monitor()` was removed. Pattern-matching covers every current Linux laptop, at the cost of a tiny list to maintain if a new form factor ships a new connector type. Fallback is still index 0, so behavior on desktops without a built-in panel is unchanged.
- **How**: New pure function `pick_primary_monitor_index()` in `main.rs` scans connector names and returns the built-in index (or 0). Used during initial enumeration to decide which window gets `KeyboardMode::Exclusive`. Hotplug branch unchanged — new monitors still get keyboard=false so focus never migrates off the panel. Unit-tested against evdi/eDP/LVDS/DSI/HDMI/DP mixes.
## 2026-04-21 Ship polkit rule in moongreet instead of moonarch (v0.8.3)

View File

@ -12,7 +12,7 @@ Part of the Moonarch ecosystem.
- **Last user/session** — Remembered in `/var/cache/moongreet/`
- **Power actions** — Reboot / Shutdown via `loginctl`
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
- **Multi-monitor + hotplug**Full greeter UI on all monitors (keyboard input on first), hotplugged monitors get windows automatically
- **Multi-monitor + hotplug**Login UI on the built-in display, wallpaper-only on other monitors; hotplugged monitors get wallpaper windows automatically
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
- **i18n** — German and English (auto-detected from system locale)
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3

View File

@ -8,3 +8,8 @@ background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme name — must match a directory in /usr/share/themes/
# Required because GTK4 under greetd does not reliably read settings.ini
gtk-theme = "Colloid-Grey-Dark-Catppuccin"
# Cursor theme name — must match a directory in /usr/share/icons/
# GTK4 under greetd does not honour XCURSOR_THEME, so set it here.
cursor-theme = "Sweet-cursors"
cursor-size = 24

View File

@ -25,6 +25,10 @@ struct Appearance {
background_blur: Option<f32>,
#[serde(rename = "gtk-theme")]
gtk_theme: Option<String>,
#[serde(rename = "cursor-theme")]
cursor_theme: Option<String>,
#[serde(rename = "cursor-size")]
cursor_size: Option<i32>,
#[serde(rename = "fingerprint-enabled")]
fingerprint_enabled: Option<bool>,
}
@ -35,6 +39,8 @@ pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
pub gtk_theme: Option<String>,
pub cursor_theme: Option<String>,
pub cursor_size: Option<i32>,
pub fingerprint_enabled: bool,
}
@ -44,6 +50,8 @@ impl Default for Config {
background_path: None,
background_blur: None,
gtk_theme: None,
cursor_theme: None,
cursor_size: None,
fingerprint_enabled: true,
}
}
@ -68,14 +76,8 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if bg_path.is_absolute() {
merged.background_path = Some(bg);
} else if let Some(parent) = path.parent() {
let joined = parent.join(&bg);
match joined.to_str() {
Some(s) => merged.background_path = Some(s.to_string()),
None => log::warn!(
"Ignoring non-UTF-8 background path: {}",
joined.display()
),
}
merged.background_path =
Some(parent.join(&bg).to_string_lossy().to_string());
}
}
if let Some(blur) = appearance.background_blur {
@ -88,6 +90,16 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if appearance.gtk_theme.is_some() {
merged.gtk_theme = appearance.gtk_theme;
}
if appearance.cursor_theme.is_some() {
merged.cursor_theme = appearance.cursor_theme;
}
if let Some(size) = appearance.cursor_size {
if (1..=256).contains(&size) {
merged.cursor_size = Some(size);
} else {
log::warn!("Ignoring cursor-size out of range (1256): {size}");
}
}
if let Some(fp) = appearance.fingerprint_enabled {
merged.fingerprint_enabled = fp;
}
@ -104,7 +116,15 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
}
}
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}, fingerprint={}", merged.background_path, merged.background_blur, merged.gtk_theme, merged.fingerprint_enabled);
log::debug!(
"Config result: background={:?}, blur={:?}, gtk_theme={:?}, cursor_theme={:?}, cursor_size={:?}, fingerprint={}",
merged.background_path,
merged.background_blur,
merged.gtk_theme,
merged.cursor_theme,
merged.cursor_size,
merged.fingerprint_enabled
);
merged
}
@ -129,14 +149,10 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path)
log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display());
}
// Moonarch ecosystem default — apply the same symlink rejection as the
// user-configured path for defense in depth. The fallback target is a
// system file, but the caller consumes the result via the same path.
if let Ok(meta) = moonarch_wallpaper.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() {
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
}
log::debug!("Wallpaper: no wallpaper found, using GTK background color");
@ -331,6 +347,40 @@ mod tests {
assert!(config.background_blur.is_none());
}
// -- Cursor theme tests --
#[test]
fn load_config_cursor_theme_and_size() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(
&conf,
"[appearance]\ncursor-theme = \"Sweet-cursors\"\ncursor-size = 32\n",
)
.unwrap();
let config = load_config(Some(&[conf]));
assert_eq!(config.cursor_theme.as_deref(), Some("Sweet-cursors"));
assert_eq!(config.cursor_size, Some(32));
}
#[test]
fn load_config_cursor_size_out_of_range_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\ncursor-size = 9999\n").unwrap();
let config = load_config(Some(&[conf]));
assert!(config.cursor_size.is_none());
}
#[test]
fn load_config_cursor_size_zero_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\ncursor-size = 0\n").unwrap();
let config = load_config(Some(&[conf]));
assert!(config.cursor_size.is_none());
}
#[test]
fn load_config_blur_inf_rejected() {
let dir = tempfile::tempdir().unwrap();

View File

@ -22,8 +22,8 @@ use crate::sessions::{self, Session};
use crate::users::{self, User};
const AVATAR_SIZE: i32 = 128;
const MAX_AVATAR_FILE_SIZE: u64 = 5 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 1024 * 1024;
const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user";
const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session";
const MAX_USERNAME_LENGTH: usize = 256;
@ -233,9 +233,6 @@ struct GreeterState {
user_switch_generation: u64,
/// Cached fprintd device proxy — initialized once on first use.
fingerprint_probe: Option<crate::fingerprint::FingerprintProbe>,
/// True while a probe init_async() is in flight. Prevents duplicate D-Bus
/// init when two user-switch probes race (both see probe == None).
fingerprint_probe_initializing: bool,
}
/// Create the main greeter window with login UI.
@ -262,6 +259,23 @@ pub fn create_greeter_window(
}
}
// Apply cursor theme from config — GTK4 under greetd does not read XCURSOR_THEME
// reliably, so set the gtk-cursor-theme-name property directly.
if let Some(ref cursor) = config.cursor_theme {
if is_valid_gtk_theme(cursor) {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_cursor_theme_name(Some(cursor));
}
} else {
log::warn!("Ignoring invalid cursor theme name: {cursor}");
}
}
if let Some(size) = config.cursor_size {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_cursor_theme_size(size);
}
}
let strings = load_strings(None);
let fingerprint_enabled = config.fingerprint_enabled;
let all_users = users::get_users(None);
@ -285,7 +299,6 @@ pub fn create_greeter_window(
fingerprint_available: false,
user_switch_generation: 0,
fingerprint_probe: None,
fingerprint_probe_initializing: false,
}));
// Root overlay for layering
@ -497,10 +510,6 @@ pub fn create_greeter_window(
let Some(user) = user else { return };
let password = Zeroizing::new(entry.text().to_string());
// Clear the GTK entry's internal buffer as early as possible. GTK allocates
// the backing `GString` via libc malloc, which `zeroize` cannot reach — the
// best we can do is shorten the window during which it resides in memory.
entry.set_text("");
let session = get_selected_session(&session_dropdown, &sessions_rc);
let Some(session) = session else {
@ -510,7 +519,7 @@ pub fn create_greeter_window(
attempt_login(
&user,
password,
&password,
&session,
strings,
&state,
@ -724,33 +733,12 @@ fn switch_to_user(
#[strong]
state,
async move {
// Initialize probe on first use, then reuse cached device proxy.
// Atomic check-and-set on fingerprint_probe_initializing prevents
// two concurrent probes (from a fast user switch) from both
// running init_async, which would open duplicate D-Bus connections.
let should_init = {
let mut s = state.borrow_mut();
if s.fingerprint_probe.is_some() || s.fingerprint_probe_initializing {
false
} else {
s.fingerprint_probe_initializing = true;
true
}
};
if should_init {
// Initialize probe on first use, then reuse cached device proxy
let needs_init = state.borrow().fingerprint_probe.is_none();
if needs_init {
let mut probe = crate::fingerprint::FingerprintProbe::new();
probe.init_async().await;
let mut s = state.borrow_mut();
s.fingerprint_probe = Some(probe);
s.fingerprint_probe_initializing = false;
} else {
// Another coroutine is initializing — yield until it publishes.
while state.borrow().fingerprint_probe.is_none()
&& state.borrow().fingerprint_probe_initializing
{
glib::timeout_future(std::time::Duration::from_millis(25)).await;
}
state.borrow_mut().fingerprint_probe = Some(probe);
}
// Take probe out of state to avoid holding borrow across await
@ -807,40 +795,28 @@ fn set_avatar_from_file(
Ok(_) => {}
}
// Show fallback immediately; decode asynchronously via GIO so the greeter
// stays responsive during a user-switch click.
image.set_icon_name(Some("avatar-default-symbolic"));
let Some(path_str) = path.to_str() else {
log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
return;
};
let display_path = path.to_path_buf();
let file = gio::File::for_path(path);
let image_clone = image.clone();
let state_clone = state.clone();
let username_owned = username.map(String::from);
glib::spawn_future_local(async move {
let stream = match file.read_future(glib::Priority::default()).await {
Ok(s) => s,
Err(e) => {
log::debug!("Failed to open avatar {}: {e}", display_path.display());
return;
}
};
match Pixbuf::from_stream_at_scale_future(&stream, AVATAR_SIZE, AVATAR_SIZE, true).await {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
if let Some(ref name) = username_owned {
state_clone
.borrow_mut()
.avatar_cache
.insert(name.clone(), texture.clone());
}
image_clone.set_paintable(Some(&texture));
}
Err(e) => {
log::debug!("Failed to decode avatar {}: {e}", display_path.display());
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
if let Some(name) = username {
state
.borrow_mut()
.avatar_cache
.insert(name.to_string(), texture.clone());
}
image.set_paintable(Some(&texture));
}
});
Err(e) => {
log::debug!("Failed to load avatar {}: {e}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
}
}
}
/// Load the default avatar SVG from GResources, tinted with the foreground color.
@ -994,7 +970,7 @@ fn set_login_sensitive(
#[allow(clippy::too_many_arguments)]
fn attempt_login(
user: &User,
password: Zeroizing<String>,
password: &str,
session: &Session,
strings: &'static Strings,
state: &Rc<RefCell<GreeterState>>,
@ -1033,6 +1009,7 @@ fn attempt_login(
set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone();
let password = Zeroizing::new(password.to_string());
let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone();
@ -1073,13 +1050,6 @@ fn attempt_login(
glib::timeout_future(min_response - elapsed).await;
}
// The login_worker's own socket is already dropped by now; drop the
// shared clone too so repeated failed attempts do not accumulate
// stale file descriptors in state.greetd_sock.
if let Ok(mut g) = state.borrow().greetd_sock.lock() {
g.take();
}
match result {
Ok(Ok(LoginResult::Success { username })) => {
save_last_user(&username);

View File

@ -286,10 +286,6 @@ mod tests {
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response");
assert!(!s.greetd_sock_not_absolute.is_empty(), "{locale}: greetd_sock_not_absolute");
assert!(!s.invalid_session_command.is_empty(), "{locale}: invalid_session_command");
assert!(!s.session_start_failed.is_empty(), "{locale}: session_start_failed");
assert!(!s.socket_error.is_empty(), "{locale}: socket_error");
}
}

View File

@ -11,11 +11,9 @@ mod sessions;
mod users;
use gdk4 as gdk;
use glib::clone;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell;
use std::rc::Rc;
fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
@ -26,20 +24,37 @@ fn load_css(display: &gdk::Display) {
);
}
fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) {
fn setup_layer_shell(window: &gtk::ApplicationWindow, layer: gtk4_layer_shell::Layer) {
window.init_layer_shell();
window.set_layer(layer);
window.set_exclusive_zone(-1);
if keyboard {
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
}
// Anchor to all edges for fullscreen
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::OnDemand);
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);
}
/// Pick the index of the built-in display among the given connector names.
///
/// Prefers `eDP*` / `LVDS*` / `DSI*` over anything else — otherwise keyboard
/// focus can land on DisplayLink/evdi phantom displays (connector `DVI-I-*`)
/// that are enumerated before the laptop panel. Falls back to index 0 when
/// no built-in connector is present.
fn pick_primary_monitor_index<'a, I>(connectors: I) -> usize
where
I: IntoIterator<Item = Option<&'a str>>,
{
for (i, conn) in connectors.into_iter().enumerate() {
if let Some(c) = conn
&& (c.starts_with("eDP") || c.starts_with("LVDS") || c.starts_with("DSI"))
{
return i;
}
}
0
}
fn activate(app: &gtk::Application) {
let display = match gdk::Display::default() {
Some(d) => d,
@ -66,51 +81,41 @@ fn activate(app: &gtk::Application) {
log::debug!("Layer shell: {use_layer_shell}");
if use_layer_shell {
// One greeter window per monitor — only the first gets keyboard input
// Single greeter window anchored to the built-in display. Other
// outputs stay under compositor control — the greeter is just a
// normal layer-shell client, no per-output keyboard grabs.
let monitors = display.monitors();
log::debug!("Monitor count: {}", monitors.n_items());
let mut first = true;
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
setup_layer_shell(&window, first, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
first = false;
}
}
let count = monitors.n_items();
log::debug!("Monitor count: {count}");
// Handle monitor hotplug — create greeter windows for newly added monitors
// (without keyboard, since the primary monitor already has it)
let bg_texture = Rc::new(bg_texture);
let config = Rc::new(config);
monitors.connect_items_changed(clone!(
#[weak]
app,
#[strong]
blur_cache,
move |list, position, _removed, added| {
for i in position..position + added {
if let Some(monitor) = list
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
log::debug!("Monitor hotplug: creating greeter window");
let window = greeter::create_greeter_window(
bg_texture.as_ref().as_ref(), &config, &blur_cache, &app,
);
setup_layer_shell(&window, false, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
}
}
}
));
let connectors: Vec<Option<String>> = (0..count)
.map(|i| {
monitors
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
.and_then(|m| m.connector())
.map(|gs| gs.to_string())
})
.collect();
let primary_idx = pick_primary_monitor_index(connectors.iter().map(|o| o.as_deref()));
log::debug!(
"Primary monitor: idx={primary_idx} connector={:?}",
connectors.get(primary_idx).and_then(|o| o.as_deref())
);
if let Some(monitor) = monitors
.item(primary_idx as u32)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
} else {
log::error!("Primary monitor {primary_idx} not available — greeter will not be shown");
}
} else {
// No layer shell — single window for development
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
greeter_window.present();
}
@ -127,16 +132,55 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}");
}
}
// Require MOONGREET_DEBUG=1 to raise verbosity. Mere presence (e.g. an
// empty value in a session-setup script) must not escalate the journal
// to Debug, which leaks socket paths, usernames, and auth round counts.
let level = match std::env::var("MOONGREET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_max_level(level);
}
#[cfg(test)]
mod tests {
use super::pick_primary_monitor_index;
#[test]
fn prefers_edp_over_phantom_dvi() {
assert_eq!(
pick_primary_monitor_index([Some("DVI-I-1"), Some("eDP-1"), Some("DVI-I-2")]),
1,
);
}
#[test]
fn prefers_lvds() {
assert_eq!(pick_primary_monitor_index([Some("HDMI-A-1"), Some("LVDS-1")]), 1);
}
#[test]
fn prefers_dsi() {
assert_eq!(pick_primary_monitor_index([Some("DP-1"), Some("DSI-1")]), 1);
}
#[test]
fn falls_back_to_first_without_builtin() {
assert_eq!(
pick_primary_monitor_index([Some("DVI-I-1"), Some("HDMI-A-1")]),
0,
);
}
#[test]
fn skips_missing_connector() {
assert_eq!(pick_primary_monitor_index([None, Some("eDP-1")]), 1);
}
#[test]
fn empty_returns_zero() {
assert_eq!(pick_primary_monitor_index(std::iter::empty::<Option<&str>>()), 0);
}
}
fn main() {
setup_logging();
log::info!("Moongreet starting");

View File

@ -40,9 +40,7 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program)
.args(args)
// stdout is never read; piping without draining would deadlock on any
// command that writes more than one OS pipe buffer before wait() returns.
.stdout(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {

View File

@ -23,8 +23,6 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec_cmd: Option<String> = None;
let mut hidden = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
@ -46,18 +44,9 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
&& exec_cmd.is_none()
{
exec_cmd = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("Hidden=") {
hidden = value.eq_ignore_ascii_case("true");
} else if let Some(value) = line.strip_prefix("NoDisplay=") {
no_display = value.eq_ignore_ascii_case("true");
}
}
if hidden || no_display {
log::debug!("Skipping {}: Hidden/NoDisplay entry", path.display());
return None;
}
let name = name.filter(|s| !s.is_empty());
let exec_cmd = exec_cmd.filter(|s| !s.is_empty());