Compare commits

...

23 Commits

Author SHA1 Message Date
nevaforget f6f33a13ab fix: audit fixes — power timeout, timing mitigation, release profile, GREETD_SOCK cache (v0.7.1)
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
nevaforget 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
nevaforget 1d557ea135 fix: audit fixes — password zeroize, blur downscale, symlink hardening, error filtering (v0.7.0)
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
nevaforget a2dc89854d fix: security hardening, blur geometry, and performance audit fixes (v0.6.2)
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
nevaforget f3f4db1ab1 ci: also update .SRCINFO in pkgver workflow
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
nevaforget a61fa4e145 ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-03-29 23:05:16 +02:00
nevaforget 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
nevaforget a462b2cf06 feat: add fprintd fingerprint authentication via greetd multi-stage PAM (v0.6.0)
Fingerprint auth was missing because moongreet rejected multi-stage
auth_message sequences from greetd. With pam_fprintd.so in the PAM
stack, greetd sends non-secret prompts for fingerprint and secret
prompts for password — moongreet now handles both in a loop.

- Replace single-pass auth with multi-stage auth_message loop
- fprintd D-Bus probe (gio::DBusProxy) for UI feedback only
- Fingerprint label shown when device available and fingers enrolled
- 60s socket timeout when fingerprint available (pam_fprintd scan time)
- Config option: [appearance] fingerprint-enabled (default: true)
- Fix: password entry focus loss after auth error (grab_focus while
  widget was insensitive — now re-enable before grab_focus)
2026-03-29 13:47:57 +02:00
nevaforget 77b94a560d fix: prevent edge darkening on GPU-blurred wallpaper (v0.5.3)
GskBlurNode samples pixels outside texture bounds as transparent,
causing visible darkening at wallpaper edges. Fix renders the texture
with 3x-sigma padding before blur, then clips back to original size.
Symmetric fix with moonset v0.7.1.
2026-03-28 23:28:39 +01:00
nevaforget b06b02faac refactor: remove embedded wallpaper from binary (v0.5.2)
Wallpaper is installed by moonarch to /usr/share/moonarch/wallpaper.jpg.
Embedding a 374K JPEG in the binary was redundant. Without a wallpaper
file, GTK background color (Catppuccin Mocha base) shows through and
wallpaper-only windows on secondary monitors are skipped.
2026-03-28 23:26:33 +01:00
nevaforget 9a89da8b13 docs: update for wallpaper removal from binary
Sync documentation with greetd-moongreet wallpaper removal.
2026-03-28 23:23:10 +01:00
nevaforget d5e431d37e fix: make setup_logging() resilient to journal logger failure (v0.5.1)
Replace unwrap() calls with match-based error handling that falls back
to eprintln — prevents panic when running outside a systemd session.
Consistent with moonlock's logging init pattern.
2026-03-28 22:56:39 +01:00
nevaforget 7c10516473 fix: re-audit findings — avatar path safety, persistence logging, tests
- Reject non-UTF-8 avatar paths early instead of passing empty string to GDK
- Log persistence write failures with warn! instead of silently discarding
- Reduce API surface: create_background_picture pub→fn
- Add boundary test for MAX_USERNAME_LENGTH and socket connect failure test
2026-03-28 22:47:21 +01:00
nevaforget 09371b5fd2 fix+perf: audit fixes and GPU blur migration (v0.5.0)
Address all findings from quality, performance, and security audits:
- Filter greetd error descriptions consistently (security)
- Re-enable power buttons after failed action (UX bug)
- Narrow TOCTOU window in avatar loading via symlink_metadata (security)
- Allow @ in usernames for LDAP compatibility
- Eliminate unnecessary Vec allocation in passwd parsing
- Remove dead i18n field, annotate retained-for-future struct fields
- Fix if/if→if/else and noisy test output in power.rs

Replace CPU blur (image crate + disk cache + async orchestration) with
GPU blur via GskBlurNode — symmetric with moonlock and moonset.
Removes ~15 transitive dependencies and ~200 lines of caching code.
2026-03-28 22:34:12 +01:00
nevaforget 3c39467508 perf: cache blurred wallpaper to disk to avoid re-blur on startup
First launch with blur blurs and saves to /var/cache/moongreet/.
Subsequent starts load the cached PNG directly. Cache invalidates
when wallpaper path, size, mtime, or sigma changes.
2026-03-28 21:23:36 +01:00
nevaforget 64470f99c3 chore: bump version to 0.4.0 2026-03-28 14:55:18 +01:00
nevaforget 293bba32a6 feat: add optional background blur via image crate
Gaussian blur applied at texture load time when `background-blur` is
set in the [appearance] section of moongreet.toml. Blur runs once,
result is shared across monitors.
2026-03-28 14:53:16 +01:00
nevaforget 14d6476e5a fix: audit findings — wallpaper safety, log filtering, error truncation (v0.4.1)
- Rework load_background_texture(): use resources_lookup_data()/from_bytes()
  for GResource path (no abort on missing resource), add 50 MB file size limit,
  handle non-UTF-8 paths gracefully
- Filter error details to debug level only — warn! logs without internal details
  to prevent system info leaking into journal
- Make debug logging opt-in via MOONGREET_DEBUG env var (default: Info)
- Truncate greetd error description in stale-session retry path using
  MAX_GREETD_ERROR_LENGTH (matching show_greetd_error())
- Add 3 unit tests for load_background_texture edge cases
2026-03-28 10:29:21 +01:00
nevaforget 4c9b436978 fix: wallpaper windows on Layer::Bottom to prevent greeter occlusion
Wallpaper-only windows for secondary monitors were on Layer::Top — same
layer as the greeter window. Since they were created after the greeter,
they occluded the login UI, leaving only the wallpaper visible.
2026-03-28 01:30:22 +01:00
nevaforget 96c94f030a feat: switch to systemd-journal-logger, add debug logging (v0.4.0)
Replace env_logger file-based logging with systemd-journal-logger for
consistency with moonlock and native journalctl integration. Add debug-level
logging at all decision points: config loading, user/session detection,
avatar resolution, locale detection, IPC messages, login flow, and
persistence. No credentials are ever logged.
2026-03-28 01:23:18 +01:00
nevaforget b91e8d47d1 docs: update CLAUDE.md for v0.3.2 audit changes 2026-03-28 00:43:00 +01:00
nevaforget 5db23937ea chore: bump version to 0.3.2 2026-03-28 00:37:51 +01:00
nevaforget 0d4a1b035a fix: audit findings — security, i18n, validation, dead code (v0.3.2)
Quality:
- Q-5: Allow relative session commands (e.g. niri-session), greetd resolves PATH
- Q-3: Socket read+write timeouts with proper error logging
- Q-2: Remove unused PowerError::Timeout variant
- Q-M1: i18n for all login_worker error messages (new: unexpected_greetd_response)
- Q-M2: Explicit INVALID_LIST_POSITION check in session dropdown
- Q-M4: Log SVG loader.close() errors instead of silencing
- Q-M6: Testable persistence functions with proper roundtrip tests

Security:
- S-2: Validate GTK theme name (alphanumeric, _, -, +, . only)
- S-3: Log file created with mode 0o640
- S-4: Cache files (last-user, last-session) created with mode 0o600

Performance:
- P-3: Single symlink_metadata() call instead of exists() + is_symlink()
- P-4: Avoid Vec allocation in IPC send_message (two write_all calls)

Config:
- Update example GTK theme to Colloid-Catppuccin
2026-03-28 00:37:35 +01:00
21 changed files with 1402 additions and 464 deletions
+43
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
+23 -8
View File
@@ -17,8 +17,9 @@ Teil des Moonarch-Ökosystems.
## Projektstruktur ## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs) - `src/` — Rust-Quellcode (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg) - `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien für `/etc/moongreet/` und `/etc/greetd/` - `config/` — Beispiel-Konfigurationsdateien für `/etc/moongreet/` und `/etc/greetd/`
- `pkg/` — PKGBUILD für Arch-Linux-Paketierung (`makepkg -sf`)
## Kommandos ## Kommandos
@@ -29,8 +30,11 @@ cargo test
# Release-Build # Release-Build
cargo build --release cargo build --release
# Greeter starten (nur zum Testen, braucht normalerweise greetd) # Greeter im Fenster starten (ohne greetd/Layer Shell)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
# Paket bauen und installieren
cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.zst
``` ```
## Architektur ## Architektur
@@ -39,10 +43,11 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
- `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection - `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection
- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files - `sessions.rs` — Wayland/X11 Sessions aus .desktop Files
- `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) - `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
- `config.rs` — TOML-Config ([appearance] background, gtk-theme) + Wallpaper-Fallback - `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
- `greeter.rs`GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC, Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence - `config.rs`TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200)
- `main.rs`Entry Point, GTK App, Layer Shell Setup, Multi-Monitor - `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, systemd-journal-logger
- `resources/style.css` — Catppuccin-inspiriertes Theme - `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions ## Design Decisions
@@ -52,4 +57,14 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
- **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>>`
- **Symmetrie mit moonset**: Gleiche Patterns (i18n, config, users, power, GResource) - **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-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
- **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor
- **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade → `None`
- **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal
Generated
+36 -173
View File
@@ -2,65 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -119,33 +60,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "colorchoice" name = "cfg_aliases"
version = "1.0.5" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
@@ -604,42 +522,12 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "khronos_api" name = "khronos_api"
version = "3.1.0" version = "3.1.0"
@@ -687,21 +575,36 @@ dependencies = [
[[package]] [[package]]
name = "moongreet" name = "moongreet"
version = "0.3.0" version = "0.7.1"
dependencies = [ dependencies = [
"env_logger",
"gdk-pixbuf", "gdk-pixbuf",
"gdk4", "gdk4",
"gio", "gio",
"glib", "glib",
"glib-build-tools", "glib-build-tools",
"graphene-rs",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"log", "log",
"nix",
"serde", "serde",
"serde_json", "serde_json",
"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]]
@@ -710,12 +613,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.22.0" version = "0.22.0"
@@ -752,21 +649,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -810,35 +692,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -964,6 +817,16 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "systemd-journal-logger"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
dependencies = [
"log",
"rustix",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.3" version = "0.13.3"
@@ -1096,12 +959,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -1287,6 +1144,12 @@ 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"
+10 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moongreet" name = "moongreet"
version = "0.3.1" version = "0.7.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"
@@ -15,11 +15,19 @@ gio = "0.22"
toml = "0.8" 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" }
nix = { version = "0.29", features = ["signal"] }
zeroize = { version = "1", features = ["std"] }
log = "0.4" log = "0.4"
env_logger = "0.11" 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"
+49
View File
@@ -0,0 +1,49 @@
# Decisions
## 2026-03-31 Fourth audit: power timeout, timing mitigation, release profile, GREETD_SOCK caching
- **Who**: Ragnar, 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**: Ragnar, 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
- **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
- **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
- **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
- **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
- **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()`
+6 -2
View File
@@ -13,8 +13,12 @@ Part of the Moonarch ecosystem.
- **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** — Greeter on primary, wallpaper on all monitors - **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)
- **Journal logging** — `journalctl -t moongreet`, debug level via `MOONGREET_DEBUG` env var
- **Password wiping** — Zeroize on drop
## Requirements ## Requirements
@@ -65,8 +69,8 @@ cargo test
# Build release # Build release
cargo build --release cargo build --release
# Run locally (without greetd, needs LD_PRELOAD for layer-shell) # Run locally (without greetd, disables layer-shell)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
``` ```
## License ## License
+1 -1
View File
@@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle. // 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() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(
+1 -1
View File
@@ -5,4 +5,4 @@
# 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 for the greeter UI
gtk-theme = "catppuccin-mocha-lavender-standard+default" gtk-theme = "Colloid-Grey-Dark-Catppuccin"
+1 -1
View File
@@ -4,7 +4,7 @@
# Maintainer: Dominik Kressler # Maintainer: Dominik Kressler
pkgname=moongreet-git pkgname=moongreet-git
pkgver=0.3.0.r0.g0000000 pkgver=0.4.0.r7.g77b94a5
pkgrel=1 pkgrel=1
pkgdesc="A greetd greeter for Wayland with GTK4 and Layer Shell" pkgdesc="A greetd greeter for Wayland with GTK4 and Layer Shell"
arch=('x86_64') arch=('x86_64')
+2 -3
View File
@@ -1,8 +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>style.css</file> <file compressed="true">style.css</file>
<file>wallpaper.jpg</file> <file compressed="true">default-avatar.svg</file>
<file>default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+8 -1
View File
@@ -22,7 +22,7 @@ window.wallpaper {
/* Round avatar image — size is set via set_size_request() in code */ /* Round avatar image — size is set via set_size_request() in code */
.avatar { .avatar {
border-radius: 50%; border-radius: 9999px;
min-width: 128px; min-width: 128px;
min-height: 128px; min-height: 128px;
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
@@ -54,6 +54,13 @@ window.wallpaper {
font-size: 14px; font-size: 14px;
} }
/* Fingerprint prompt label */
.fingerprint-label {
color: alpha(white, 0.6);
font-size: 13px;
margin-top: 8px;
}
/* User list on the bottom left */ /* User list on the bottom left */
.user-list { .user-list {
background-color: transparent; background-color: transparent;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

+146 -32
View File
@@ -6,7 +6,6 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg"; const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet";
/// Default config search path: system-wide config. /// Default config search path: system-wide config.
fn default_config_paths() -> Vec<PathBuf> { fn default_config_paths() -> Vec<PathBuf> {
@@ -22,15 +21,32 @@ struct TomlConfig {
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
struct Appearance { struct Appearance {
background: Option<String>, background: Option<String>,
#[serde(rename = "background-blur")]
background_blur: Option<f32>,
#[serde(rename = "gtk-theme")] #[serde(rename = "gtk-theme")]
gtk_theme: Option<String>, gtk_theme: Option<String>,
#[serde(rename = "fingerprint-enabled")]
fingerprint_enabled: Option<bool>,
} }
/// Greeter configuration. /// Greeter configuration.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub background_path: Option<String>, pub background_path: Option<String>,
pub background_blur: Option<f32>,
pub gtk_theme: Option<String>, pub gtk_theme: Option<String>,
pub fingerprint_enabled: bool,
}
impl Default for Config {
fn default() -> Self {
Config {
background_path: None,
background_blur: None,
gtk_theme: None,
fingerprint_enabled: true,
}
}
} }
/// Load config from TOML files. Later paths override earlier ones. /// Load config from TOML files. Later paths override earlier ones.
@@ -40,54 +56,81 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default(); let mut merged = Config::default();
for path in paths { for path in paths {
if let Ok(content) = fs::read_to_string(path) { match fs::read_to_string(path) {
if let Ok(parsed) = toml::from_str::<TomlConfig>(&content) { Ok(content) => {
if let Some(appearance) = parsed.appearance { match toml::from_str::<TomlConfig>(&content) {
if let Some(bg) = appearance.background { Ok(parsed) => {
// Resolve relative paths against config file directory log::debug!("Config loaded: {}", path.display());
let bg_path = PathBuf::from(&bg); if let Some(appearance) = parsed.appearance {
if bg_path.is_absolute() { if let Some(bg) = appearance.background {
merged.background_path = Some(bg); // Resolve relative paths against config file directory
} else if let Some(parent) = path.parent() { let bg_path = PathBuf::from(&bg);
merged.background_path = if bg_path.is_absolute() {
Some(parent.join(&bg).to_string_lossy().to_string()); merged.background_path = Some(bg);
} else if let Some(parent) = path.parent() {
merged.background_path =
Some(parent.join(&bg).to_string_lossy().to_string());
}
}
if 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;
}
if let Some(fp) = appearance.fingerprint_enabled {
merged.fingerprint_enabled = fp;
}
} }
} }
if appearance.gtk_theme.is_some() { Err(e) => {
merged.gtk_theme = appearance.gtk_theme; log::warn!("Config parse error in {}: {e}", path.display());
} }
} }
} }
Err(_) => {
log::debug!("Config not found: {}", path.display());
}
} }
} }
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}, fingerprint={}", merged.background_path, merged.background_blur, merged.gtk_theme, merged.fingerprint_enabled);
merged merged
} }
/// Resolve the wallpaper path using the fallback hierarchy. /// Resolve the wallpaper path using the fallback hierarchy.
/// ///
/// Priority: config background_path > Moonarch system default > gresource fallback. /// Priority: config background_path > Moonarch system default > None (GTK background color).
pub fn resolve_background_path(config: &Config) -> PathBuf { pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
} }
/// 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) -> PathBuf { 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 { if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg); let path = PathBuf::from(bg);
if path.is_file() { if let Ok(meta) = path.symlink_metadata() {
return path; 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 usable, trying fallbacks", path.display());
} }
// Moonarch ecosystem default // Moonarch ecosystem default
if moonarch_wallpaper.is_file() { if moonarch_wallpaper.is_file() {
return moonarch_wallpaper.to_path_buf(); log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
} }
// GResource fallback path (loaded from compiled resources at runtime) log::debug!("Wallpaper: no wallpaper found, using GTK background color");
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg")) None
} }
#[cfg(test)] #[cfg(test)]
@@ -98,7 +141,9 @@ mod tests {
fn default_config_has_none_fields() { fn default_config_has_none_fields() {
let config = Config::default(); let config = Config::default();
assert!(config.background_path.is_none()); assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
assert!(config.gtk_theme.is_none()); assert!(config.gtk_theme.is_none());
assert!(config.fingerprint_enabled);
} }
#[test] #[test]
@@ -115,7 +160,7 @@ mod tests {
let conf = dir.path().join("moongreet.toml"); let conf = dir.path().join("moongreet.toml");
fs::write( fs::write(
&conf, &conf,
"[appearance]\nbackground = \"/custom/wallpaper.jpg\"\ngtk-theme = \"catppuccin\"\n", "[appearance]\nbackground = \"/custom/wallpaper.jpg\"\nbackground-blur = 20.0\ngtk-theme = \"catppuccin\"\n",
) )
.unwrap(); .unwrap();
let paths = vec![conf]; let paths = vec![conf];
@@ -124,9 +169,20 @@ mod tests {
config.background_path.as_deref(), config.background_path.as_deref(),
Some("/custom/wallpaper.jpg") Some("/custom/wallpaper.jpg")
); );
assert_eq!(config.background_blur, Some(20.0));
assert_eq!(config.gtk_theme.as_deref(), Some("catppuccin")); assert_eq!(config.gtk_theme.as_deref(), Some("catppuccin"));
} }
#[test]
fn load_config_blur_optional() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\nbackground = \"/bg.jpg\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert!(config.background_blur.is_none());
}
#[test] #[test]
fn load_config_resolves_relative_background() { fn load_config_resolves_relative_background() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -180,11 +236,11 @@ mod tests {
fs::write(&wallpaper, "fake").unwrap(); fs::write(&wallpaper, "fake").unwrap();
let config = Config { let config = Config {
background_path: Some(wallpaper.to_str().unwrap().to_string()), background_path: Some(wallpaper.to_str().unwrap().to_string()),
gtk_theme: None, ..Config::default()
}; };
assert_eq!( assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")), resolve_background_path_with(&config, Path::new("/nonexistent")),
wallpaper Some(wallpaper)
); );
} }
@@ -192,10 +248,10 @@ mod tests {
fn resolve_ignores_config_path_when_file_missing() { fn resolve_ignores_config_path_when_file_missing() {
let config = Config { let config = Config {
background_path: Some("/nonexistent/wallpaper.jpg".to_string()), background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
gtk_theme: None, ..Config::default()
}; };
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("moongreet")); assert!(result.is_none());
} }
#[test] #[test]
@@ -206,14 +262,72 @@ mod tests {
let config = Config::default(); let config = Config::default();
assert_eq!( assert_eq!(
resolve_background_path_with(&config, &moonarch_wp), resolve_background_path_with(&config, &moonarch_wp),
moonarch_wp Some(moonarch_wp)
); );
} }
#[test] #[test]
fn resolve_uses_gresource_fallback_as_last_resort() { fn resolve_returns_none_when_no_wallpaper_found() {
let config = Config::default(); let config = Config::default();
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg")); assert!(result.is_none());
}
#[test]
fn load_config_fingerprint_enabled_default_true() {
let paths = vec![PathBuf::from("/nonexistent/moongreet.toml")];
let config = load_config(Some(&paths));
assert!(config.fingerprint_enabled);
}
#[test]
fn load_config_fingerprint_disabled() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\nfingerprint-enabled = false\n").unwrap();
let paths = vec![conf];
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());
} }
} }
+142
View File
@@ -0,0 +1,142 @@
// ABOUTME: fprintd D-Bus probe for fingerprint device availability.
// ABOUTME: Checks if fprintd is running and the user has enrolled fingerprints.
use gio::prelude::*;
use gtk4::gio;
const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint";
const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager";
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).
pub struct FingerprintProbe {
device_proxy: Option<gio::DBusProxy>,
}
impl FingerprintProbe {
/// Create a probe without any D-Bus connections.
/// Call `init_async().await` to connect to fprintd.
pub fn new() -> Self {
FingerprintProbe {
device_proxy: None,
}
}
/// Connect to fprintd on the system bus and discover the default device.
pub async fn init_async(&mut self) {
let manager = match gio::DBusProxy::for_bus_future(
gio::BusType::System,
gio::DBusProxyFlags::NONE,
None,
FPRINTD_BUS_NAME,
FPRINTD_MANAGER_PATH,
FPRINTD_MANAGER_IFACE,
)
.await
{
Ok(m) => m,
Err(e) => {
log::debug!("fprintd manager not available: {e}");
return;
}
};
let result = match manager
.call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await
{
Ok(r) => r,
Err(e) => {
log::debug!("fprintd GetDefaultDevice failed: {e}");
return;
}
};
let device_path = match result.child_value(0).get::<String>() {
Some(p) => p,
None => {
log::debug!("fprintd: unexpected GetDefaultDevice response type");
return;
}
};
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,
gio::DBusProxyFlags::NONE,
None,
FPRINTD_BUS_NAME,
&device_path,
FPRINTD_DEVICE_IFACE,
)
.await
{
Ok(proxy) => {
self.device_proxy = Some(proxy);
}
Err(e) => {
log::debug!("fprintd device proxy failed: {e}");
}
}
}
/// Check if the user has enrolled fingerprints on the default device.
/// Returns false if fprintd is unavailable or the user has no enrollments.
pub async fn is_available_async(&self, username: &str) -> bool {
let proxy = match &self.device_proxy {
Some(p) => p,
None => return false,
};
let args = glib::Variant::from((&username,));
match proxy
.call_future(
"ListEnrolledFingers",
Some(&args),
gio::DBusCallFlags::NONE,
DBUS_TIMEOUT_MS,
)
.await
{
Ok(result) => match result.child_value(0).get::<Vec<String>>() {
Some(fingers) => !fingers.is_empty(),
None => {
log::debug!("fprintd: unexpected ListEnrolledFingers response type");
false
}
},
Err(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_probe_has_no_device() {
let probe = FingerprintProbe::new();
assert!(probe.device_proxy.is_none());
}
#[test]
fn constants_are_defined() {
assert!(!FPRINTD_BUS_NAME.is_empty());
assert!(!FPRINTD_MANAGER_PATH.is_empty());
assert!(!FPRINTD_MANAGER_IFACE.is_empty());
assert!(!FPRINTD_DEVICE_IFACE.is_empty());
assert!(DBUS_TIMEOUT_MS > 0);
}
}
+747 -141
View File
File diff suppressed because it is too large Load Diff
+23 -18
View File
@@ -4,6 +4,7 @@
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";
@@ -19,17 +20,15 @@ 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 multi_stage_unsupported: &'static str, pub fingerprint_prompt: &'static str,
pub invalid_session_command: &'static str, pub invalid_session_command: &'static str,
pub session_start_failed: &'static str, pub session_start_failed: &'static str,
pub reboot_failed: &'static str, pub reboot_failed: &'static str,
pub shutdown_failed: &'static str, pub shutdown_failed: &'static str,
pub connection_error: &'static str,
pub socket_error: &'static str, pub socket_error: &'static str,
pub unexpected_greetd_response: &'static str,
// Templates (use .replace("{n}", &count.to_string())) // Templates (use .replace("{n}", &count.to_string()))
pub faillock_attempts_remaining: &'static str, pub faillock_attempts_remaining: &'static str,
@@ -43,17 +42,15 @@ 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",
multi_stage_unsupported: "Mehrstufige Authentifizierung wird nicht unterstützt", fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
invalid_session_command: "Ungültiger Session-Befehl", invalid_session_command: "Ungültiger Session-Befehl",
session_start_failed: "Session konnte nicht gestartet werden", session_start_failed: "Session konnte nicht gestartet werden",
reboot_failed: "Neustart fehlgeschlagen", reboot_failed: "Neustart fehlgeschlagen",
shutdown_failed: "Herunterfahren fehlgeschlagen", shutdown_failed: "Herunterfahren fehlgeschlagen",
connection_error: "Verbindungsfehler",
socket_error: "Socket-Fehler", socket_error: "Socket-Fehler",
unexpected_greetd_response: "Unerwartete Antwort von greetd",
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!", faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked: "Konto ist möglicherweise gesperrt", faillock_locked: "Konto ist möglicherweise gesperrt",
}; };
@@ -65,17 +62,15 @@ 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",
multi_stage_unsupported: "Multi-stage authentication is not supported", fingerprint_prompt: "Place finger on reader or enter password",
invalid_session_command: "Invalid session command", invalid_session_command: "Invalid session command",
session_start_failed: "Failed to start session", session_start_failed: "Failed to start session",
reboot_failed: "Reboot failed", reboot_failed: "Reboot failed",
shutdown_failed: "Shutdown failed", shutdown_failed: "Shutdown failed",
connection_error: "Connection error",
socket_error: "Socket error", socket_error: "Socket error",
unexpected_greetd_response: "Unexpected response from greetd",
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!", faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
faillock_locked: "Account may be locked", faillock_locked: "Account may be locked",
}; };
@@ -124,20 +119,28 @@ pub fn detect_locale() -> String {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); .or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
match lang { let result = match lang {
Some(l) => parse_lang_prefix(&l), Some(ref l) => parse_lang_prefix(l),
None => "en".to_string(), None => "en".to_string(),
} };
log::debug!("Detected locale: {result} (source: {})", match lang {
Some(_) => "LANG env or locale.conf",
None => "default",
});
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.to_string(), Some(l) => l,
None => detect_locale(), None => CACHED_LOCALE.get_or_init(detect_locale),
}; };
match locale.as_str() { match locale {
"de" => &STRINGS_DE, "de" => &STRINGS_DE,
_ => &STRINGS_EN, _ => &STRINGS_EN,
} }
@@ -277,10 +280,12 @@ mod tests {
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set"); assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed"); assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
assert!(!s.wrong_password.is_empty(), "{locale}: wrong_password"); assert!(!s.wrong_password.is_empty(), "{locale}: wrong_password");
assert!(!s.fingerprint_prompt.is_empty(), "{locale}: fingerprint_prompt");
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed"); assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed");
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed"); assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed");
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");
} }
} }
+7 -5
View File
@@ -75,7 +75,6 @@ fn recv_payload(stream: &mut UnixStream, n: usize) -> Result<Vec<u8>, IpcError>
} }
/// Send a length-prefixed JSON message to the greetd socket. /// Send a length-prefixed JSON message to the greetd socket.
/// Header and payload are sent in a single write for atomicity.
pub fn send_message( pub fn send_message(
stream: &mut UnixStream, stream: &mut UnixStream,
msg: &serde_json::Value, msg: &serde_json::Value,
@@ -85,11 +84,12 @@ pub fn send_message(
return Err(IpcError::PayloadTooLarge(payload.len())); return Err(IpcError::PayloadTooLarge(payload.len()));
} }
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
log::debug!("IPC send: type={msg_type}, size={} bytes", payload.len());
let header = (payload.len() as u32).to_le_bytes(); let header = (payload.len() as u32).to_le_bytes();
let mut buf = Vec::with_capacity(4 + payload.len()); stream.write_all(&header)?;
buf.extend_from_slice(&header); stream.write_all(&payload)?;
buf.extend_from_slice(&payload);
stream.write_all(&buf)?;
Ok(()) Ok(())
} }
@@ -104,6 +104,8 @@ pub fn recv_message(stream: &mut UnixStream) -> Result<serde_json::Value, IpcErr
let payload = recv_payload(stream, length)?; let payload = recv_payload(stream, length)?;
let value: serde_json::Value = serde_json::from_slice(&payload)?; let value: serde_json::Value = serde_json::from_slice(&payload)?;
let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
log::debug!("IPC recv: type={msg_type}, size={length} bytes");
Ok(value) Ok(value)
} }
+35 -26
View File
@@ -2,6 +2,7 @@
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows. // ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
mod config; mod config;
mod fingerprint;
mod greeter; mod greeter;
mod i18n; mod i18n;
mod ipc; mod ipc;
@@ -13,21 +14,19 @@ use gdk4 as gdk;
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::path::PathBuf;
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");
gtk::style_context_add_provider_for_display( gtk::style_context_add_provider_for_display(
display, display,
&css_provider, &css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk::STYLE_PROVIDER_PRIORITY_USER,
); );
} }
fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool) { fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) {
window.init_layer_shell(); window.init_layer_shell();
window.set_layer(gtk4_layer_shell::Layer::Top); window.set_layer(layer);
window.set_exclusive_zone(-1); window.set_exclusive_zone(-1);
if keyboard { if keyboard {
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive); window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
@@ -48,31 +47,42 @@ fn activate(app: &gtk::Application) {
} }
}; };
log::debug!("Display: {:?}", display);
load_css(&display); load_css(&display);
// Load config and resolve wallpaper // Load config and resolve wallpaper
let config = config::load_config(None); let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config); let bg_texture = config::resolve_background_path(&config)
.and_then(|path| {
log::debug!("Background path: {}", path.display());
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}");
// Main greeter window (login UI) — compositor picks focused monitor // Main greeter window (login UI) — compositor picks focused monitor
let greeter_window = greeter::create_greeter_window(&bg_path, &config, app); let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
if use_layer_shell { if use_layer_shell {
setup_layer_shell(&greeter_window, true); setup_layer_shell(&greeter_window, true, gtk4_layer_shell::Layer::Top);
} }
greeter_window.present(); greeter_window.present();
// Wallpaper-only windows on all monitors (only with layer shell) // Wallpaper-only windows on all monitors (only with layer shell)
if use_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());
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 wallpaper = greeter::create_wallpaper_window(&bg_path, app); let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, &blur_cache, app);
setup_layer_shell(&wallpaper, false); setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
wallpaper.set_monitor(Some(&monitor)); wallpaper.set_monitor(Some(&monitor));
wallpaper.present(); wallpaper.present();
} }
@@ -81,23 +91,22 @@ fn activate(app: &gtk::Application) {
} }
fn setup_logging() { fn setup_logging() {
let mut builder = env_logger::Builder::from_default_env(); match systemd_journal_logger::JournalLog::new() {
builder.filter_level(log::LevelFilter::Info); Ok(logger) => {
if let Err(e) = logger.install() {
// Try file logging to /var/cache/moongreet/ — fall back to stderr eprintln!("Failed to install journal logger: {e}");
let log_dir = PathBuf::from("/var/cache/moongreet"); }
if log_dir.is_dir() { }
let log_file = log_dir.join("moongreet.log"); Err(e) => {
if let Ok(file) = std::fs::OpenOptions::new() eprintln!("Failed to create journal logger: {e}");
.create(true)
.append(true)
.open(&log_file)
{
builder.target(env_logger::Target::Pipe(Box::new(file)));
} }
} }
let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
builder.init(); log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_max_level(level);
} }
fn main() { fn main() {
+60 -16
View File
@@ -2,7 +2,13 @@
// 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::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)] #[derive(Debug)]
pub enum PowerError { pub enum PowerError {
@@ -25,32 +31,70 @@ impl fmt::Display for PowerError {
impl std::error::Error for PowerError {} 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> { fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
let child = Command::new(program) log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program)
.args(args) .args(args)
.stdout(Stdio::piped())
.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 output = child let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
.wait_with_output() let done = Arc::new(AtomicBool::new(false));
.map_err(|e| PowerError::CommandFailed { let done_clone = done.clone();
action,
message: e.to_string(),
})?;
if !output.status.success() { let timeout_thread = std::thread::spawn(move || {
let stderr = String::from_utf8_lossy(&output.stderr); let interval = Duration::from_millis(100);
return Err(PowerError::CommandFailed { 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 {}: {}", output.status, stderr.trim()), message: format!("exit code {}: {}", status, stderr_buf.trim()),
}); })
} }
Ok(())
} }
/// Reboot the system via loginctl. /// Reboot the system via loginctl.
+25 -11
View File
@@ -12,6 +12,7 @@ const DEFAULT_XSESSION_DIRS: &[&str] = &["/usr/share/xsessions"];
pub struct Session { pub struct Session {
pub name: String, pub name: String,
pub exec_cmd: String, pub exec_cmd: String,
#[allow(dead_code)] // Retained for future Wayland-only filtering
pub session_type: String, pub session_type: String,
} }
@@ -35,19 +36,28 @@ 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=")
if name.is_none() { && 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=")
if exec_cmd.is_none() { && exec_cmd.is_none()
exec_cmd = Some(value.to_string()); {
} exec_cmd = Some(value.to_string());
} }
} }
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());
if name.is_none() || exec_cmd.is_none() {
log::debug!("Skipping {}: missing Name={} Exec={}", path.display(),
name.is_some(), exec_cmd.is_some());
return None;
}
let name = name?;
let exec_cmd = exec_cmd?;
Some(Session { Some(Session {
name, name,
@@ -74,7 +84,10 @@ pub fn get_sessions(
for (dirs, session_type) in [(wayland, "wayland"), (xsession, "x11")] { for (dirs, session_type) in [(wayland, "wayland"), (xsession, "x11")] {
for directory in dirs { for directory in dirs {
let entries = match fs::read_dir(directory) { let entries = match fs::read_dir(directory) {
Ok(e) => e, Ok(e) => {
log::debug!("Scanning session directory: {}", directory.display());
e
}
Err(_) => continue, Err(_) => continue,
}; };
@@ -93,6 +106,7 @@ pub fn get_sessions(
} }
} }
log::debug!("Found {} session(s)", sessions.len());
sessions sessions
} }
+37 -23
View File
@@ -23,9 +23,11 @@ const NOLOGIN_SHELLS: &[&str] = &[
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct User { pub struct User {
pub username: String, pub username: String,
#[allow(dead_code)] // Retained for debugging and future UID-based features
pub uid: u32, pub uid: u32,
pub gecos: String, pub gecos: String,
pub home: PathBuf, pub home: PathBuf,
#[allow(dead_code)] // Retained for debugging and future shell-based filtering
pub shell: String, pub shell: String,
} }
@@ -46,29 +48,29 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
let content = match fs::read_to_string(path) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
Err(_) => return Vec::new(), Err(e) => {
log::warn!("Failed to read passwd file {}: {e}", path.display());
return Vec::new();
}
}; };
let mut users = Vec::new(); let mut users = Vec::new();
for line in content.lines() { for line in content.lines() {
let parts: Vec<&str> = line.split(':').collect(); let mut fields = line.splitn(7, ':');
if parts.len() < 7 { let (Some(username), Some(_pw), Some(uid_str), Some(_gid), Some(gecos), Some(home), Some(shell)) =
(fields.next(), fields.next(), fields.next(), fields.next(),
fields.next(), fields.next(), fields.next())
else {
continue; continue;
} };
let username = parts[0];
let uid_str = parts[2];
let gecos = parts[4];
let home = parts[5];
let shell = parts[6];
let uid = match uid_str.parse::<u32>() { let uid = match uid_str.parse::<u32>() {
Ok(u) => u, Ok(u) => u,
Err(_) => continue, Err(_) => continue,
}; };
if uid < MIN_UID || uid > MAX_UID { if !(MIN_UID..=MAX_UID).contains(&uid) {
continue; continue;
} }
if NOLOGIN_SHELLS.contains(&shell) { if NOLOGIN_SHELLS.contains(&shell) {
@@ -88,10 +90,11 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
}); });
} }
log::debug!("Found {} login user(s)", users.len());
users 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. /// 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))
@@ -103,20 +106,31 @@ pub fn get_avatar_path_with(
home: &Path, home: &Path,
accountsservice_dir: &Path, accountsservice_dir: &Path,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
// AccountsService icon takes priority // ~/.face takes priority (consistent with moonlock/moonset)
if accountsservice_dir.exists() { let face = home.join(".face");
let icon = accountsservice_dir.join(username); if let Ok(meta) = face.symlink_metadata() {
if icon.exists() && !icon.is_symlink() { if meta.file_type().is_symlink() {
return Some(icon); 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);
} }
} }
// ~/.face fallback // AccountsService icon fallback
let face = home.join(".face"); if accountsservice_dir.exists() {
if face.exists() && !face.is_symlink() { let icon = accountsservice_dir.join(username);
return Some(face); if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
} else if meta.is_file() {
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
return Some(icon);
}
}
} }
log::debug!("No avatar found for {username}");
None None
} }
@@ -234,7 +248,7 @@ mod tests {
} }
#[test] #[test]
fn accountsservice_icon_takes_priority() { fn face_file_takes_priority_over_accountsservice() {
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();
@@ -247,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(icon)); assert_eq!(path, Some(face));
} }
#[test] #[test]