Compare commits

..

No commits in common. "main" and "v0.6.1" have entirely different histories.
main ... v0.6.1

19 changed files with 229 additions and 739 deletions

View File

@ -1,43 +0,0 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
# ABOUTME: Ensures paru detects new versions of this package.
name: Update PKGBUILD version
on:
push:
branches:
- main
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Checkout source repo
run: |
git clone --bare http://gitea:3000/nevaforget/greetd-moongreet.git source.git
cd source.git
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
- name: Update PKGBUILD
run: |
PKGVER=$(cat /tmp/pkgver)
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' moongreet-git/PKGBUILD | cut -d= -f2)
if [ "$OLD_VER" = "$PKGVER" ]; then
echo "pkgver already up to date ($PKGVER)"
exit 0
fi
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moongreet-git/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moongreet-git/.SRCINFO
echo "Updated pkgver: $OLD_VER → $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add moongreet-git/PKGBUILD moongreet-git/.SRCINFO
git commit -m "chore(moongreet-git): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push

View File

@ -1,5 +1,7 @@
# Moongreet # Moongreet
**Name**: Selene (Mondgöttin — passend zu Moon-greet)
## Projekt ## Projekt
Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell. Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
@ -43,9 +45,9 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- `power.rs` — Reboot/Shutdown via loginctl - `power.rs` — Reboot/Shutdown via loginctl
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen - `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 - `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) - `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback
- `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) - `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 (0o600 Permissions)
- `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 - `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-journal-logger
- `resources/style.css` — Catppuccin-inspiriertes Theme - `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions ## Design Decisions
@ -55,13 +57,13 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads - **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche - **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
- **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>` - **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
- **GPU-Blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` im `connect_realize` Callback — kein CPU-Blur, kein Disk-Cache, kein `image`-Crate. Blurred Texture wird per `Rc<RefCell<Option<gdk::Texture>>>` über alle Monitore gecacht (1x GPU-Renderpass statt N). - **GPU-Blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` im `connect_realize` Callback — kein CPU-Blur, kein Disk-Cache, kein `image`-Crate
- **Fingerprint via greetd Multi-Stage PAM**: fprintd D-Bus nur als Probe (Gerät/Enrollment), eigentliche Verifizierung läuft über PAM im greetd-Auth-Loop. `auth_message_type: "secret"` → Passwort, alles andere → `None` (PAM entscheidet). 60s Socket-Timeout bei fprintd. Device-Proxy in `GreeterState` gecacht, Generation-Counter gegen Race Conditions bei schnellem User-Switch. - **Fingerprint via greetd Multi-Stage PAM**: fprintd D-Bus nur als Probe (Gerät/Enrollment), eigentliche Verifizierung läuft über PAM im greetd-Auth-Loop. `auth_message_type: "secret"` → Passwort, alles andere → `None` (PAM entscheidet). 60s Socket-Timeout bei fprintd.
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur) - **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 - **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 - **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var - **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 - **File Permissions**: Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests - **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 - **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` - **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`

28
Cargo.lock generated
View File

@ -59,12 +59,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -575,7 +569,7 @@ dependencies = [
[[package]] [[package]]
name = "moongreet" name = "moongreet"
version = "0.8.6" version = "0.6.1"
dependencies = [ dependencies = [
"gdk-pixbuf", "gdk-pixbuf",
"gdk4", "gdk4",
@ -586,25 +580,11 @@ dependencies = [
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"log", "log",
"nix",
"serde", "serde",
"serde_json", "serde_json",
"systemd-journal-logger", "systemd-journal-logger",
"tempfile", "tempfile",
"toml 0.8.23", "toml 0.8.23",
"zeroize",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
] ]
[[package]] [[package]]
@ -1144,12 +1124,6 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "moongreet" name = "moongreet"
version = "0.8.6" version = "0.6.1"
edition = "2024" edition = "2024"
description = "A greetd greeter for Wayland with GTK4 and Layer Shell" description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
license = "MIT" license = "MIT"
@ -16,18 +16,11 @@ toml = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
graphene-rs = { version = "0.22", package = "graphene-rs" } graphene-rs = { version = "0.22", package = "graphene-rs" }
nix = { version = "0.29", features = ["signal"] }
zeroize = { version = "1", features = ["std"] }
log = "0.4" log = "0.4"
systemd-journal-logger = "2.2" systemd-journal-logger = "2.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"

View File

@ -1,105 +1,35 @@
# Decisions # Decisions
## 2026-04-24 Audit LOW fixes: stdout null, utf-8 path, debug value, hidden sessions (v0.8.6)
- **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`.
## 2026-04-24 Audit MEDIUM fixes: FP double-init, async avatar, symlink, FD leak (v0.8.5)
- **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.
## 2026-04-24 Audit fix: shrink password-in-memory window (v0.8.4)
- **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.
## 2026-04-21 Ship polkit rule in moongreet instead of moonarch (v0.8.3)
- **Who**: ClaudeCode, Dom
- **Why**: Reboot/shutdown from the greeter silently failed on a fresh install. The polkit rule that grants the `greeter` user `org.freedesktop.login1.{reboot,power-off}` lived in the moonarch repo but was never installed by any PKGBUILD. The laptop worked only because the rule had been hand-deployed once.
- **Tradeoffs**: Rule ownership moves from moonarch (system defaults) to moongreet (greeter-specific auth). Cleaner boundary — moonarch no longer needs to know about the greeter's auth requirements — but it means moongreet is now responsible for a system polkit rule that ties it to a fixed username (`greeter`).
- **How**: Source file moved to `moongreet/config/polkit/50-moongreet-power.rules`, installed to `/etc/polkit-1/rules.d/` by `moongreet-git/PKGBUILD`. Old file removed from the moonarch repo.
## 2026-04-09 Monitor hotplug via ListModel items-changed
- **Who**: ClaudeCode, Dom
- **Why**: Greeter windows were only created at startup. If a monitor was hotplugged (e.g. HDMI reconnect), it would show no greeter UI. Aligned with moonlock's hotplug fix (same day).
- **Tradeoffs**: Hotplugged monitors get greeter windows without keyboard input (keyboard stays on the primary monitor). Acceptable — user can still interact on the primary screen.
- **How**: Connect to `display.monitors().connect_items_changed()` and create new greeter windows for added monitors. Shared state (config, texture, blur_cache) moved to Rc for the closure.
## 2026-04-08 Show greeter UI on all monitors instead of just one
- **Who**: ClaudeCode, Dom
- **Why**: moonlock showed its UI on all monitors via ext-session-lock-v1, but moongreet only showed the login UI on one monitor (compositor-picked) with wallpaper-only windows on the rest. Inconsistent UX across the ecosystem.
- **Tradeoffs**: Each monitor gets its own full greeter widget tree (slightly more memory), but the UI is lightweight. Screen mirroring (e.g., wl-mirror/screencopy) was considered and rejected — it requires an external process, compositor screencopy support, adds latency, and fights Wayland's per-output model. One-window-per-monitor is the established Wayland pattern (swaylock, hyprlock, moonlock all do this).
- **How**: Create one `create_greeter_window()` per monitor with `set_monitor()`, only the first gets `KeyboardMode::Exclusive`. Removed `create_wallpaper_window()` (no longer needed). No layer shell fallback keeps single-window mode for development.
## 2026-04-06 Restore explicit gtk-theme in moongreet config
- **Who**: ClaudeCode, Dom
- **Why**: GTK4 under greetd does not reliably read `/etc/xdg/gtk-4.0/settings.ini` — likely requires a settings daemon that doesn't run in the greeter session. moongreet fell back to Adwaita/Colloid-default (blue accent) instead of Colloid-Grey-Dark-Catppuccin.
- **Tradeoffs**: Reverts `094878f` ("Remove gtk-theme from app config, use system-wide GTK settings instead"). Duplicates the theme name between settings.ini and moongreet.toml, but the explicit set via `set_gtk_theme_name()` is the only reliable path in a greetd context.
- **How**: Added `gtk-theme = "Colloid-Grey-Dark-Catppuccin"` to example config and deployed `/etc/moongreet/moongreet.toml`.
## 2026-04-02 Replace hardcoded CSS colors with GTK theme variables
- **Who**: ClaudeCode, Dom
- **Why**: moongreet used hardcoded colors (#1a1a2e, white, #ff6b6b) while moonset already used @theme_bg_color, @theme_fg_color, @error_color etc. Inconsistent across the ecosystem and broke theme flexibility.
- **Tradeoffs**: Depends on the active GTK theme defining standard color variables. Catppuccin Colloid provides all needed vars (@theme_bg_color, @theme_fg_color, @error_color, @success_color, @theme_selected_bg_color). Fallback behavior if a theme lacks vars is GTK's default colors — acceptable.
- **How**: Replaced all hardcoded hex/named colors with GTK theme variables. Coordinated change across moongreet, moonlock, and moonset (all three now use identical pattern).
## 2026-03-31 Fourth audit: power timeout, timing mitigation, release profile, GREETD_SOCK caching
- **Who**: ClaudeCode, Dom
- **Why**: Fourth triple audit found moongreet power.rs had no timeout on loginctl (greeter could freeze), username enumeration via timing differential, GREETD_SOCK re-read on every login, missing release profile, and missing GResource compression.
- **Tradeoffs**: 500ms minimum login response time adds slight delay on fast auth but prevents timing-based username enumeration. Power timeout (30s + SIGKILL) matches moonset pattern — aggressive but prevents greeter freeze.
- **How**: (1) power.rs adapted from moonset with 30s timeout + SIGKILL (nix dependency added). (2) 500ms min response floor in attempt_login via Instant + glib::timeout_future. (3) GREETD_SOCK cached in GreeterState at startup. (4) `[profile.release]` with LTO, codegen-units=1, strip. (5) `compressed="true"` on GResource entries. (6) SYNC comments on duplicated blur/background functions.
## 2026-03-30 Full audit fix: security, quality, performance (v0.6.2)
- **Who**: ClaudeCode, Dom
- **Why**: Three parallel audits (security, code quality, performance) identified 10 actionable findings across the codebase — from world-readable cache dirs to a GPU blur geometry bug to a race condition in fingerprint probing.
- **Tradeoffs**: `too_many_arguments` Clippy warnings suppressed with `#[allow]` rather than introducing a `UiWidgets` struct — GTK's `clone!` macro with `#[weak]` refs requires individual widget parameters, a struct would fight the idiom. Async avatar loading skipped because `Pixbuf` is `!Send`; cache already prevents repeat loads. TOCTOU socket pre-check removed entirely — `connect()` in login_worker already handles errors, the `metadata()` check gave false security guarantees.
- **How**: Cache dirs use `DirBuilder::mode(0o700)` instead of `create_dir_all`. Blur config clamped to `0.0..=200.0` with `is_finite()` guard. Blur texture cached in `Rc<RefCell<Option<gdk::Texture>>>` across monitors. FingerprintProbe device proxy cached in `GreeterState` with generation counter to prevent stale async writes. GPU blur geometry fixed (`-pad` origin shift instead of texture stretching). `is_valid_gtk_theme` extracted as testable function. 9 new tests.
## 2026-03-29 Fingerprint authentication via greetd multi-stage PAM ## 2026-03-29 Fingerprint authentication via greetd multi-stage PAM
- **Who**: ClaudeCode, Dom - **Who**: Ragnar, Dom
- **Why**: moonlock supports fprintd but moongreet rejected multi-stage auth. Users with enrolled fingerprints couldn't use them at the login screen. - **Why**: moonlock supports fprintd but moongreet rejected multi-stage auth. Users with enrolled fingerprints couldn't use them at the login screen.
- **Tradeoffs**: Direct fprintd D-Bus verification (like moonlock) can't start a greetd session — greetd controls session creation via PAM. Using greetd multi-stage means PAM decides the auth order (fingerprint first, then password fallback), not truly parallel. Acceptable — matches standard pam_fprintd behavior. - **Tradeoffs**: Direct fprintd D-Bus verification (like moonlock) can't start a greetd session — greetd controls session creation via PAM. Using greetd multi-stage means PAM decides the auth order (fingerprint first, then password fallback), not truly parallel. Acceptable — matches standard pam_fprintd behavior.
- **How**: Replace single-pass auth with a loop over auth_message rounds. Secret prompts get the password, non-secret prompts (fprintd) get None and block until PAM resolves. fprintd D-Bus probe (gio::DBusProxy) only for UI — detecting device availability and enrolled fingers. 60s socket timeout when fingerprint available. Config option `fingerprint-enabled` (default true). - **How**: Replace single-pass auth with a loop over auth_message rounds. Secret prompts get the password, non-secret prompts (fprintd) get None and block until PAM resolves. fprintd D-Bus probe (gio::DBusProxy) only for UI — detecting device availability and enrolled fingers. 60s socket timeout when fingerprint available. Config option `fingerprint-enabled` (default true).
## 2026-03-28 Remove embedded wallpaper from binary ## 2026-03-28 Remove embedded wallpaper from binary
- **Who**: ClaudeCode, Dom - **Who**: Selene, Dom
- **Why**: Wallpaper is installed by moonarch to /usr/share/moonarch/wallpaper.jpg. Embedding a 374K JPEG in the binary is redundant. GTK background color (Catppuccin Mocha base) is a clean fallback. - **Why**: Wallpaper is installed by moonarch to /usr/share/moonarch/wallpaper.jpg. Embedding a 374K JPEG in the binary is redundant. GTK background color (Catppuccin Mocha base) is a clean fallback.
- **Tradeoffs**: Without moonarch installed AND without config, greeter shows plain dark background instead of wallpaper. Acceptable — that's the expected minimal state. - **Tradeoffs**: Without moonarch installed AND without config, greeter shows plain dark background instead of wallpaper. Acceptable — that's the expected minimal state.
- **How**: Remove wallpaper.jpg from GResources, return None from resolve_background_path when no file found, skip wallpaper window creation and background picture when no path available. - **How**: Remove wallpaper.jpg from GResources, return None from resolve_background_path when no file found, skip wallpaper window creation and background picture when no path available.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur ## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: ClaudeCode, Dom - **Who**: Ragnar, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked the GTK main thread for 500ms2s on 4K wallpapers at cold cache. Disk cache and async orchestration added significant complexity. - **Why**: CPU-side Gaussian blur (`image` crate) blocked the GTK main thread for 500ms2s on 4K wallpapers at cold cache. Disk cache and async orchestration added significant complexity.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely (~15 transitive crates eliminated). No disk cache needed. - **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely (~15 transitive crates eliminated). No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer, producing a concrete `gdk::Texture`. Zero startup latency. Symmetric with moonlock and moonset. - **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer, producing a concrete `gdk::Texture`. Zero startup latency. Symmetric with moonlock and moonset.
## 2026-03-28 Optional background blur via `image` crate (superseded) ## 2026-03-28 Optional background blur via `image` crate (superseded)
- **Who**: ClaudeCode, Dom - **Who**: Selene, Dom
- **Why**: Blurred wallpaper as greeter background is a common UX pattern for login screens - **Why**: Blurred wallpaper as greeter background is a common UX pattern for login screens
- **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors. - **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors.
- **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns blurred `gdk::Texture`. Config option `background-blur: Option<f32>` in `[appearance]` TOML section. - **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns blurred `gdk::Texture`. Config option `background-blur: Option<f32>` in `[appearance]` TOML section.
## 2026-03-28 Audit fixes for shared wallpaper texture (v0.4.1) ## 2026-03-28 Audit fixes for shared wallpaper texture (v0.4.1)
- **Who**: ClaudeCode, Dominik - **Who**: Selene, Dominik
- **Why**: Quality, performance, and security audits flagged issues in `load_background_texture()`, debug logging, and greetd error handling - **Why**: Quality, performance, and security audits flagged issues in `load_background_texture()`, debug logging, and greetd error handling
- **Tradeoffs**: GResource path now requires UTF-8 (returns `None` for non-UTF-8 instead of aborting); 50 MB wallpaper limit is generous but prevents OOM; debug logging off by default trades observability for security - **Tradeoffs**: GResource path now requires UTF-8 (returns `None` for non-UTF-8 instead of aborting); 50 MB wallpaper limit is generous but prevents OOM; debug logging off by default trades observability for security
- **How**: GResource branch via `resources_lookup_data()` + `from_bytes()` (no abort), file size limit, error details only at debug level, `MOONGREET_DEBUG` env var for log level, greetd retry path truncation matching `show_greetd_error()` - **How**: GResource branch via `resources_lookup_data()` + `from_bytes()` (no abort), file size limit, error details only at debug level, `MOONGREET_DEBUG` env var for log level, greetd retry path truncation matching `show_greetd_error()`

View File

@ -12,13 +12,10 @@ Part of the Moonarch ecosystem.
- **Last user/session** — Remembered in `/var/cache/moongreet/` - **Last user/session** — Remembered in `/var/cache/moongreet/`
- **Power actions** — Reboot / Shutdown via `loginctl` - **Power actions** — Reboot / Shutdown via `loginctl`
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer) - **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** — Greeter on primary, wallpaper on all monitors
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
- **i18n** — German and English (auto-detected from system locale) - **i18n** — German and English (auto-detected from system locale)
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3 - **Faillock warning** — Warns after 2 failed attempts, locked message after 3
- **Fingerprint** — fprintd support via greetd multi-stage PAM (configurable) - **Fingerprint** — fprintd support via greetd multi-stage PAM (configurable)
- **Journal logging**`journalctl -t moongreet`, debug level via `MOONGREET_DEBUG` env var
- **Password wiping** — Zeroize on drop
## Requirements ## Requirements
@ -60,14 +57,6 @@ sudo cp config/moongreet.toml /etc/moongreet/moongreet.toml
user = "greeter" user = "greeter"
``` ```
4. Install the polkit rule so the greeter user can reboot / power off:
```bash
sudo install -Dm644 config/polkit/50-moongreet-power.rules \
/etc/polkit-1/rules.d/50-moongreet-power.rules
```
Without this rule, `loginctl reboot` / `loginctl poweroff` fail because
greetd's greeter session is inactive in logind.
## Development ## Development
```bash ```bash
@ -77,8 +66,8 @@ cargo test
# Build release # Build release
cargo build --release cargo build --release
# Run locally (without greetd, disables layer-shell) # Run locally (without greetd, needs LD_PRELOAD for layer-shell)
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
``` ```
## License ## License

View File

@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle. // ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css and default-avatar.svg into the binary. // ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary.
fn main() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(

View File

@ -4,7 +4,5 @@
[appearance] [appearance]
# Absolute path to wallpaper image # Absolute path to wallpaper image
background = "/usr/share/backgrounds/wallpaper.jpg" background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme for the greeter UI
# GTK theme name — must match a directory in /usr/share/themes/ gtk-theme = "Colloid-Catppuccin"
# Required because GTK4 under greetd does not reliably read settings.ini
gtk-theme = "Colloid-Grey-Dark-Catppuccin"

View File

@ -1,12 +0,0 @@
// ABOUTME: Allow the greeter user to reboot and power off without authentication.
// ABOUTME: Required because greetd's greeter session is inactive in logind.
polkit.addRule(function(action, subject) {
if (subject.user === "greeter" &&
(action.id === "org.freedesktop.login1.reboot" ||
action.id === "org.freedesktop.login1.reboot-multiple-sessions" ||
action.id === "org.freedesktop.login1.power-off" ||
action.id === "org.freedesktop.login1.power-off-multiple-sessions")) {
return polkit.Result.YES;
}
});

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/dev/moonarch/moongreet"> <gresource prefix="/dev/moonarch/moongreet">
<file compressed="true">style.css</file> <file>style.css</file>
<file compressed="true">default-avatar.svg</file> <file>default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@ -1,16 +1,16 @@
/* ABOUTME: GTK4 CSS stylesheet for the Moongreet greeter. */ /* ABOUTME: GTK4 CSS stylesheet for the Moongreet greeter. */
/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */ /* ABOUTME: Defines styling for the login screen layout. */
/* Main window background */ /* Main window background */
window.greeter { window.greeter {
background-color: @theme_bg_color; background-color: #1a1a2e;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
} }
/* Wallpaper-only window for secondary monitors */ /* Wallpaper-only window for secondary monitors */
window.wallpaper { window.wallpaper {
background-color: @theme_bg_color; background-color: #1a1a2e;
} }
/* Central login area */ /* Central login area */
@ -26,14 +26,14 @@ window.wallpaper {
min-width: 128px; min-width: 128px;
min-height: 128px; min-height: 128px;
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
border: 3px solid alpha(@theme_fg_color, 0.3); border: 3px solid alpha(white, 0.3);
} }
/* Username label */ /* Username label */
.username-label { .username-label {
font-size: 24px; font-size: 24px;
font-weight: bold; font-weight: bold;
color: @theme_fg_color; color: white;
margin-top: 12px; margin-top: 12px;
margin-bottom: 40px; margin-bottom: 40px;
} }
@ -50,13 +50,13 @@ window.wallpaper {
/* Error message label */ /* Error message label */
.error-label { .error-label {
color: @error_color; color: #ff6b6b;
font-size: 14px; font-size: 14px;
} }
/* Fingerprint prompt label */ /* Fingerprint prompt label */
.fingerprint-label { .fingerprint-label {
color: alpha(@theme_fg_color, 0.6); color: alpha(white, 0.6);
font-size: 13px; font-size: 13px;
margin-top: 8px; margin-top: 8px;
} }
@ -70,16 +70,16 @@ window.wallpaper {
.user-list-item { .user-list-item {
padding: 8px 16px; padding: 8px 16px;
border-radius: 8px; border-radius: 8px;
color: @theme_fg_color; color: white;
font-size: 14px; font-size: 14px;
} }
.user-list-item:hover { .user-list-item:hover {
background-color: alpha(@theme_fg_color, 0.15); background-color: alpha(white, 0.15);
} }
.user-list-item:selected { .user-list-item:selected {
background-color: alpha(@theme_fg_color, 0.2); background-color: alpha(white, 0.2);
} }
/* Power buttons on the bottom right */ /* Power buttons on the bottom right */
@ -88,12 +88,12 @@ window.wallpaper {
min-height: 48px; min-height: 48px;
padding: 0px; padding: 0px;
border-radius: 24px; border-radius: 24px;
background-color: alpha(@theme_fg_color, 0.1); background-color: alpha(white, 0.1);
color: @theme_fg_color; color: white;
border: none; border: none;
margin: 4px; margin: 4px;
} }
.power-button:hover { .power-button:hover {
background-color: alpha(@theme_fg_color, 0.25); background-color: alpha(white, 0.25);
} }

View File

@ -68,22 +68,12 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if bg_path.is_absolute() { if bg_path.is_absolute() {
merged.background_path = Some(bg); merged.background_path = Some(bg);
} else if let Some(parent) = path.parent() { } else if let Some(parent) = path.parent() {
let joined = parent.join(&bg); merged.background_path =
match joined.to_str() { Some(parent.join(&bg).to_string_lossy().to_string());
Some(s) => merged.background_path = Some(s.to_string()),
None => log::warn!(
"Ignoring non-UTF-8 background path: {}",
joined.display()
),
}
} }
} }
if let Some(blur) = appearance.background_blur { if appearance.background_blur.is_some() {
if blur.is_finite() { merged.background_blur = appearance.background_blur;
merged.background_blur = Some(blur.clamp(0.0, 200.0));
} else {
log::warn!("Ignoring non-finite background-blur value");
}
} }
if appearance.gtk_theme.is_some() { if appearance.gtk_theme.is_some() {
merged.gtk_theme = appearance.gtk_theme; merged.gtk_theme = appearance.gtk_theme;
@ -117,26 +107,20 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
/// Resolve with configurable moonarch wallpaper path (for testing). /// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> { pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path — reject symlinks to prevent path traversal // User-configured path
if let Some(ref bg) = config.background_path { if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg); let path = PathBuf::from(bg);
if let Ok(meta) = path.symlink_metadata() { if path.is_file() {
if meta.is_file() && !meta.file_type().is_symlink() { log::debug!("Wallpaper: using config path {}", path.display());
log::debug!("Wallpaper: using config path {}", path.display()); return Some(path);
return Some(path);
}
} }
log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display()); log::debug!("Wallpaper: config path {} not found, trying fallbacks", path.display());
} }
// Moonarch ecosystem default — apply the same symlink rejection as the // Moonarch ecosystem default
// user-configured path for defense in depth. The fallback target is a if moonarch_wallpaper.is_file() {
// system file, but the caller consumes the result via the same path. log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
if let Ok(meta) = moonarch_wallpaper.symlink_metadata() { return Some(moonarch_wallpaper.to_path_buf());
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());
}
} }
log::debug!("Wallpaper: no wallpaper found, using GTK background color"); log::debug!("Wallpaper: no wallpaper found, using GTK background color");
@ -299,45 +283,4 @@ mod tests {
let config = load_config(Some(&paths)); let config = load_config(Some(&paths));
assert!(!config.fingerprint_enabled); assert!(!config.fingerprint_enabled);
} }
// -- Blur validation tests --
#[test]
fn load_config_blur_clamped_to_max() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\nbackground-blur = 999.0\n").unwrap();
let config = load_config(Some(&[conf]));
assert_eq!(config.background_blur, Some(200.0));
}
#[test]
fn load_config_blur_negative_clamped_to_zero() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\nbackground-blur = -5.0\n").unwrap();
let config = load_config(Some(&[conf]));
assert_eq!(config.background_blur, Some(0.0));
}
#[test]
fn load_config_blur_nan_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
// TOML doesn't support NaN literals, but the parser may return NaN for nan
fs::write(&conf, "[appearance]\nbackground-blur = nan\n").unwrap();
let config = load_config(Some(&[conf]));
// nan is not valid TOML float, so the whole config parse fails → no blur
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_blur_inf_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\nbackground-blur = inf\n").unwrap();
let config = load_config(Some(&[conf]));
// inf is valid TOML → parsed as f32::INFINITY → rejected by is_finite() guard
assert!(config.background_blur.is_none());
}
} }

View File

@ -10,7 +10,6 @@ const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device"; const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
const DBUS_TIMEOUT_MS: i32 = 3000; const DBUS_TIMEOUT_MS: i32 = 3000;
const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/";
/// Lightweight fprintd probe — detects device availability and finger enrollment. /// Lightweight fprintd probe — detects device availability and finger enrollment.
/// Does NOT perform verification (that happens through greetd/PAM). /// Does NOT perform verification (that happens through greetd/PAM).
@ -67,10 +66,6 @@ impl FingerprintProbe {
if device_path.is_empty() { if device_path.is_empty() {
return; return;
} }
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
log::warn!("Unexpected fprintd device path: {device_path}");
return;
}
match gio::DBusProxy::for_bus_future( match gio::DBusProxy::for_bus_future(
gio::BusType::System, gio::BusType::System,

View File

@ -12,7 +12,6 @@ use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
use crate::config::Config; use crate::config::Config;
use crate::i18n::{faillock_warning, load_strings, Strings}; use crate::i18n::{faillock_warning, load_strings, Strings};
@ -22,8 +21,8 @@ use crate::sessions::{self, Session};
use crate::users::{self, User}; use crate::users::{self, User};
const AVATAR_SIZE: i32 = 128; const AVATAR_SIZE: i32 = 128;
const MAX_AVATAR_FILE_SIZE: u64 = 5 * 1024 * 1024; const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_WALLPAPER_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_USER_PATH: &str = "/var/cache/moongreet/last-user";
const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session"; const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session";
const MAX_USERNAME_LENGTH: usize = 256; const MAX_USERNAME_LENGTH: usize = 256;
@ -88,9 +87,7 @@ fn is_valid_username(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_USERNAME_LENGTH { if name.is_empty() || name.len() > MAX_USERNAME_LENGTH {
return false; return false;
} }
let Some(first) = name.chars().next() else { let first = name.chars().next().unwrap();
return false;
};
if !first.is_ascii_alphanumeric() && first != '_' { if !first.is_ascii_alphanumeric() && first != '_' {
return false; return false;
} }
@ -98,28 +95,16 @@ fn is_valid_username(name: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '@') .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '@')
} }
/// Validate a GTK theme name — alphanumeric plus `_-+.` only.
fn is_valid_gtk_theme(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
}
/// Load background texture from filesystem. /// Load background texture from filesystem.
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> { pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
if let Ok(meta) = std::fs::symlink_metadata(bg_path) { if let Ok(meta) = std::fs::metadata(bg_path)
if meta.file_type().is_symlink() { && meta.len() > MAX_WALLPAPER_FILE_SIZE
log::warn!("Rejecting symlink wallpaper: {}", bg_path.display()); {
return None; log::warn!(
} "Wallpaper file too large ({} bytes), skipping: {}",
if meta.len() > MAX_WALLPAPER_FILE_SIZE { meta.len(), bg_path.display()
log::warn!( );
"Wallpaper file too large ({} bytes), skipping: {}", return None;
meta.len(), bg_path.display()
);
return None;
}
} }
match gdk::Texture::from_filename(bg_path) { match gdk::Texture::from_filename(bg_path) {
Ok(texture) => Some(texture), Ok(texture) => Some(texture),
@ -133,22 +118,11 @@ pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
// -- GPU blur via GskBlurNode ------------------------------------------------- // -- GPU blur via GskBlurNode -------------------------------------------------
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
// are duplicated in moonlock/src/lockscreen.rs and moonset/src/panel.rs.
// Changes here must be mirrored to the other two projects.
/// Maximum texture dimension before downscaling for blur.
/// Keeps GPU work reasonable on 4K+ displays.
const MAX_BLUR_DIMENSION: f32 = 1920.0;
/// Render a blurred texture using the GPU via GskBlurNode. /// Render a blurred texture using the GPU via GskBlurNode.
/// ///
/// To avoid edge darkening (blur samples transparent pixels outside bounds), /// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// the texture is rendered with padding equal to 3x the blur sigma. The blur /// the texture is rendered with padding equal to 3x the blur sigma. The blur
/// is applied to the padded area, then cropped back to the original size. /// is applied to the padded area, then cropped back to the original size.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture( fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>, widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture, texture: &gdk::Texture,
@ -157,29 +131,17 @@ fn render_blurred_texture(
let native = widget.native()?; let native = widget.native()?;
let renderer = native.renderer()?; let renderer = native.renderer()?;
let orig_w = texture.width() as f32; let w = texture.width() as f32;
let orig_h = texture.height() as f32; let h = texture.height() as f32;
// Downscale large textures to reduce GPU blur work
let max_dim = orig_w.max(orig_h);
let scale = if max_dim > MAX_BLUR_DIMENSION {
MAX_BLUR_DIMENSION / max_dim
} else {
1.0
};
let w = (orig_w * scale).round();
let h = (orig_h * scale).round();
let scaled_sigma = sigma * scale;
// Padding must cover the blur kernel radius (typically ~3x sigma) // Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (scaled_sigma * 3.0).ceil(); let pad = (sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new(); let snapshot = gtk::Snapshot::new();
// Clip output to scaled texture size // Clip output to original texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h)); snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(scaled_sigma as f64); snapshot.push_blur(sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds) // Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad)); snapshot.append_texture(texture, &graphene_rs::Rect::new(0.0, 0.0, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur snapshot.pop(); // blur
snapshot.pop(); // clip snapshot.pop(); // clip
@ -188,13 +150,25 @@ fn render_blurred_texture(
Some(renderer.render_texture(&node, Some(&viewport))) Some(renderer.render_texture(&node, Some(&viewport)))
} }
/// Create a Picture widget for the wallpaper background, optionally with GPU blur. /// Create a wallpaper-only window for secondary monitors.
/// Uses `blur_cache` to compute the blurred texture only once across all monitors. pub fn create_wallpaper_window(
fn create_background_picture(
texture: &gdk::Texture, texture: &gdk::Texture,
blur_radius: Option<f32>, blur_radius: Option<f32>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>, app: &gtk::Application,
) -> gtk::Picture { ) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(texture, blur_radius);
window.set_child(Some(&background));
window
}
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
fn create_background_picture(texture: &gdk::Texture, blur_radius: Option<f32>) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture); let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover); background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true); background.set_hexpand(true);
@ -202,16 +176,9 @@ fn create_background_picture(
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) { if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
let texture = texture.clone(); let texture = texture.clone();
let blur_cache = blur_cache.clone();
background.connect_realize(move |picture| { background.connect_realize(move |picture| {
// Use cached blurred texture if available
if let Some(ref cached) = *blur_cache.borrow() {
picture.set_paintable(Some(cached));
return;
}
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) { if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
picture.set_paintable(Some(&blurred)); picture.set_paintable(Some(&blurred));
*blur_cache.borrow_mut() = Some(blurred);
} }
}); });
} }
@ -226,23 +193,14 @@ struct GreeterState {
default_avatar_texture: Option<gdk::Texture>, default_avatar_texture: Option<gdk::Texture>,
failed_attempts: HashMap<String, u32>, failed_attempts: HashMap<String, u32>,
greetd_sock: Arc<Mutex<Option<UnixStream>>>, greetd_sock: Arc<Mutex<Option<UnixStream>>>,
greetd_sock_path: Option<String>,
login_cancelled: Arc<std::sync::atomic::AtomicBool>, login_cancelled: Arc<std::sync::atomic::AtomicBool>,
fingerprint_available: bool, fingerprint_available: bool,
/// Incremented on each user switch to discard stale async results.
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. /// Create the main greeter window with login UI.
pub fn create_greeter_window( pub fn create_greeter_window(
texture: Option<&gdk::Texture>, texture: Option<&gdk::Texture>,
config: &Config, config: &Config,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
app: &gtk::Application, app: &gtk::Application,
) -> gtk::ApplicationWindow { ) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
@ -253,7 +211,11 @@ pub fn create_greeter_window(
// Apply GTK theme from config // Apply GTK theme from config
if let Some(ref theme_name) = config.gtk_theme { if let Some(ref theme_name) = config.gtk_theme {
if is_valid_gtk_theme(theme_name) { if !theme_name.is_empty()
&& theme_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
{
if let Some(settings) = gtk::Settings::default() { if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_theme_name(Some(theme_name)); settings.set_gtk_theme_name(Some(theme_name));
} }
@ -271,21 +233,14 @@ pub fn create_greeter_window(
log::debug!("GTK theme: {theme}"); log::debug!("GTK theme: {theme}");
} }
// Cache GREETD_SOCK at startup — it never changes during runtime
let greetd_sock_path = std::env::var("GREETD_SOCK").ok().filter(|p| !p.is_empty());
let state = Rc::new(RefCell::new(GreeterState { let state = Rc::new(RefCell::new(GreeterState {
selected_user: None, selected_user: None,
avatar_cache: HashMap::new(), avatar_cache: HashMap::new(),
default_avatar_texture: None, default_avatar_texture: None,
failed_attempts: HashMap::new(), failed_attempts: HashMap::new(),
greetd_sock: Arc::new(Mutex::new(None)), greetd_sock: Arc::new(Mutex::new(None)),
greetd_sock_path,
login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)), login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fingerprint_available: false, fingerprint_available: false,
user_switch_generation: 0,
fingerprint_probe: None,
fingerprint_probe_initializing: false,
})); }));
// Root overlay for layering // Root overlay for layering
@ -294,7 +249,7 @@ pub fn create_greeter_window(
// Background wallpaper // Background wallpaper
if let Some(texture) = texture { if let Some(texture) = texture {
overlay.set_child(Some(&create_background_picture(texture, config.background_blur, blur_cache))); overlay.set_child(Some(&create_background_picture(texture, config.background_blur)));
} }
// Main layout: 3 rows (top spacer, center login, bottom bar) // Main layout: 3 rows (top spacer, center login, bottom bar)
@ -496,11 +451,7 @@ pub fn create_greeter_window(
}; };
let Some(user) = user else { return }; let Some(user) = user else { return };
let password = Zeroizing::new(entry.text().to_string()); let password = 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 session = get_selected_session(&session_dropdown, &sessions_rc);
let Some(session) = session else { let Some(session) = session else {
@ -510,7 +461,7 @@ pub fn create_greeter_window(
attempt_login( attempt_login(
&user, &user,
password, &password,
&session, &session,
strings, strings,
&state, &state,
@ -543,18 +494,6 @@ pub fn create_greeter_window(
)); ));
window.add_controller(key_controller); window.add_controller(key_controller);
// Grab keyboard focus after map — layer-shell keyboard grab is only
// confirmed by the compositor at map time, not at realize time.
window.connect_map(clone!(
#[weak]
password_entry,
move |_| {
glib::idle_add_local_once(move || {
password_entry.grab_focus();
});
}
));
// Defer initial user selection until realized (for correct theme colors) // Defer initial user selection until realized (for correct theme colors)
window.connect_realize(clone!( window.connect_realize(clone!(
#[strong] #[strong]
@ -620,7 +559,6 @@ pub fn create_greeter_window(
} }
/// Select the last user or the first available user. /// Select the last user or the first available user.
#[allow(clippy::too_many_arguments)]
fn select_initial_user( fn select_initial_user(
users: &[User], users: &[User],
state: &Rc<RefCell<GreeterState>>, state: &Rc<RefCell<GreeterState>>,
@ -663,7 +601,6 @@ fn select_initial_user(
} }
/// Update the UI to show the selected user. /// Update the UI to show the selected user.
#[allow(clippy::too_many_arguments)]
fn switch_to_user( fn switch_to_user(
user: &User, user: &User,
state: &Rc<RefCell<GreeterState>>, state: &Rc<RefCell<GreeterState>>,
@ -679,13 +616,11 @@ fn switch_to_user(
strings: &'static Strings, strings: &'static Strings,
) { ) {
log::debug!("Switching to user: {}", user.username); log::debug!("Switching to user: {}", user.username);
let generation = { {
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
s.selected_user = Some(user.clone()); s.selected_user = Some(user.clone());
s.fingerprint_available = false; s.fingerprint_available = false;
s.user_switch_generation += 1; }
s.user_switch_generation
};
username_label.set_text(user.display_name()); username_label.set_text(user.display_name());
password_entry.set_text(""); password_entry.set_text("");
@ -715,7 +650,7 @@ fn switch_to_user(
// Pre-select last used session for this user // Pre-select last used session for this user
select_last_session(&user.username, session_dropdown, sessions); select_last_session(&user.username, session_dropdown, sessions);
// Probe fprintd for fingerprint availability (cached device proxy, generation-guarded) // Probe fprintd for fingerprint availability
if fingerprint_enabled { if fingerprint_enabled {
let username = user.username.clone(); let username = user.username.clone();
glib::spawn_future_local(clone!( glib::spawn_future_local(clone!(
@ -724,50 +659,9 @@ fn switch_to_user(
#[strong] #[strong]
state, state,
async move { async move {
// Initialize probe on first use, then reuse cached device proxy. let mut probe = crate::fingerprint::FingerprintProbe::new();
// Atomic check-and-set on fingerprint_probe_initializing prevents probe.init_async().await;
// two concurrent probes (from a fast user switch) from both let available = probe.is_available_async(&username).await;
// 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 {
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;
}
}
// Take probe out of state to avoid holding borrow across await
let probe = state.borrow_mut().fingerprint_probe.take();
let available = match &probe {
Some(p) => p.is_available_async(&username).await,
None => false,
};
state.borrow_mut().fingerprint_probe = probe;
// Discard result if user switched while we were probing
let s = state.borrow();
if s.user_switch_generation != generation {
return;
}
drop(s);
state.borrow_mut().fingerprint_available = available; state.borrow_mut().fingerprint_available = available;
fp_label.set_visible(available); fp_label.set_visible(available);
if available { if available {
@ -807,40 +701,28 @@ fn set_avatar_from_file(
Ok(_) => {} Ok(_) => {}
} }
// Show fallback immediately; decode asynchronously via GIO so the greeter let Some(path_str) = path.to_str() else {
// stays responsive during a user-switch click. log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display());
image.set_icon_name(Some("avatar-default-symbolic")); image.set_icon_name(Some("avatar-default-symbolic"));
return;
};
let display_path = path.to_path_buf(); match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
let file = gio::File::for_path(path); Ok(pixbuf) => {
let image_clone = image.clone(); let texture = gdk::Texture::for_pixbuf(&pixbuf);
let state_clone = state.clone(); if let Some(name) = username {
let username_owned = username.map(String::from); state
.borrow_mut()
glib::spawn_future_local(async move { .avatar_cache
let stream = match file.read_future(glib::Priority::default()).await { .insert(name.to_string(), texture.clone());
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());
} }
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. /// Load the default avatar SVG from GResources, tinted with the foreground color.
@ -952,19 +834,15 @@ fn extract_greetd_description<'a>(response: &'a serde_json::Value, fallback: &'a
.unwrap_or(fallback) .unwrap_or(fallback)
} }
/// Display a greetd error. Logs raw PAM details at debug level, /// Display a greetd error, using a fallback for missing or oversized descriptions.
/// shows only the generic fallback in the UI to avoid leaking system info.
fn show_greetd_error( fn show_greetd_error(
error_label: &gtk::Label, error_label: &gtk::Label,
password_entry: &gtk::PasswordEntry, password_entry: &gtk::PasswordEntry,
response: &serde_json::Value, response: &serde_json::Value,
fallback: &str, fallback: &str,
) { ) {
let raw = extract_greetd_description(response, fallback); let message = extract_greetd_description(response, fallback);
if raw != fallback { show_error(error_label, password_entry, message);
log::debug!("greetd error detail: {raw}");
}
show_error(error_label, password_entry, fallback);
} }
/// Cancel any in-progress greetd session. /// Cancel any in-progress greetd session.
@ -973,10 +851,10 @@ fn cancel_pending_session(state: &Rc<RefCell<GreeterState>>) {
let s = state.borrow(); let s = state.borrow();
s.login_cancelled s.login_cancelled
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut sock_guard) = s.greetd_sock.lock() if let Ok(mut sock_guard) = s.greetd_sock.lock() {
&& let Some(sock) = sock_guard.take() if let Some(sock) = sock_guard.take() {
{ let _ = sock.shutdown(std::net::Shutdown::Both);
let _ = sock.shutdown(std::net::Shutdown::Both); }
} }
} }
@ -994,7 +872,7 @@ fn set_login_sensitive(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn attempt_login( fn attempt_login(
user: &User, user: &User,
password: Zeroizing<String>, password: &str,
session: &Session, session: &Session,
strings: &'static Strings, strings: &'static Strings,
state: &Rc<RefCell<GreeterState>>, state: &Rc<RefCell<GreeterState>>,
@ -1004,9 +882,9 @@ fn attempt_login(
session_dropdown: &gtk::DropDown, session_dropdown: &gtk::DropDown,
) { ) {
log::debug!("Login attempt for user: {}", user.username); log::debug!("Login attempt for user: {}", user.username);
let sock_path = match state.borrow().greetd_sock_path.clone() { let sock_path = match std::env::var("GREETD_SOCK") {
Some(p) => p, Ok(p) if !p.is_empty() => p,
None => { _ => {
show_error(error_label, password_entry, strings.greetd_sock_not_set); show_error(error_label, password_entry, strings.greetd_sock_not_set);
return; return;
} }
@ -1024,6 +902,28 @@ fn attempt_login(
return; return;
} }
match std::fs::metadata(&sock_pathbuf) {
Ok(meta) => {
use std::os::unix::fs::FileTypeExt;
if !meta.file_type().is_socket() {
show_error(
error_label,
password_entry,
strings.greetd_sock_not_socket,
);
return;
}
}
Err(_) => {
show_error(
error_label,
password_entry,
strings.greetd_sock_unreachable,
);
return;
}
}
// Reset cancellation flag and disable UI // Reset cancellation flag and disable UI
{ {
let s = state.borrow(); let s = state.borrow();
@ -1033,6 +933,7 @@ fn attempt_login(
set_login_sensitive(password_entry, session_dropdown, false); set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone(); let username = user.username.clone();
let password = password.to_string();
let exec_cmd = session.exec_cmd.clone(); let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone(); let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone(); let greetd_sock = state.borrow().greetd_sock.clone();
@ -1052,8 +953,6 @@ fn attempt_login(
state, state,
async move { async move {
let session_name_clone = session_name.clone(); let session_name_clone = session_name.clone();
// Minimum response time to prevent username enumeration via timing
let login_start = std::time::Instant::now();
let result = gio::spawn_blocking(move || { let result = gio::spawn_blocking(move || {
login_worker( login_worker(
&username, &username,
@ -1067,18 +966,6 @@ fn attempt_login(
) )
}) })
.await; .await;
let elapsed = login_start.elapsed();
let min_response = std::time::Duration::from_millis(500);
if elapsed < min_response {
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 { match result {
Ok(Ok(LoginResult::Success { username })) => { Ok(Ok(LoginResult::Success { username })) => {
@ -1143,7 +1030,6 @@ enum LoginResult {
} }
/// Run greetd IPC in a background thread. /// Run greetd IPC in a background thread.
#[allow(clippy::too_many_arguments)]
fn login_worker( fn login_worker(
username: &str, username: &str,
password: &str, password: &str,
@ -1190,11 +1076,8 @@ fn login_worker(
return Ok(LoginResult::Cancelled); return Ok(LoginResult::Cancelled);
} }
if response.get("type").and_then(|v| v.as_str()) == Some("error") { if response.get("type").and_then(|v| v.as_str()) == Some("error") {
let raw = extract_greetd_description(&response, strings.auth_failed); let message = extract_greetd_description(&response, strings.auth_failed).to_string();
if raw != strings.auth_failed { return Ok(LoginResult::Error { message });
log::debug!("greetd error detail: {raw}");
}
return Ok(LoginResult::Error { message: strings.auth_failed.to_string() });
} }
} }
@ -1282,12 +1165,9 @@ fn login_worker(
username: username.to_string(), username: username.to_string(),
}); });
} else { } else {
let raw = extract_greetd_description(&response, strings.session_start_failed);
if raw != strings.session_start_failed {
log::debug!("greetd error detail: {raw}");
}
return Ok(LoginResult::Error { return Ok(LoginResult::Error {
message: strings.session_start_failed.to_string(), message: extract_greetd_description(&response, strings.session_start_failed)
.to_string(),
}); });
} }
} }
@ -1310,7 +1190,7 @@ fn execute_power_action(
#[weak] #[weak]
button, button,
async move { async move {
let result = gio::spawn_blocking(action_fn).await; let result = gio::spawn_blocking(move || action_fn()).await;
match result { match result {
Ok(Ok(())) => {} Ok(Ok(())) => {}
@ -1333,15 +1213,6 @@ fn execute_power_action(
// -- Last user/session persistence -- // -- Last user/session persistence --
/// Create a cache directory with restricted permissions (0o700).
fn create_cache_dir(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)
}
fn load_last_user() -> Option<String> { fn load_last_user() -> Option<String> {
load_last_user_from(Path::new(LAST_USER_PATH)) load_last_user_from(Path::new(LAST_USER_PATH))
} }
@ -1365,7 +1236,7 @@ fn save_last_user(username: &str) {
fn save_last_user_to(path: &Path, username: &str) { fn save_last_user_to(path: &Path, username: &str) {
log::debug!("Saving last user: {username}"); log::debug!("Saving last user: {username}");
if let Some(parent) = path.parent() if let Some(parent) = path.parent()
&& let Err(e) = create_cache_dir(parent) && let Err(e) = std::fs::create_dir_all(parent)
{ {
log::warn!("Failed to create cache dir {}: {e}", parent.display()); log::warn!("Failed to create cache dir {}: {e}", parent.display());
return; return;
@ -1418,10 +1289,7 @@ fn save_last_session(username: &str, session_name: &str) {
return; return;
} }
let dir = Path::new(LAST_SESSION_DIR); let dir = Path::new(LAST_SESSION_DIR);
if let Err(e) = create_cache_dir(dir) { let _ = std::fs::create_dir_all(dir);
log::warn!("Failed to create session cache dir {}: {e}", dir.display());
return;
}
save_last_session_to(&dir.join(username), session_name); save_last_session_to(&dir.join(username), session_name);
} }
@ -2016,55 +1884,4 @@ mod tests {
let resp = serde_json::json!({"type": "error"}); let resp = serde_json::json!({"type": "error"});
assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback"); assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback");
} }
// -- GTK theme validation --
#[test]
fn valid_gtk_themes() {
assert!(is_valid_gtk_theme("Adwaita"));
assert!(is_valid_gtk_theme("Catppuccin-Mocha"));
assert!(is_valid_gtk_theme("Arc_Dark"));
assert!(is_valid_gtk_theme("Theme+Variant"));
assert!(is_valid_gtk_theme("v1.0"));
}
#[test]
fn invalid_gtk_themes() {
assert!(!is_valid_gtk_theme(""));
assert!(!is_valid_gtk_theme("../evil"));
assert!(!is_valid_gtk_theme("theme/path"));
assert!(!is_valid_gtk_theme("theme name"));
assert!(!is_valid_gtk_theme("thème"));
assert!(!is_valid_gtk_theme("theme\0null"));
}
// -- Username validation: Unicode edge cases --
#[test]
fn invalid_unicode_usernames() {
assert!(!is_valid_username("üser"));
assert!(!is_valid_username("用户"));
assert!(!is_valid_username("user🔑"));
}
// -- Cache directory permissions --
#[test]
fn create_cache_dir_sets_mode_0o700() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join("cache");
create_cache_dir(&cache_dir).unwrap();
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&cache_dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "Cache dir should be 0o700, got {mode:#o}");
}
#[test]
fn save_last_session_with_unwritable_dir() {
// Attempt to save in a non-existent dir under /proc (guaranteed unwritable)
let path = Path::new("/proc/nonexistent-moongreet-test/session");
save_last_session_to(path, "niri");
// Should not panic — just logs a warning
}
} }

View File

@ -4,7 +4,6 @@
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::OnceLock;
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf"; const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
@ -20,6 +19,8 @@ pub struct Strings {
pub no_session_selected: &'static str, pub no_session_selected: &'static str,
pub greetd_sock_not_set: &'static str, pub greetd_sock_not_set: &'static str,
pub greetd_sock_not_absolute: &'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 auth_failed: &'static str,
pub wrong_password: &'static str, pub wrong_password: &'static str,
pub fingerprint_prompt: &'static str, pub fingerprint_prompt: &'static str,
@ -42,6 +43,8 @@ const STRINGS_DE: Strings = Strings {
no_session_selected: "Keine Session ausgewählt", no_session_selected: "Keine Session ausgewählt",
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt", greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad", 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", auth_failed: "Authentifizierung fehlgeschlagen",
wrong_password: "Falsches Passwort", wrong_password: "Falsches Passwort",
fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben", fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
@ -62,6 +65,8 @@ const STRINGS_EN: Strings = Strings {
no_session_selected: "No session selected", no_session_selected: "No session selected",
greetd_sock_not_set: "GREETD_SOCK not set", greetd_sock_not_set: "GREETD_SOCK not set",
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path", 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", auth_failed: "Authentication failed",
wrong_password: "Wrong password", wrong_password: "Wrong password",
fingerprint_prompt: "Place finger on reader or enter password", fingerprint_prompt: "Place finger on reader or enter password",
@ -130,17 +135,14 @@ pub fn detect_locale() -> String {
result result
} }
/// Cached locale — detected once, reused for the lifetime of the process.
static CACHED_LOCALE: OnceLock<String> = OnceLock::new();
/// Return the string table for the given locale, defaulting to English. /// Return the string table for the given locale, defaulting to English.
pub fn load_strings(locale: Option<&str>) -> &'static Strings { pub fn load_strings(locale: Option<&str>) -> &'static Strings {
let locale = match locale { let locale = match locale {
Some(l) => l, Some(l) => l.to_string(),
None => CACHED_LOCALE.get_or_init(detect_locale), None => detect_locale(),
}; };
match locale { match locale.as_str() {
"de" => &STRINGS_DE, "de" => &STRINGS_DE,
_ => &STRINGS_EN, _ => &STRINGS_EN,
} }
@ -286,10 +288,6 @@ mod tests {
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining"); assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked"); assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response"); 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; mod users;
use gdk4 as gdk; use gdk4 as gdk;
use glib::clone;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell; use gtk4_layer_shell::LayerShell;
use std::rc::Rc;
fn load_css(display: &gdk::Display) { fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new(); let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css"); css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
@ -60,59 +58,33 @@ fn activate(app: &gtk::Application) {
greeter::load_background_texture(&path) greeter::load_background_texture(&path)
}); });
let blur_cache = std::rc::Rc::new(std::cell::RefCell::new(None));
let use_layer_shell = std::env::var("MOONGREET_NO_LAYER_SHELL").is_err(); let use_layer_shell = std::env::var("MOONGREET_NO_LAYER_SHELL").is_err();
log::debug!("Layer shell: {use_layer_shell}"); log::debug!("Layer shell: {use_layer_shell}");
// Main greeter window (login UI) — compositor picks focused monitor
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, app);
if use_layer_shell { if use_layer_shell {
// One greeter window per monitor — only the first gets keyboard input setup_layer_shell(&greeter_window, true, gtk4_layer_shell::Layer::Top);
}
greeter_window.present();
// Wallpaper-only windows on all monitors (only with layer shell)
if use_layer_shell
&& let Some(ref texture) = bg_texture
{
let monitors = display.monitors(); let monitors = display.monitors();
log::debug!("Monitor count: {}", monitors.n_items()); log::debug!("Monitor count: {}", monitors.n_items());
let mut first = true;
for i in 0..monitors.n_items() { for i in 0..monitors.n_items() {
if let Some(monitor) = monitors if let Some(monitor) = monitors
.item(i) .item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) .and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{ {
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app); let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, app);
setup_layer_shell(&window, first, gtk4_layer_shell::Layer::Top); setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
window.set_monitor(Some(&monitor)); wallpaper.set_monitor(Some(&monitor));
window.present(); wallpaper.present();
first = false;
} }
} }
// 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();
}
}
}
));
} 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,12 +99,10 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}"); eprintln!("Failed to create journal logger: {e}");
} }
} }
// Require MOONGREET_DEBUG=1 to raise verbosity. Mere presence (e.g. an let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
// empty value in a session-setup script) must not escalate the journal log::LevelFilter::Debug
// to Debug, which leaks socket paths, usernames, and auth round counts. } else {
let level = match std::env::var("MOONGREET_DEBUG").ok().as_deref() { log::LevelFilter::Info
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
}; };
log::set_max_level(level); log::set_max_level(level);
} }

View File

@ -2,18 +2,11 @@
// ABOUTME: Wrappers around system commands for the greeter UI. // ABOUTME: Wrappers around system commands for the greeter UI.
use std::fmt; use std::fmt;
use std::io::Read; use std::process::Command;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)] #[derive(Debug)]
pub enum PowerError { pub enum PowerError {
CommandFailed { action: &'static str, message: String }, CommandFailed { action: &'static str, message: String },
Timeout { action: &'static str },
} }
impl fmt::Display for PowerError { impl fmt::Display for PowerError {
@ -22,81 +15,41 @@ impl fmt::Display for PowerError {
PowerError::CommandFailed { action, message } => { PowerError::CommandFailed { action, message } => {
write!(f, "{action} failed: {message}") write!(f, "{action} failed: {message}")
} }
PowerError::Timeout { action } => {
write!(f, "{action} timed out")
}
} }
} }
} }
impl std::error::Error for PowerError {} impl std::error::Error for PowerError {}
/// Run a command with timeout and return a PowerError on failure. /// Run a command and return a PowerError on failure.
///
/// Uses blocking `child.wait()` with a separate timeout thread that sends
/// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`,
/// so blocking is expected.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
log::debug!("Power action: {action} ({program} {args:?})"); log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program) let child = Command::new(program)
.args(args) .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())
.stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| PowerError::CommandFailed { .map_err(|e| PowerError::CommandFailed {
action, action,
message: e.to_string(), message: e.to_string(),
})?; })?;
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32); let output = child
let done = Arc::new(AtomicBool::new(false)); .wait_with_output()
let done_clone = done.clone(); .map_err(|e| PowerError::CommandFailed {
let timeout_thread = std::thread::spawn(move || {
let interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < POWER_TIMEOUT {
std::thread::sleep(interval);
if done_clone.load(Ordering::Relaxed) {
return;
}
elapsed += interval;
}
// ESRCH if the process already exited — harmless
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
});
let status = child.wait().map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
done.store(true, Ordering::Relaxed);
let _ = timeout_thread.join();
if status.success() {
log::debug!("Power action {action} completed");
Ok(())
} else {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if status.signal() == Some(9) {
return Err(PowerError::Timeout { action });
}
}
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
Err(PowerError::CommandFailed {
action, action,
message: format!("exit code {}: {}", status, stderr_buf.trim()), message: e.to_string(),
}) })?;
if output.status.success() {
log::debug!("Power action {action} completed successfully");
} else {
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. /// Reboot the system via loginctl.
@ -122,12 +75,6 @@ mod tests {
assert_eq!(err.to_string(), "reboot failed: No such file or directory"); 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] #[test]
fn run_command_returns_error_for_missing_binary() { fn run_command_returns_error_for_missing_binary() {
let result = run_command("test", "nonexistent-binary-xyz", &[]); let result = run_command("test", "nonexistent-binary-xyz", &[]);
@ -152,7 +99,7 @@ mod tests {
#[test] #[test]
fn run_command_passes_args() { fn run_command_passes_args() {
let result = run_command("test", "echo", &["hello", "world"]); let result = run_command("test", "true", &["--ignored-arg"]);
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }

View File

@ -23,8 +23,6 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
let mut in_section = false; let mut in_section = false;
let mut name: Option<String> = None; let mut name: Option<String> = None;
let mut exec_cmd: Option<String> = None; let mut exec_cmd: Option<String> = None;
let mut hidden = false;
let mut no_display = false;
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
@ -38,26 +36,17 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
continue; continue;
} }
if let Some(value) = line.strip_prefix("Name=") if let Some(value) = line.strip_prefix("Name=") {
&& name.is_none() if name.is_none() {
{ name = Some(value.to_string());
name = Some(value.to_string()); }
} else if let Some(value) = line.strip_prefix("Exec=") } else if let Some(value) = line.strip_prefix("Exec=") {
&& exec_cmd.is_none() if exec_cmd.is_none() {
{ exec_cmd = Some(value.to_string());
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 name = name.filter(|s| !s.is_empty());
let exec_cmd = exec_cmd.filter(|s| !s.is_empty()); let exec_cmd = exec_cmd.filter(|s| !s.is_empty());

View File

@ -70,7 +70,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
Err(_) => continue, Err(_) => continue,
}; };
if !(MIN_UID..=MAX_UID).contains(&uid) { if uid < MIN_UID || uid > MAX_UID {
continue; continue;
} }
if NOLOGIN_SHELLS.contains(&shell) { if NOLOGIN_SHELLS.contains(&shell) {
@ -94,7 +94,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
users users
} }
/// Find avatar for a user: ~/.face > AccountsService icon > None. /// Find avatar for a user: AccountsService icon > ~/.face > None.
/// Rejects symlinks to prevent path traversal. /// Rejects symlinks to prevent path traversal.
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> { pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR)) get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
@ -106,30 +106,30 @@ pub fn get_avatar_path_with(
home: &Path, home: &Path,
accountsservice_dir: &Path, accountsservice_dir: &Path,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
// ~/.face takes priority (consistent with moonlock/moonset) // AccountsService icon takes priority
let face = home.join(".face");
if let Ok(meta) = face.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", face.display());
} else if meta.is_file() {
log::debug!("Avatar for {username}: ~/.face {}", face.display());
return Some(face);
}
}
// AccountsService icon fallback
if accountsservice_dir.exists() { if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username); let icon = accountsservice_dir.join(username);
if let Ok(meta) = icon.symlink_metadata() { if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() { if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display()); log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
} else if meta.is_file() { } else {
log::debug!("Avatar for {username}: AccountsService {}", icon.display()); log::debug!("Avatar for {username}: AccountsService {}", icon.display());
return Some(icon); return Some(icon);
} }
} }
} }
// ~/.face fallback
let face = home.join(".face");
if let Ok(meta) = face.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", face.display());
} else {
log::debug!("Avatar for {username}: ~/.face {}", face.display());
return Some(face);
}
}
log::debug!("No avatar found for {username}"); log::debug!("No avatar found for {username}");
None None
} }
@ -248,7 +248,7 @@ mod tests {
} }
#[test] #[test]
fn face_file_takes_priority_over_accountsservice() { fn accountsservice_icon_takes_priority() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let icons_dir = dir.path().join("icons"); let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap(); fs::create_dir(&icons_dir).unwrap();
@ -261,7 +261,7 @@ mod tests {
fs::write(&face, "fake face").unwrap(); fs::write(&face, "fake face").unwrap();
let path = get_avatar_path_with("testuser", &home, &icons_dir); let path = get_avatar_path_with("testuser", &home, &icons_dir);
assert_eq!(path, Some(face)); assert_eq!(path, Some(icon));
} }
#[test] #[test]