Compare commits

...

21 Commits
v0.6.0 ... main

Author SHA1 Message Date
b9b6f50974 fix: audit LOW fixes — stdout null, utf-8 path, debug value, hidden sessions (v0.8.6)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- power::run_command: .stdout(Stdio::null()) — the pipe was never drained,
  structurally fragile even if no current caller hits it.
- config: replace to_string_lossy() on relative wallpaper paths with
  to_str() + log::warn, so non-UTF-8 paths are dropped cleanly instead
  of being mangled into unopenable U+FFFD strings.
- main: require MOONGREET_DEBUG=1 to raise verbosity. Mere presence of
  the var must not leak socket paths, usernames, and auth round counts
  into the journal.
- sessions: parse Hidden= and NoDisplay= keys, skip entries marked true.
  Keeps disabled or stub .desktop files out of the session dropdown.
2026-04-24 14:08:35 +02:00
3a1af6471f fix: audit MEDIUM fixes — FP race, async avatar, symlink, FD leak (v0.8.5)
- i18n test: assert four previously-missing string fields so future locales
  cannot ship empty strings unnoticed.
- greeter: atomic check-and-set fingerprint_probe_initializing to keep a
  fast user switch from spawning two parallel fprintd D-Bus inits.
- greeter: set_avatar_from_file decodes via gio::File::read_future +
  Pixbuf::from_stream_at_scale_future inside glib::spawn_future_local;
  shows default icon first, swaps on completion.
- greeter: cap MAX_WALLPAPER_FILE_SIZE at 10 MB and MAX_AVATAR_FILE_SIZE
  at 5 MB to bound worst-case decode latency.
- config: apply the same symlink-rejection check to the Moonarch
  wallpaper fallback that the user-configured path already uses.
- greeter: after login_worker returns, drop the cloned greetd socket
  held in shared state so repeated failed logins do not leak FDs.
2026-04-24 13:26:52 +02:00
35f1a17cdf fix: audit fix — reduce password copies in memory (v0.8.4)
- attempt_login takes Zeroizing<String> by value, eliminating the redundant
  Zeroizing::new(password.to_string()) that doubled the Rust-owned copy.
- Clear password_entry's internal buffer immediately after extracting the
  password, shortening the window during which the GTK GString persists in
  non-zeroizable libc memory.
2026-04-24 12:52:59 +02:00
48d363bb18 fix: ship polkit rule so greeter user can reboot/power off (v0.8.3)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
The rule that grants the greeter user authorization for
org.freedesktop.login1.{reboot,power-off} lived only in the moonarch
repo and was never installed by any PKGBUILD. On a fresh install the
reboot/shutdown buttons silently failed because greetd's greeter
session is inactive in logind and polkit denies inactive sessions by
default.

Move the rule into the moongreet source tree where it belongs and
ship it via moongreet-git.
2026-04-21 09:11:59 +02:00
448e4212e3 docs: drop Selene persona, unify attribution on ClaudeCode
Remove the Selene persona block from CLAUDE.md and rewrite prior
DECISIONS entries from Selene to ClaudeCode for consistency with
the rest of the Moonarch ecosystem.
2026-04-21 09:03:21 +02:00
cd42df1095 fix: handle monitor hotplug for greeter windows (v0.8.2)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
Greeter windows were only created at startup. Hotplugged monitors (e.g.
HDMI reconnect) would show no UI. Connect to the monitor ListModel's
items-changed signal to create greeter windows for newly added monitors.

Aligned with moonlock's hotplug fix using the same pattern adapted for
gtk4-layer-shell (ListModel) instead of session-lock (connect_monitor).
2026-04-09 15:06:29 +02:00
f7e258d402 feat: show greeter UI on all monitors, not just one (v0.8.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
Wayland surfaces belong to exactly one output — mirroring is not an option.
Create one full greeter window per monitor via set_monitor(), with only the
first receiving KeyboardMode::Exclusive. Removes the old wallpaper-only
secondary windows. Matches moonlock's per-monitor pattern.
2026-04-08 08:48:04 +02:00
de97d6658e fix: grab keyboard focus on map instead of realize (v0.7.4)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
Layer-shell keyboard grab is only confirmed by the compositor at map
time. The previous realize-time grab_focus() could fire before the
compositor assigned keyboard input, causing intermittent input loss.
2026-04-06 22:29:37 +02:00
9c1e00d0ef fix: restore explicit gtk-theme in config for greetd session (v0.7.3)
GTK4 does not reliably read /etc/xdg/gtk-4.0/settings.ini under greetd
without a settings daemon, falling back to default blue accent instead
of Colloid-Grey-Dark-Catppuccin.
2026-04-06 22:24:06 +02:00
874888391e docs: rename Ragnar to ClaudeCode in DECISIONS.md
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-04-02 10:13:34 +02:00
51157ecb23 fix: replace hardcoded CSS colors with GTK theme variables (v0.7.2)
Greeter used hardcoded colors (#1a1a2e, white, #ff6b6b) instead of
GTK theme variables, breaking theme consistency across the ecosystem.
Now uses @theme_bg_color, @theme_fg_color, @error_color etc. —
matching moonlock and moonset.
2026-04-02 10:12:01 +02:00
183e10c1cc Remove unnecessary pacman git install from CI workflow
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
Git is already available in the runner image.
2026-04-02 08:28:06 +02:00
094878fc2e Remove gtk-theme from app config, use system-wide GTK settings instead
The GTK theme is now set globally via /etc/xdg/gtk-4.0/settings.ini
rather than per-application config.
2026-04-02 08:27:54 +02:00
cf18105887 Revert CI workaround: remove pacman install step
Some checks failed
Update PKGBUILD version / update-pkgver (push) Failing after 0s
The act_runner now uses a custom Arch-based image with git
pre-installed, so per-workflow installs are no longer needed.
2026-04-01 16:17:47 +02:00
f6f33a13ab fix: audit fixes — power timeout, timing mitigation, release profile, GREETD_SOCK cache (v0.7.1)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Add 30s timeout with SIGKILL to power actions (adapted from moonset)
- Add 500ms minimum login response time against timing enumeration
- Cache GREETD_SOCK in GreeterState at startup
- Add [profile.release] with LTO, codegen-units=1, strip
- Add compressed="true" to GResource CSS/SVG entries
- Add SYNC comments to duplicated blur/background functions
- Add nix dependency for signal handling in power timeout
2026-03-31 11:08:40 +02:00
60d294fa37 docs: update README, fix build.rs comment, correct gtk-theme in config
README: replace LD_PRELOAD with MOONGREET_NO_LAYER_SHELL env var,
add missing features (GPU blur, journal logging, password wiping).
build.rs: remove wallpaper.jpg reference.
moongreet.toml: correct gtk-theme to Colloid-Grey-Dark-Catppuccin.
2026-03-31 09:36:19 +02:00
1d557ea135 fix: audit fixes — password zeroize, blur downscale, symlink hardening, error filtering (v0.7.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Add zeroize dependency, wrap password in Zeroizing<String> from entry extraction
  through to login_worker (prevents heap-resident plaintext)
- Add MAX_BLUR_DIMENSION (1920px) downscale before GPU blur to reduce 4K workload
- Wallpaper: use symlink_metadata + is_symlink rejection in greeter.rs and config.rs
- Avatar: add is_file() check, swap lookup order to ~/.face first (consistent with
  moonlock/moonset)
- greetd errors: show generic fallback in UI, log raw PAM details at debug level only
- fprintd: validate device path prefix before creating D-Bus proxy
- Locale: cache detected locale via OnceLock (avoid repeated env/file reads)
2026-03-30 16:03:04 +02:00
a2dc89854d fix: security hardening, blur geometry, and performance audit fixes (v0.6.2)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
Security: cache dirs now 0o700 via DirBuilder::mode(), blur config
validated (finite + clamp 0–200), TOCTOU socket pre-check removed.

Quality: GPU blur geometry fixed (texture shifted instead of stretched),
is_valid_username hardened, is_valid_gtk_theme extracted as testable fn,
save_last_session error handling consistent with save_last_user.

Performance: blurred texture cached across monitors (1x GPU renderpass
instead of N), FingerprintProbe device proxy cached in GreeterState with
generation counter to prevent race condition on fast user-switch.

Clippy: all 7 warnings resolved (collapsible if-let, redundant closure,
manual_range_contains, too_many_arguments suppressed for GTK widget fns).

Tests: 109 → 118 (GTK theme validation, Unicode usernames, cache dir
permissions, unwritable dir handling, blur config edge cases).
2026-03-30 14:31:28 +02:00
f3f4db1ab1 ci: also update .SRCINFO in pkgver workflow
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
paru reads .SRCINFO (not PKGBUILD) for version comparison during
sysupgrade. Without updating .SRCINFO, paru never detects upgrades
for PKGBUILD repository packages.
2026-03-30 13:49:09 +02:00
a61fa4e145 ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-29 23:05:16 +02:00
f09a04a115 fix: elevate CSS priority to override GTK4 user theme (v0.6.1)
Colloid-Catppuccin theme loaded via ~/.config/gtk-4.0/gtk.css at
PRIORITY_USER (800) was overriding moongreet's PRIORITY_APPLICATION (600),
causing avatar to lose its circular border-radius.

- Use STYLE_PROVIDER_PRIORITY_USER for app CSS provider
- Replace border-radius: 50% with 9999px (GTK4 CSS percentage quirk)
- Include missed Cargo.lock and PKGBUILD updates from v0.6.0
2026-03-29 14:26:19 +02:00
20 changed files with 741 additions and 231 deletions

View File

@ -0,0 +1,43 @@
# 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,7 +1,5 @@
# Moongreet
**Name**: Selene (Mondgöttin — passend zu Moon-greet)
## Projekt
Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
@ -45,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
- `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, systemd-journal-logger
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200)
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor mit Hotplug via `items-changed` auf Monitor-ListModel (one greeter window per monitor, first gets keyboard), systemd-journal-logger
- `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions
@ -57,13 +55,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
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
- **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
- **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.
- **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).
- **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.
- **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
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
- **File Permissions**: Cache-Dateien 0o600
- **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`

28
Cargo.lock generated
View File

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

View File

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

View File

@ -1,35 +1,105 @@
# 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
- **Who**: Ragnar, Dom
- **Who**: ClaudeCode, Dom
- **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.
- **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
- **Who**: Selene, Dom
- **Who**: ClaudeCode, 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.
- **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.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: Ragnar, Dom
- **Who**: ClaudeCode, 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.
- **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.
## 2026-03-28 Optional background blur via `image` crate (superseded)
- **Who**: Selene, Dom
- **Who**: ClaudeCode, Dom
- **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.
- **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)
- **Who**: Selene, Dominik
- **Who**: ClaudeCode, Dominik
- **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
- **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,10 +12,13 @@ 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** — Greeter on primary, wallpaper on all monitors
- **Multi-monitor + hotplug** — Full greeter UI on all monitors (keyboard input on first), hotplugged monitors get 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
- **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
@ -57,6 +60,14 @@ sudo cp config/moongreet.toml /etc/moongreet/moongreet.toml
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
```bash
@ -66,8 +77,8 @@ cargo test
# Build release
cargo build --release
# Run locally (without greetd, needs LD_PRELOAD for layer-shell)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
# Run locally (without greetd, disables layer-shell)
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
```
## License

View File

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

View File

@ -4,5 +4,7 @@
[appearance]
# Absolute path to wallpaper image
background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme for the greeter UI
gtk-theme = "Colloid-Catppuccin"
# 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"

View File

@ -0,0 +1,12 @@
// 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

@ -4,7 +4,7 @@
# Maintainer: Dominik Kressler
pkgname=moongreet-git
pkgver=0.3.1.r5.g4c9b436
pkgver=0.4.0.r7.g77b94a5
pkgrel=1
pkgdesc="A greetd greeter for Wayland with GTK4 and Layer Shell"
arch=('x86_64')

View File

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

View File

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

View File

@ -68,12 +68,22 @@ 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() {
merged.background_path =
Some(parent.join(&bg).to_string_lossy().to_string());
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()
),
}
}
if appearance.background_blur.is_some() {
merged.background_blur = appearance.background_blur;
}
if let Some(blur) = appearance.background_blur {
if blur.is_finite() {
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() {
merged.gtk_theme = appearance.gtk_theme;
@ -107,21 +117,27 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
/// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path
// User-configured path — reject symlinks to prevent path traversal
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
if path.is_file() {
if let Ok(meta) = path.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() {
log::debug!("Wallpaper: using config path {}", path.display());
return Some(path);
}
log::debug!("Wallpaper: config path {} not found, trying fallbacks", path.display());
}
log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display());
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
// 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());
}
}
log::debug!("Wallpaper: no wallpaper found, using GTK background color");
None
@ -283,4 +299,45 @@ mod tests {
let config = load_config(Some(&paths));
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,6 +10,7 @@ const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
const DBUS_TIMEOUT_MS: i32 = 3000;
const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/";
/// Lightweight fprintd probe — detects device availability and finger enrollment.
/// Does NOT perform verification (that happens through greetd/PAM).
@ -66,6 +67,10 @@ impl FingerprintProbe {
if device_path.is_empty() {
return;
}
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
log::warn!("Unexpected fprintd device path: {device_path}");
return;
}
match gio::DBusProxy::for_bus_future(
gio::BusType::System,

View File

@ -12,6 +12,7 @@ use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
use crate::config::Config;
use crate::i18n::{faillock_warning, load_strings, Strings};
@ -21,8 +22,8 @@ use crate::sessions::{self, Session};
use crate::users::{self, User};
const AVATAR_SIZE: i32 = 128;
const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 1024 * 1024;
const MAX_AVATAR_FILE_SIZE: u64 = 5 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 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;
@ -87,7 +88,9 @@ fn is_valid_username(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_USERNAME_LENGTH {
return false;
}
let first = name.chars().next().unwrap();
let Some(first) = name.chars().next() else {
return false;
};
if !first.is_ascii_alphanumeric() && first != '_' {
return false;
}
@ -95,17 +98,29 @@ fn is_valid_username(name: &str) -> bool {
.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.
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
if let Ok(meta) = std::fs::metadata(bg_path)
&& meta.len() > MAX_WALLPAPER_FILE_SIZE
{
if let Ok(meta) = std::fs::symlink_metadata(bg_path) {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink wallpaper: {}", bg_path.display());
return None;
}
if meta.len() > MAX_WALLPAPER_FILE_SIZE {
log::warn!(
"Wallpaper file too large ({} bytes), skipping: {}",
meta.len(), bg_path.display()
);
return None;
}
}
match gdk::Texture::from_filename(bg_path) {
Ok(texture) => Some(texture),
Err(e) => {
@ -118,11 +133,22 @@ pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
// -- 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.
///
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// 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.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
@ -131,17 +157,29 @@ fn render_blurred_texture(
let native = widget.native()?;
let renderer = native.renderer()?;
let w = texture.width() as f32;
let h = texture.height() as f32;
let orig_w = texture.width() as f32;
let orig_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)
let pad = (sigma * 3.0).ceil();
let pad = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
// Clip output to original texture size
// Clip output to scaled texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
snapshot.push_blur(sigma as f64);
snapshot.push_blur(scaled_sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene_rs::Rect::new(0.0, 0.0, w + 2.0 * pad, h + 2.0 * pad));
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur
snapshot.pop(); // clip
@ -150,25 +188,13 @@ fn render_blurred_texture(
Some(renderer.render_texture(&node, Some(&viewport)))
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
/// Uses `blur_cache` to compute the blurred texture only once across all monitors.
fn create_background_picture(
texture: &gdk::Texture,
blur_radius: Option<f32>,
app: &gtk::Application,
) -> 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 {
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true);
@ -176,9 +202,16 @@ fn create_background_picture(texture: &gdk::Texture, blur_radius: Option<f32>) -
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
let texture = texture.clone();
let blur_cache = blur_cache.clone();
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) {
picture.set_paintable(Some(&blurred));
*blur_cache.borrow_mut() = Some(blurred);
}
});
}
@ -193,14 +226,23 @@ struct GreeterState {
default_avatar_texture: Option<gdk::Texture>,
failed_attempts: HashMap<String, u32>,
greetd_sock: Arc<Mutex<Option<UnixStream>>>,
greetd_sock_path: Option<String>,
login_cancelled: Arc<std::sync::atomic::AtomicBool>,
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.
pub fn create_greeter_window(
texture: Option<&gdk::Texture>,
config: &Config,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
app: &gtk::Application,
) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
@ -211,11 +253,7 @@ pub fn create_greeter_window(
// Apply GTK theme from config
if let Some(ref theme_name) = config.gtk_theme {
if !theme_name.is_empty()
&& theme_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
{
if is_valid_gtk_theme(theme_name) {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_theme_name(Some(theme_name));
}
@ -233,14 +271,21 @@ pub fn create_greeter_window(
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 {
selected_user: None,
avatar_cache: HashMap::new(),
default_avatar_texture: None,
failed_attempts: HashMap::new(),
greetd_sock: Arc::new(Mutex::new(None)),
greetd_sock_path,
login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fingerprint_available: false,
user_switch_generation: 0,
fingerprint_probe: None,
fingerprint_probe_initializing: false,
}));
// Root overlay for layering
@ -249,7 +294,7 @@ pub fn create_greeter_window(
// Background wallpaper
if let Some(texture) = texture {
overlay.set_child(Some(&create_background_picture(texture, config.background_blur)));
overlay.set_child(Some(&create_background_picture(texture, config.background_blur, blur_cache)));
}
// Main layout: 3 rows (top spacer, center login, bottom bar)
@ -451,7 +496,11 @@ pub fn create_greeter_window(
};
let Some(user) = user else { return };
let password = entry.text().to_string();
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 {
@ -461,7 +510,7 @@ pub fn create_greeter_window(
attempt_login(
&user,
&password,
password,
&session,
strings,
&state,
@ -494,6 +543,18 @@ pub fn create_greeter_window(
));
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)
window.connect_realize(clone!(
#[strong]
@ -559,6 +620,7 @@ pub fn create_greeter_window(
}
/// Select the last user or the first available user.
#[allow(clippy::too_many_arguments)]
fn select_initial_user(
users: &[User],
state: &Rc<RefCell<GreeterState>>,
@ -601,6 +663,7 @@ fn select_initial_user(
}
/// Update the UI to show the selected user.
#[allow(clippy::too_many_arguments)]
fn switch_to_user(
user: &User,
state: &Rc<RefCell<GreeterState>>,
@ -616,11 +679,13 @@ fn switch_to_user(
strings: &'static Strings,
) {
log::debug!("Switching to user: {}", user.username);
{
let generation = {
let mut s = state.borrow_mut();
s.selected_user = Some(user.clone());
s.fingerprint_available = false;
}
s.user_switch_generation += 1;
s.user_switch_generation
};
username_label.set_text(user.display_name());
password_entry.set_text("");
@ -650,7 +715,7 @@ fn switch_to_user(
// Pre-select last used session for this user
select_last_session(&user.username, session_dropdown, sessions);
// Probe fprintd for fingerprint availability
// Probe fprintd for fingerprint availability (cached device proxy, generation-guarded)
if fingerprint_enabled {
let username = user.username.clone();
glib::spawn_future_local(clone!(
@ -659,9 +724,50 @@ 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 {
let mut probe = crate::fingerprint::FingerprintProbe::new();
probe.init_async().await;
let available = probe.is_available_async(&username).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;
fp_label.set_visible(available);
if available {
@ -701,28 +807,40 @@ fn set_avatar_from_file(
Ok(_) => {}
}
let Some(path_str) = path.to_str() else {
log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display());
// 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"));
return;
};
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
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(name) = username {
state
if let Some(ref name) = username_owned {
state_clone
.borrow_mut()
.avatar_cache
.insert(name.to_string(), texture.clone());
.insert(name.clone(), texture.clone());
}
image.set_paintable(Some(&texture));
image_clone.set_paintable(Some(&texture));
}
Err(e) => {
log::debug!("Failed to load avatar {}: {e}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
log::debug!("Failed to decode avatar {}: {e}", display_path.display());
}
}
});
}
/// Load the default avatar SVG from GResources, tinted with the foreground color.
@ -834,15 +952,19 @@ fn extract_greetd_description<'a>(response: &'a serde_json::Value, fallback: &'a
.unwrap_or(fallback)
}
/// Display a greetd error, using a fallback for missing or oversized descriptions.
/// Display a greetd error. Logs raw PAM details at debug level,
/// shows only the generic fallback in the UI to avoid leaking system info.
fn show_greetd_error(
error_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
response: &serde_json::Value,
fallback: &str,
) {
let message = extract_greetd_description(response, fallback);
show_error(error_label, password_entry, message);
let raw = extract_greetd_description(response, fallback);
if raw != fallback {
log::debug!("greetd error detail: {raw}");
}
show_error(error_label, password_entry, fallback);
}
/// Cancel any in-progress greetd session.
@ -851,12 +973,12 @@ fn cancel_pending_session(state: &Rc<RefCell<GreeterState>>) {
let s = state.borrow();
s.login_cancelled
.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut sock_guard) = s.greetd_sock.lock() {
if let Some(sock) = sock_guard.take() {
if let Ok(mut sock_guard) = s.greetd_sock.lock()
&& let Some(sock) = sock_guard.take()
{
let _ = sock.shutdown(std::net::Shutdown::Both);
}
}
}
/// Set login controls sensitivity.
fn set_login_sensitive(
@ -872,7 +994,7 @@ fn set_login_sensitive(
#[allow(clippy::too_many_arguments)]
fn attempt_login(
user: &User,
password: &str,
password: Zeroizing<String>,
session: &Session,
strings: &'static Strings,
state: &Rc<RefCell<GreeterState>>,
@ -882,9 +1004,9 @@ fn attempt_login(
session_dropdown: &gtk::DropDown,
) {
log::debug!("Login attempt for user: {}", user.username);
let sock_path = match std::env::var("GREETD_SOCK") {
Ok(p) if !p.is_empty() => p,
_ => {
let sock_path = match state.borrow().greetd_sock_path.clone() {
Some(p) => p,
None => {
show_error(error_label, password_entry, strings.greetd_sock_not_set);
return;
}
@ -902,28 +1024,6 @@ fn attempt_login(
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
{
let s = state.borrow();
@ -933,7 +1033,6 @@ fn attempt_login(
set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone();
let password = password.to_string();
let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone();
@ -953,6 +1052,8 @@ fn attempt_login(
state,
async move {
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 || {
login_worker(
&username,
@ -966,6 +1067,18 @@ fn attempt_login(
)
})
.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 {
Ok(Ok(LoginResult::Success { username })) => {
@ -1030,6 +1143,7 @@ enum LoginResult {
}
/// Run greetd IPC in a background thread.
#[allow(clippy::too_many_arguments)]
fn login_worker(
username: &str,
password: &str,
@ -1076,8 +1190,11 @@ fn login_worker(
return Ok(LoginResult::Cancelled);
}
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
let message = extract_greetd_description(&response, strings.auth_failed).to_string();
return Ok(LoginResult::Error { message });
let raw = extract_greetd_description(&response, strings.auth_failed);
if raw != strings.auth_failed {
log::debug!("greetd error detail: {raw}");
}
return Ok(LoginResult::Error { message: strings.auth_failed.to_string() });
}
}
@ -1165,9 +1282,12 @@ fn login_worker(
username: username.to_string(),
});
} 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 {
message: extract_greetd_description(&response, strings.session_start_failed)
.to_string(),
message: strings.session_start_failed.to_string(),
});
}
}
@ -1190,7 +1310,7 @@ fn execute_power_action(
#[weak]
button,
async move {
let result = gio::spawn_blocking(move || action_fn()).await;
let result = gio::spawn_blocking(action_fn).await;
match result {
Ok(Ok(())) => {}
@ -1213,6 +1333,15 @@ fn execute_power_action(
// -- 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> {
load_last_user_from(Path::new(LAST_USER_PATH))
}
@ -1236,7 +1365,7 @@ fn save_last_user(username: &str) {
fn save_last_user_to(path: &Path, username: &str) {
log::debug!("Saving last user: {username}");
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
&& let Err(e) = create_cache_dir(parent)
{
log::warn!("Failed to create cache dir {}: {e}", parent.display());
return;
@ -1289,7 +1418,10 @@ fn save_last_session(username: &str, session_name: &str) {
return;
}
let dir = Path::new(LAST_SESSION_DIR);
let _ = std::fs::create_dir_all(dir);
if let Err(e) = create_cache_dir(dir) {
log::warn!("Failed to create session cache dir {}: {e}", dir.display());
return;
}
save_last_session_to(&dir.join(username), session_name);
}
@ -1884,4 +2016,55 @@ mod tests {
let resp = serde_json::json!({"type": "error"});
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,6 +4,7 @@
use std::env;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
@ -19,8 +20,6 @@ pub struct Strings {
pub no_session_selected: &'static str,
pub greetd_sock_not_set: &'static str,
pub greetd_sock_not_absolute: &'static str,
pub greetd_sock_not_socket: &'static str,
pub greetd_sock_unreachable: &'static str,
pub auth_failed: &'static str,
pub wrong_password: &'static str,
pub fingerprint_prompt: &'static str,
@ -43,8 +42,6 @@ const STRINGS_DE: Strings = Strings {
no_session_selected: "Keine Session ausgewählt",
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
greetd_sock_not_socket: "GREETD_SOCK zeigt nicht auf einen Socket",
greetd_sock_unreachable: "GREETD_SOCK nicht erreichbar",
auth_failed: "Authentifizierung fehlgeschlagen",
wrong_password: "Falsches Passwort",
fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
@ -65,8 +62,6 @@ const STRINGS_EN: Strings = Strings {
no_session_selected: "No session selected",
greetd_sock_not_set: "GREETD_SOCK not set",
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
greetd_sock_not_socket: "GREETD_SOCK does not point to a socket",
greetd_sock_unreachable: "GREETD_SOCK unreachable",
auth_failed: "Authentication failed",
wrong_password: "Wrong password",
fingerprint_prompt: "Place finger on reader or enter password",
@ -135,14 +130,17 @@ pub fn detect_locale() -> String {
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.
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
let locale = match locale {
Some(l) => l.to_string(),
None => detect_locale(),
Some(l) => l,
None => CACHED_LOCALE.get_or_init(detect_locale),
};
match locale.as_str() {
match locale {
"de" => &STRINGS_DE,
_ => &STRINGS_EN,
}
@ -288,6 +286,10 @@ mod tests {
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response");
assert!(!s.greetd_sock_not_absolute.is_empty(), "{locale}: greetd_sock_not_absolute");
assert!(!s.invalid_session_command.is_empty(), "{locale}: invalid_session_command");
assert!(!s.session_start_failed.is_empty(), "{locale}: session_start_failed");
assert!(!s.socket_error.is_empty(), "{locale}: socket_error");
}
}

View File

@ -11,16 +11,18 @@ 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");
gtk::style_context_add_provider_for_display(
display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
gtk::STYLE_PROVIDER_PRIORITY_USER,
);
}
@ -58,33 +60,59 @@ fn activate(app: &gtk::Application) {
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();
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 {
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
{
// One greeter window per monitor — only the first gets keyboard input
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 wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();
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;
}
}
// 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();
}
}
@ -99,10 +127,12 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
// 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,
};
log::set_max_level(level);
}

View File

@ -2,11 +2,18 @@
// ABOUTME: Wrappers around system commands for the greeter UI.
use std::fmt;
use std::process::Command;
use std::io::Read;
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)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
Timeout { action: &'static str },
}
impl fmt::Display for PowerError {
@ -15,41 +22,81 @@ impl fmt::Display for PowerError {
PowerError::CommandFailed { action, message } => {
write!(f, "{action} failed: {message}")
}
PowerError::Timeout { action } => {
write!(f, "{action} timed out")
}
}
}
}
impl std::error::Error for PowerError {}
/// Run a command and return a PowerError on failure.
/// Run a command with timeout 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> {
log::debug!("Power action: {action} ({program} {args:?})");
let child = Command::new(program)
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())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
let output = child
.wait_with_output()
.map_err(|e| PowerError::CommandFailed {
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
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(),
})?;
if output.status.success() {
log::debug!("Power action {action} completed successfully");
done.store(true, Ordering::Relaxed);
let _ = timeout_thread.join();
if status.success() {
log::debug!("Power action {action} completed");
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", output.status, stderr.trim()),
});
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if status.signal() == Some(9) {
return Err(PowerError::Timeout { action });
}
}
Ok(())
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,
message: format!("exit code {}: {}", status, stderr_buf.trim()),
})
}
}
/// Reboot the system via loginctl.
@ -75,6 +122,12 @@ mod tests {
assert_eq!(err.to_string(), "reboot failed: No such file or directory");
}
#[test]
fn power_error_timeout_display() {
let err = PowerError::Timeout { action: "shutdown" };
assert_eq!(err.to_string(), "shutdown timed out");
}
#[test]
fn run_command_returns_error_for_missing_binary() {
let result = run_command("test", "nonexistent-binary-xyz", &[]);
@ -99,7 +152,7 @@ mod tests {
#[test]
fn run_command_passes_args() {
let result = run_command("test", "true", &["--ignored-arg"]);
let result = run_command("test", "echo", &["hello", "world"]);
assert!(result.is_ok());
}
}

View File

@ -23,6 +23,8 @@ 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();
@ -36,15 +38,24 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
continue;
}
if let Some(value) = line.strip_prefix("Name=") {
if name.is_none() {
if let Some(value) = line.strip_prefix("Name=")
&& name.is_none()
{
name = Some(value.to_string());
}
} else if let Some(value) = line.strip_prefix("Exec=") {
if exec_cmd.is_none() {
} else if let Some(value) = line.strip_prefix("Exec=")
&& 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());

View File

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