Compare commits

...

17 Commits

Author SHA1 Message Date
nevaforget 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
nevaforget 874888391e docs: rename Ragnar to ClaudeCode in DECISIONS.md
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-04-02 10:13:34 +02:00
nevaforget 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
nevaforget 183e10c1cc Remove unnecessary pacman git install from CI workflow
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
nevaforget 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
nevaforget cf18105887 Revert CI workaround: remove pacman install step
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
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
20 changed files with 927 additions and 245 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
+7 -5
View File
@@ -17,7 +17,7 @@ Teil des Moonarch-Ökosystems.
## Projektstruktur
- `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/`
- `pkg/` — PKGBUILD für Arch-Linux-Paketierung (`makepkg -sf`)
@@ -44,8 +44,9 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files
- `power.rs` — Reboot/Shutdown via loginctl
- `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
- `greeter.rs`GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC, Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o600 Permissions)
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
- `config.rs`TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200)
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-journal-logger
- `resources/style.css` — Catppuccin-inspiriertes Theme
@@ -56,12 +57,13 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
- **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
- **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`
Generated
+27 -1
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.0"
version = "0.7.1"
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"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moongreet"
version = "0.5.1"
version = "0.7.3"
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"
+43 -1
View File
@@ -1,8 +1,50 @@
# Decisions
## 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**: 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
- **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.
+6 -2
View File
@@ -13,8 +13,12 @@ Part of the Moonarch ecosystem.
- **Power actions** — Reboot / Shutdown via `loginctl`
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
- **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)
- **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
@@ -65,8 +69,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
+1 -1
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(
+4 -2
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"
+1 -1
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')
+2 -3
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/dev/moonarch/moongreet">
<file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
<file compressed="true">style.css</file>
<file compressed="true">default-avatar.svg</file>
</gresource>
</gresources>
+20 -13
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,10 +50,17 @@ window.wallpaper {
/* Error message label */
.error-label {
color: #ff6b6b;
color: @error_color;
font-size: 14px;
}
/* Fingerprint prompt label */
.fingerprint-label {
color: alpha(@theme_fg_color, 0.6);
font-size: 13px;
margin-top: 8px;
}
/* User list on the bottom left */
.user-list {
background-color: transparent;
@@ -63,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 */
@@ -81,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);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

+102 -22
View File
@@ -6,7 +6,6 @@ use std::fs;
use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet";
/// Default config search path: system-wide config.
fn default_config_paths() -> Vec<PathBuf> {
@@ -26,14 +25,28 @@ struct Appearance {
background_blur: Option<f32>,
#[serde(rename = "gtk-theme")]
gtk_theme: Option<String>,
#[serde(rename = "fingerprint-enabled")]
fingerprint_enabled: Option<bool>,
}
/// Greeter configuration.
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
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.
@@ -59,12 +72,19 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
Some(parent.join(&bg).to_string_lossy().to_string());
}
}
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;
}
if let Some(fp) = appearance.fingerprint_enabled {
merged.fingerprint_enabled = fp;
}
}
}
Err(e) => {
@@ -78,38 +98,39 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
}
}
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}", merged.background_path, merged.background_blur, merged.gtk_theme);
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}, fingerprint={}", merged.background_path, merged.background_blur, merged.gtk_theme, merged.fingerprint_enabled);
merged
}
/// Resolve the wallpaper path using the fallback hierarchy.
///
/// Priority: config background_path > Moonarch system default > gresource fallback.
pub fn resolve_background_path(config: &Config) -> PathBuf {
/// Priority: config background_path > Moonarch system default > None (GTK background color).
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
}
/// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
// User-configured path
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// 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() {
log::debug!("Wallpaper: using config path {}", path.display());
return path;
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() {
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return moonarch_wallpaper.to_path_buf();
return Some(moonarch_wallpaper.to_path_buf());
}
// GResource fallback path (loaded from compiled resources at runtime)
log::debug!("Wallpaper: using GResource fallback");
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
log::debug!("Wallpaper: no wallpaper found, using GTK background color");
None
}
#[cfg(test)]
@@ -122,6 +143,7 @@ mod tests {
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
assert!(config.gtk_theme.is_none());
assert!(config.fingerprint_enabled);
}
#[test]
@@ -218,7 +240,7 @@ mod tests {
};
assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")),
wallpaper
Some(wallpaper)
);
}
@@ -229,7 +251,7 @@ mod tests {
..Config::default()
};
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("moongreet"));
assert!(result.is_none());
}
#[test]
@@ -240,14 +262,72 @@ mod tests {
let config = Config::default();
assert_eq!(
resolve_background_path_with(&config, &moonarch_wp),
moonarch_wp
Some(moonarch_wp)
);
}
#[test]
fn resolve_uses_gresource_fallback_as_last_resort() {
fn resolve_returns_none_when_no_wallpaper_found() {
let config = Config::default();
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);
}
}
+405 -126
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};
@@ -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,49 +98,57 @@ fn is_valid_username(name: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '@')
}
/// Load background texture from GResource or filesystem.
/// 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> {
let path_str = bg_path.to_str()?;
if bg_path.starts_with("/dev/moonarch/moongreet") {
match gio::resources_lookup_data(path_str, gio::ResourceLookupFlags::NONE) {
Ok(bytes) => match gdk::Texture::from_bytes(&bytes) {
Ok(texture) => Some(texture),
Err(e) => {
log::debug!("GResource texture decode error: {e}");
log::warn!("Failed to decode background texture from GResource {path_str}");
None
}
},
Err(e) => {
log::debug!("GResource lookup error: {e}");
log::warn!("Failed to load background texture from GResource {path_str}");
None
}
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;
}
} else {
if let Ok(meta) = std::fs::metadata(bg_path)
&& meta.len() > MAX_WALLPAPER_FILE_SIZE
{
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) => {
log::debug!("Wallpaper load error: {e}");
log::warn!("Failed to load background texture from {}", bg_path.display());
None
}
}
match gdk::Texture::from_filename(bg_path) {
Ok(texture) => Some(texture),
Err(e) => {
log::debug!("Wallpaper load error: {e}");
log::warn!("Failed to load background texture from {}", bg_path.display());
None
}
}
}
// -- 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,
@@ -145,21 +156,43 @@ fn render_blurred_texture(
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
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 = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
let bounds = graphene_rs::Rect::new(
0.0, 0.0, texture.width() as f32, texture.height() as f32,
);
snapshot.push_blur(sigma as f64);
snapshot.append_texture(texture, &bounds);
snapshot.pop();
// Clip output to scaled texture size
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
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(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur
snapshot.pop(); // clip
let node = snapshot.to_node()?;
Some(renderer.render_texture(&node, None))
let viewport = graphene_rs::Rect::new(pad, pad, w, h);
Some(renderer.render_texture(&node, Some(&viewport)))
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(
texture: &gdk::Texture,
blur_radius: Option<f32>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
app: &gtk::Application,
) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
@@ -167,14 +200,19 @@ pub fn create_wallpaper_window(
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(texture, blur_radius);
let background = create_background_picture(texture, blur_radius, blur_cache);
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 {
/// Uses `blur_cache` to compute the blurred texture only once across all monitors.
fn create_background_picture(
texture: &gdk::Texture,
blur_radius: Option<f32>,
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);
@@ -182,9 +220,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);
}
});
}
@@ -199,13 +244,20 @@ 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>,
}
/// 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()
@@ -216,11 +268,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));
}
@@ -230,6 +278,7 @@ pub fn create_greeter_window(
}
let strings = load_strings(None);
let fingerprint_enabled = config.fingerprint_enabled;
let all_users = users::get_users(None);
let all_sessions = sessions::get_sessions(None, None);
log::debug!("Greeter window: {} user(s), {} session(s)", all_users.len(), all_sessions.len());
@@ -237,13 +286,20 @@ 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,
}));
// Root overlay for layering
@@ -252,7 +308,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)
@@ -314,6 +370,12 @@ pub fn create_greeter_window(
error_label.set_visible(false);
login_box.append(&error_label);
// Fingerprint label (hidden until probe confirms availability)
let fp_label = gtk::Label::new(None);
fp_label.add_css_class("fingerprint-label");
fp_label.set_visible(false);
login_box.append(&fp_label);
login_box.set_halign(gtk::Align::Center);
main_box.append(&login_box);
@@ -354,6 +416,8 @@ pub fn create_greeter_window(
#[weak]
error_label,
#[weak]
fp_label,
#[weak]
session_dropdown,
#[weak]
window,
@@ -370,9 +434,12 @@ pub fn create_greeter_window(
&username_label,
&password_entry,
&error_label,
&fp_label,
&session_dropdown,
&sessions_rc,
&window,
fingerprint_enabled,
strings,
);
}
));
@@ -443,7 +510,7 @@ 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());
let session = get_selected_session(&session_dropdown, &sessions_rc);
let Some(session) = session else {
@@ -503,6 +570,8 @@ pub fn create_greeter_window(
#[weak]
error_label,
#[weak]
fp_label,
#[weak]
session_dropdown,
#[weak]
window,
@@ -520,6 +589,8 @@ pub fn create_greeter_window(
#[weak]
error_label,
#[weak]
fp_label,
#[weak]
session_dropdown,
#[weak]
window,
@@ -531,9 +602,12 @@ pub fn create_greeter_window(
&username_label,
&password_entry,
&error_label,
&fp_label,
&session_dropdown,
&sessions_rc,
&window,
fingerprint_enabled,
strings,
);
}
));
@@ -544,6 +618,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>>,
@@ -551,9 +626,12 @@ fn select_initial_user(
username_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
error_label: &gtk::Label,
fp_label: &gtk::Label,
session_dropdown: &gtk::DropDown,
sessions: &[Session],
window: &gtk::ApplicationWindow,
fingerprint_enabled: bool,
strings: &'static Strings,
) {
if users.is_empty() {
return;
@@ -573,13 +651,17 @@ fn select_initial_user(
username_label,
password_entry,
error_label,
fp_label,
session_dropdown,
sessions,
window,
fingerprint_enabled,
strings,
);
}
/// Update the UI to show the selected user.
#[allow(clippy::too_many_arguments)]
fn switch_to_user(
user: &User,
state: &Rc<RefCell<GreeterState>>,
@@ -587,19 +669,26 @@ fn switch_to_user(
username_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
error_label: &gtk::Label,
fp_label: &gtk::Label,
session_dropdown: &gtk::DropDown,
sessions: &[Session],
window: &gtk::ApplicationWindow,
fingerprint_enabled: bool,
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("");
error_label.set_visible(false);
fp_label.set_visible(false);
// Update avatar
let cached = {
@@ -624,6 +713,47 @@ 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 (cached device proxy, generation-guarded)
if fingerprint_enabled {
let username = user.username.clone();
glib::spawn_future_local(clone!(
#[weak]
fp_label,
#[strong]
state,
async move {
// Initialize probe on first use, then reuse cached device proxy
let needs_init = state.borrow().fingerprint_probe.is_none();
if needs_init {
let mut probe = crate::fingerprint::FingerprintProbe::new();
probe.init_async().await;
state.borrow_mut().fingerprint_probe = Some(probe);
}
// 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 {
fp_label.set_text(strings.fingerprint_prompt);
}
}
));
}
password_entry.grab_focus();
}
@@ -787,15 +917,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.
@@ -804,10 +938,10 @@ 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() {
let _ = sock.shutdown(std::net::Shutdown::Both);
}
if let Ok(mut sock_guard) = s.greetd_sock.lock()
&& let Some(sock) = sock_guard.take()
{
let _ = sock.shutdown(std::net::Shutdown::Both);
}
}
@@ -835,9 +969,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;
}
@@ -855,28 +989,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();
@@ -886,11 +998,12 @@ fn attempt_login(
set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone();
let password = password.to_string();
let password = Zeroizing::new(password.to_string());
let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone();
let login_cancelled = state.borrow().login_cancelled.clone();
let fingerprint_available = state.borrow().fingerprint_available;
glib::spawn_future_local(clone!(
#[weak]
@@ -905,6 +1018,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,
@@ -914,9 +1029,15 @@ fn attempt_login(
&greetd_sock,
&login_cancelled,
strings,
fingerprint_available,
)
})
.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;
}
match result {
Ok(Ok(LoginResult::Success { username })) => {
@@ -931,6 +1052,7 @@ fn attempt_login(
let warning = faillock_warning(*count, strings);
drop(s);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_greetd_error(
&error_label,
&password_entry,
@@ -941,24 +1063,23 @@ fn attempt_login(
let current = error_label.text().to_string();
error_label.set_text(&format!("{current}\n{w}"));
}
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Ok(LoginResult::Error { message })) => {
show_error(&error_label, &password_entry, &message);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_error(&error_label, &password_entry, &message);
}
Ok(Ok(LoginResult::Cancelled)) => {
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Err(e)) => {
log::error!("Login worker error: {e}");
show_error(&error_label, &password_entry, strings.socket_error);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_error(&error_label, &password_entry, strings.socket_error);
}
Err(_) => {
log::error!("Login worker panicked");
show_error(&error_label, &password_entry, strings.socket_error);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_error(&error_label, &password_entry, strings.socket_error);
}
}
}
@@ -981,6 +1102,7 @@ enum LoginResult {
}
/// Run greetd IPC in a background thread.
#[allow(clippy::too_many_arguments)]
fn login_worker(
username: &str,
password: &str,
@@ -989,6 +1111,7 @@ fn login_worker(
greetd_sock: &Arc<Mutex<Option<UnixStream>>>,
login_cancelled: &Arc<std::sync::atomic::AtomicBool>,
strings: &Strings,
fingerprint_available: bool,
) -> Result<LoginResult, String> {
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
log::debug!("Login cancelled before connect");
@@ -997,7 +1120,9 @@ fn login_worker(
log::debug!("Connecting to greetd socket: {sock_path}");
let mut sock = UnixStream::connect(sock_path).map_err(|e| e.to_string())?;
if let Err(e) = sock.set_read_timeout(Some(std::time::Duration::from_secs(10))) {
// Longer timeout when fingerprint is available — pam_fprintd waits for scan
let read_timeout_secs = if fingerprint_available { 60 } else { 10 };
if let Err(e) = sock.set_read_timeout(Some(std::time::Duration::from_secs(read_timeout_secs))) {
log::warn!("Failed to set read timeout: {e}");
}
if let Err(e) = sock.set_write_timeout(Some(std::time::Duration::from_secs(10))) {
@@ -1024,16 +1149,48 @@ 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() });
}
}
// Step 2: Send password if auth message received
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
log::debug!("Sending auth response for {username}");
response =
ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?;
// Step 2: Handle auth_message loop (supports multi-stage PAM, e.g. fprintd + password)
const MAX_AUTH_ROUNDS: u32 = 5;
let mut auth_round = 0;
while response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
auth_round += 1;
if auth_round > MAX_AUTH_ROUNDS {
log::warn!("Too many auth rounds ({auth_round}), aborting");
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.auth_failed.to_string(),
});
}
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
}
let msg_type = response
.get("auth_message_type")
.and_then(|v| v.as_str())
.unwrap_or("secret");
if msg_type == "secret" {
log::debug!("Sending password for {username} (round {auth_round})");
response =
ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?;
} else {
// Non-secret prompt (e.g. fprintd "Place finger on reader")
// PAM handles the actual verification; this blocks until resolved
log::debug!("Acknowledging non-secret auth prompt (round {auth_round})");
response =
ipc::post_auth_response(&mut sock, None).map_err(|e| e.to_string())?;
}
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
@@ -1046,14 +1203,6 @@ fn login_worker(
username: username.to_string(),
});
}
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
// Multi-stage auth is not supported
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.multi_stage_unsupported.to_string(),
});
}
}
// Step 3: Start session
@@ -1092,9 +1241,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(),
});
}
}
@@ -1117,7 +1269,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(())) => {}
@@ -1140,6 +1292,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))
}
@@ -1163,7 +1324,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;
@@ -1216,7 +1377,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);
}
@@ -1481,7 +1645,7 @@ mod tests {
let result = login_worker(
"alice", "wrongpass", "/usr/bin/niri",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1523,7 +1687,7 @@ mod tests {
let result = login_worker(
"alice", "correct", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1532,40 +1696,104 @@ mod tests {
}
#[test]
fn login_worker_multi_stage_rejected() {
fn login_worker_multi_stage_fingerprint_then_password() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "Place your finger on the reader",
})).unwrap();
// post_auth_response with None (fingerprint prompt acknowledged)
let msg = ipc::recv_message(stream).unwrap();
assert!(msg["response"].is_null());
// Fingerprint failed, PAM falls through to password
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response → another auth_message (TOTP)
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "TOTP: ",
})).unwrap();
// post_auth_response with password
let msg = ipc::recv_message(stream).unwrap();
assert_eq!(msg["response"], "correctpass");
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// cancel_session
// start_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"alice", "correctpass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), true,
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Success { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_multi_stage_fingerprint_success() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "Place your finger on the reader",
})).unwrap();
// post_auth_response with None → fingerprint matched via PAM
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// start_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")), true,
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Success { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_max_auth_rounds_exceeded() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
// Send 6 auth_messages (exceeds MAX_AUTH_ROUNDS=5)
for _ in 0..6 {
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "Prompt",
})).unwrap();
let _msg = ipc::recv_message(stream).unwrap();
}
});
let result = login_worker(
"alice", "pass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")), false,
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Error { .. }));
if let LoginResult::Error { message } = result {
assert!(message.contains("Multi-stage"));
}
handle.join().unwrap();
}
@@ -1595,7 +1823,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1610,7 +1838,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"/nonexistent/sock", &default_greetd_sock(), &cancelled,
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1623,7 +1851,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"/nonexistent/sock", &default_greetd_sock(), &cancelled,
load_strings(Some("en")),
load_strings(Some("en")), false,
);
assert!(result.is_err());
@@ -1653,7 +1881,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "../../../etc/evil",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1685,7 +1913,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "niri-session",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1747,4 +1975,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
}
}
+11 -12
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,11 +20,9 @@ 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 multi_stage_unsupported: &'static str,
pub fingerprint_prompt: &'static str,
pub invalid_session_command: &'static str,
pub session_start_failed: &'static str,
pub reboot_failed: &'static str,
@@ -43,11 +42,9 @@ 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",
multi_stage_unsupported: "Mehrstufige Authentifizierung wird nicht unterstützt",
fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
invalid_session_command: "Ungültiger Session-Befehl",
session_start_failed: "Session konnte nicht gestartet werden",
reboot_failed: "Neustart fehlgeschlagen",
@@ -65,11 +62,9 @@ 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",
multi_stage_unsupported: "Multi-stage authentication is not supported",
fingerprint_prompt: "Place finger on reader or enter password",
invalid_session_command: "Invalid session command",
session_start_failed: "Failed to start session",
reboot_failed: "Reboot failed",
@@ -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,
}
@@ -282,6 +280,7 @@ mod tests {
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
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.shutdown_failed.is_empty(), "{locale}: shutdown_failed");
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
+10 -11
View File
@@ -2,6 +2,7 @@
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
mod config;
mod fingerprint;
mod greeter;
mod i18n;
mod ipc;
@@ -19,7 +20,7 @@ fn load_css(display: &gdk::Display) {
gtk::style_context_add_provider_for_display(
display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
gtk::STYLE_PROVIDER_PRIORITY_USER,
);
}
@@ -51,21 +52,19 @@ fn activate(app: &gtk::Application) {
// Load config and resolve wallpaper
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
log::debug!("Background path: {}", bg_path.display());
let bg_texture = config::resolve_background_path(&config)
.and_then(|path| {
log::debug!("Background path: {}", path.display());
greeter::load_background_texture(&path)
});
// Load background texture once — shared across all windows
// Blur is applied on the GPU via GskBlurNode at widget realization time.
let bg_texture = greeter::load_background_texture(&bg_path);
if bg_texture.is_none() {
log::error!("Failed to load background texture — greeter will start without wallpaper");
}
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);
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
if use_layer_shell {
setup_layer_shell(&greeter_window, true, gtk4_layer_shell::Layer::Top);
}
@@ -82,7 +81,7 @@ fn activate(app: &gtk::Application) {
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, app);
let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, &blur_cache, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();
+70 -19
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,79 @@ 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(Stdio::piped())
.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 {
action,
message: e.to_string(),
})?;
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
if output.status.success() {
log::debug!("Power action {action} completed successfully");
let timeout_thread = std::thread::spawn(move || {
let interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < POWER_TIMEOUT {
std::thread::sleep(interval);
if done_clone.load(Ordering::Relaxed) {
return;
}
elapsed += interval;
}
// ESRCH if the process already exited — harmless
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
});
let status = child.wait().map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
done.store(true, Ordering::Relaxed);
let _ = timeout_thread.join();
if status.success() {
log::debug!("Power action {action} completed");
Ok(())
} else {
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 +120,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 +150,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());
}
}
+8 -8
View File
@@ -36,14 +36,14 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
continue;
}
if let Some(value) = line.strip_prefix("Name=") {
if name.is_none() {
name = Some(value.to_string());
}
} else if let Some(value) = line.strip_prefix("Exec=") {
if exec_cmd.is_none() {
exec_cmd = Some(value.to_string());
}
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=")
&& exec_cmd.is_none()
{
exec_cmd = Some(value.to_string());
}
}
+17 -17
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]