Compare commits
4 Commits
b9b6f50974
...
ce9f2196ca
| Author | SHA1 | Date | |
|---|---|---|---|
| ce9f2196ca | |||
| 29ce185886 | |||
| 91b4289748 | |||
| 97165d94f8 |
@ -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 0–200)
|
||||
- `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 0–200) + Cursor-Size-Validierung (range 1–256)
|
||||
- `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.0–v0.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
2
Cargo.lock
generated
@ -575,7 +575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moongreet"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"gdk-pixbuf",
|
||||
"gdk4",
|
||||
|
||||
@ -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"
|
||||
|
||||
31
DECISIONS.md
31
DECISIONS.md
@ -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 1–256). `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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (1–256): {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();
|
||||
|
||||
120
src/greeter.rs
120
src/greeter.rs
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
154
src/main.rs
154
src/main.rs
@ -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: >k::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) {
|
||||
fn setup_layer_shell(window: >k::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: >k::Application) {
|
||||
let display = match gdk::Display::default() {
|
||||
Some(d) => d,
|
||||
@ -66,51 +81,41 @@ fn activate(app: >k::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");
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user