Compare commits

..

9 Commits

Author SHA1 Message Date
nevaforget 86d0f5e56d docs: translate CLAUDE.md to English
Per the committed=English rule.
2026-06-16 10:46:13 +02:00
nevaforget 49bcb9e58f ci: switch update-pkgver to tag-trigger (no-suffix pkgname) 2026-06-10 18:23:23 +02:00
nevaforget 030f8c62a6 refactor: power-confirm via PowerAction table (v0.10.1)
Update PKGBUILD version / update-pkgver (push) Successful in 5s
Replace the two hand-wired reboot/shutdown handlers and the loose-param
show_power_confirm with a PowerAction table + create_power_button factory,
mirroring moonset's ActionDef pattern. Couples icon/prompt/error/action so
a mismatched prompt/action pair is unrepresentable.

Restore the in-flight re-trigger guard via power_box.set_sensitive(false)
(re-enabled on failure), superseding the v0.10.0 "no guard" tradeoff.
2026-06-02 14:31:31 +02:00
nevaforget 3c34b4ec25 feat: confirm dialog before power actions (v0.10.0)
Reboot/shutdown buttons triggered immediately on click. Now show an
inline confirmation prompt, mirroring moonlock's show_power_confirm.

- i18n: reboot_confirm, shutdown_confirm, confirm_yes, confirm_no (DE/EN)
- greeter: confirm_area in login_box, handlers route through
  show_power_confirm/dismiss_power_confirm; execute_power_action drops
  the now-redundant button-disable guard
- style: .confirm-label/-yes/-no classes
2026-06-02 13:53:28 +02:00
nevaforget 63d6c656bd feat: apply cursor theme via GtkSettings (v0.9.0)
Update PKGBUILD version / update-pkgver (push) Successful in 4s
GTK4 under greetd does not honour XCURSOR_THEME reliably, so the greeter
showed the wrong cursor over GTK widgets on some machines. Set
gtk-cursor-theme-name / -size on GtkSettings from new [appearance] config
fields (cursor-theme, cursor-size), mirroring the gtk-theme handling and
reusing is_valid_gtk_theme validation.

Salvaged from commit 29ce185 — tagged v0.9.0 on 2026-04-24 but never
pushed. The orphan branch's keyboard refactor (v0.10.0) is discarded,
superseded by the v0.8.7 single-window fix.
2026-06-02 12:58:59 +02:00
nevaforget 41228605ad fix: power buttons via systemctl, single greeter window (v0.8.7)
Update PKGBUILD version / update-pkgver (push) Successful in 6s
Reboot/shutdown buttons always failed: power.rs called `loginctl
reboot|poweroff`, but loginctl has no such verbs (systemd 260) — those
belong to systemctl. moonlock/moonset already used systemctl; moongreet
was the outlier. Switch to `systemctl --no-ask-password reboot|poweroff`.

The multi-monitor greeter gave Exclusive keyboard only to the first
monitor's window, so a user focused on any other output could not type
the password. Drop the per-monitor loop + hotplug; create one window on
the focused output (no set_monitor) with Exclusive keyboard.

Polkit rule kept as a harmless safety net (it was never the blocker;
CanReboot returns yes). The missing journal errors were not a logging
bug — they were lost to a hard power-cut before journald synced.
2026-06-02 12:46:13 +02:00
nevaforget b9b6f50974 fix: audit LOW fixes — stdout null, utf-8 path, debug value, hidden sessions (v0.8.6)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- power::run_command: .stdout(Stdio::null()) — the pipe was never drained,
  structurally fragile even if no current caller hits it.
- config: replace to_string_lossy() on relative wallpaper paths with
  to_str() + log::warn, so non-UTF-8 paths are dropped cleanly instead
  of being mangled into unopenable U+FFFD strings.
- main: require MOONGREET_DEBUG=1 to raise verbosity. Mere presence of
  the var must not leak socket paths, usernames, and auth round counts
  into the journal.
- sessions: parse Hidden= and NoDisplay= keys, skip entries marked true.
  Keeps disabled or stub .desktop files out of the session dropdown.
2026-04-24 14:08:35 +02:00
nevaforget 3a1af6471f fix: audit MEDIUM fixes — FP race, async avatar, symlink, FD leak (v0.8.5)
- i18n test: assert four previously-missing string fields so future locales
  cannot ship empty strings unnoticed.
- greeter: atomic check-and-set fingerprint_probe_initializing to keep a
  fast user switch from spawning two parallel fprintd D-Bus inits.
- greeter: set_avatar_from_file decodes via gio::File::read_future +
  Pixbuf::from_stream_at_scale_future inside glib::spawn_future_local;
  shows default icon first, swaps on completion.
- greeter: cap MAX_WALLPAPER_FILE_SIZE at 10 MB and MAX_AVATAR_FILE_SIZE
  at 5 MB to bound worst-case decode latency.
- config: apply the same symlink-rejection check to the Moonarch
  wallpaper fallback that the user-configured path already uses.
- greeter: after login_worker returns, drop the cloned greetd socket
  held in shared state so repeated failed logins do not leak FDs.
2026-04-24 13:26:52 +02:00
nevaforget 35f1a17cdf fix: audit fix — reduce password copies in memory (v0.8.4)
- attempt_login takes Zeroizing<String> by value, eliminating the redundant
  Zeroizing::new(password.to_string()) that doubled the Rust-owned copy.
- Clear password_entry's internal buffer immediately after extracting the
  password, shortening the window during which the GTK GString persists in
  non-zeroizable libc memory.
2026-04-24 12:52:59 +02:00
14 changed files with 464 additions and 257 deletions
+11 -11
View File
@@ -1,22 +1,22 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
# ABOUTME: Ensures paru detects new versions of this package.
# ABOUTME: Updates pkgver in moonarch-pkgbuilds when a new moongreet tag is pushed.
# ABOUTME: Reads the latest version tag and bumps the PKGBUILD + .SRCINFO.
name: Update PKGBUILD version
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Checkout source repo
- name: Determine pkgver from latest tag
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/-/./')
PKGVER=$(git describe --tags --abbrev=0 | sed 's/^v//')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
@@ -26,18 +26,18 @@ jobs:
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' moongreet-git/PKGBUILD | cut -d= -f2)
OLD_VER=$(grep '^pkgver=' moongreet/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
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moongreet/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moongreet/.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 add moongreet/PKGBUILD moongreet/.SRCINFO
git commit -m "chore(moongreet): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
+46 -47
View File
@@ -1,70 +1,69 @@
# Moongreet
## Projekt
## Project
Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
Teil des Moonarch-Ökosystems.
Moongreet is a greetd greeter for Wayland, built with Rust + gtk4-rs + gtk4-layer-shell.
Part of the Moonarch ecosystem.
## Tech-Stack
## Tech Stack
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-layer-shell 0.8 für Wayland Layer Shell (TOP Layer)
- greetd IPC über Unix Domain Socket (length-prefixed JSON)
- `cargo test` für Unit-Tests
- Rust (edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-layer-shell 0.8 for the Wayland Layer Shell (TOP layer)
- greetd IPC over a Unix domain socket (length-prefixed JSON)
- `cargo test` for unit tests
## Projektstruktur
## Project Structure
- `src/` — Rust-Quellcode (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
- `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`)
- `src/` — Rust source code (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
- `resources/` — GResource assets (style.css, default-avatar.svg)
- `config/`example configuration files for `/etc/moongreet/` and `/etc/greetd/`
- `pkg/` — PKGBUILD for Arch Linux packaging (`makepkg -sf`)
## Kommandos
## Commands
```bash
# Tests ausführen
# Run tests
cargo test
# Release-Build
# Release build
cargo build --release
# Greeter im Fenster starten (ohne greetd/Layer Shell)
# Start the greeter in a window (without greetd/Layer Shell)
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
# Paket bauen und installieren
# Build and install the package
cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.zst
```
## Architektur
## Architecture
- `ipc.rs` — greetd Socket-Kommunikation (4-byte LE header + JSON)
- `users.rs`Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection
- `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
- `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, cursor-theme, cursor-size, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200) + Cursor-Size-Validierung (range 1256)
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files).
- `main.rs`Entry Point, GTK App, Layer Shell Setup. Ein einziges Greeter-Fenster, verankert am Built-in-Display (via `pick_primary_monitor_index`), `KeyboardMode::OnDemand` — moongreet ist ein normaler layer-shell-client, keine output-scoped policies. Sekundäre Monitore bleiben unter Compositor-Kontrolle. Systemd-journal-logger.
- `resources/style.css` — Catppuccin-inspiriertes Theme
- `ipc.rs` — greetd socket communication (4-byte LE header + JSON)
- `users.rs`users from /etc/passwd, avatars (AccountsService + ~/.face), symlink rejection
- `sessions.rs` — Wayland/X11 sessions from .desktop files
- `power.rs`reboot/shutdown via systemctl (`--no-ask-password`)
- `i18n.rs`locale detection (LANG / /etc/locale.conf) and string tables (DE/EN), all UI and login error messages
- `fingerprint.rs` — fprintd D-Bus probe (gio::DBusProxy) — device detection and enrollment check for UI feedback
- `config.rs` — TOML config ([appearance] background, gtk-theme, cursor-theme, cursor-size, fingerprint-enabled) + wallpaper fallback + blur validation (finite, clamp 0200) + cursor-size validation (range 1256)
- `greeter.rs` — GTK4 UI (overlay layout), login flow via greetd IPC (multi-stage auth for fprintd), faillock warning, power confirm (inline confirmation before reboot/shutdown, like moonlock), avatar cache, last-user/last-session persistence (0o700 dirs, 0o600 files)
- `main.rs`entry point, GTK app, Layer Shell setup, one greeter window on the focused output (no `set_monitor`), `KeyboardMode::Exclusive`, systemd-journal-logger
- `resources/style.css` — Catppuccin-inspired theme
## Design Decisions
- **TOP Layer statt OVERLAY**: Greeter läuft unter greetd, nicht über Waybar
- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert
- **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. 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
- **Cursor-Theme via GtkSettings**: GTK4 unter greetd liest `XCURSOR_THEME` env nicht zuverlässig — Cursor wird via `gtk::Settings::set_gtk_cursor_theme_name()` gesetzt, analog zu `gtk-theme`. Gleiche Validierung (`is_valid_gtk_theme`) gegen Path-Traversal.
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
- **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor
- **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade`None`
- **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal
- **Single Greeter Window, keine Output-Policies**: Ein einziges layer-shell-fenster auf dem Built-in-Display, `KeyboardMode::OnDemand`. Sekundäre Outputs bleiben unter Compositor-Kontrolle. Grund: Die output-scoped policies aus v0.8.0v0.8.5 (Exclusive-Keyboard auf Primary, Wallpaper-Only auf Secondaries, Hotplug-Callbacks) haben den Greeter bei realen Multi-Monitor-Setups wiederholt kaputt gemacht (Pointer kommt nicht zum Primary, Keyboard tabt nicht zur UI). Im User-Session-Niri gibt es diese Probleme nicht — moongreet verhält sich jetzt wie jeder normale layer-shell-client.
- **TOP layer instead of OVERLAY**: the greeter runs under greetd, not above Waybar
- **GResource bundle**: CSS, wallpaper and default avatar are compiled into the binary
- **Async login**: `glib::spawn_future_local` + `gio::spawn_blocking` instead of raw threads
- **Socket cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` for clean cancellation
- **Avatar cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
- **GPU blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` in the `connect_realize` callback — no CPU blur, no disk cache, no `image` crate. The blurred texture is cached across all monitors via `Rc<RefCell<Option<gdk::Texture>>>` (1 GPU render pass instead of N).
- **Fingerprint via greetd multi-stage PAM**: fprintd D-Bus only as a probe (device/enrollment), the actual verification runs through PAM in the greetd auth loop. `auth_message_type: "secret"`password, everything else → `None` (PAM decides). 60s socket timeout for fprintd. Device proxy cached in `GreeterState`, generation counter against race conditions on fast user switch.
- **Symmetry with moonlock/moonset**: same patterns (i18n, config, users, power, GResource, GPU blur)
- **Session validation**: relative paths allowed (greetd resolves PATH), only `..`/null bytes are rejected
- **GTK theme validation**: only alphanumeric + `_-+.` allowed, prevents path traversal via config
- **Cursor theme via GtkSettings**: GTK4 under greetd does not read the `XCURSOR_THEME` env reliably — the cursor is set via `gtk::Settings::set_gtk_cursor_theme_name()`, analogous to `gtk-theme`. Same validation (`is_valid_gtk_theme`) against path traversal.
- **Journal logging**: `systemd-journal-logger` instead of file logging — `journalctl -t moongreet`, debug level via the `MOONGREET_DEBUG` env var
- **File permissions**: cache directories 0o700 via `DirBuilder::mode()`, cache files 0o600
- **Testable persistence**: `save_*_to`/`load_*_from` variants with a configurable path for unit tests
- **Shared wallpaper texture**: the `gdk::Texture` is decoded once in `load_background_texture()` and shared by ref-count across all windows — avoids redundant JPEG decoding per monitor
- **Wallpaper validation**: GResource branch via `resources_lookup_data()` + `from_bytes()` (no abort on a missing path), file-size limit 50 MB, non-UTF-8 paths`None`
- **Error-detail filtering**: GDK/greetd error details only at `debug!` level, `warn!` without internal details — prevents system-info leak into the journal
Generated
+1 -1
View File
@@ -575,7 +575,7 @@ dependencies = [
[[package]]
name = "moongreet"
version = "0.9.0"
version = "0.10.1"
dependencies = [
"gdk-pixbuf",
"gdk4",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moongreet"
version = "0.10.0"
version = "0.10.1"
edition = "2024"
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
license = "MIT"
+37 -16
View File
@@ -1,32 +1,53 @@
# Decisions
## 2026-04-24 Single greeter window, no per-output keyboard grab (v0.10.0)
## 2026-06-02 Align power-confirm to moonset's ActionDef pattern (v0.10.1)
- **Who**: ClaudeCode, Dom
- **Why**: The v0.8.0 → v0.8.4 → v0.8.5 sequence accumulated multi-monitor logic (login widget on primary, wallpaper-only on secondaries, `KeyboardMode::Exclusive` on the primary surface) to work around keyboard-routing on specific hardware setups. After the moonarch greeter switched to niri, the symptoms returned: the UI was on eDP-1 but the pointer could not cross onto it, and keyboard tab did not reach the login widget. Niri in a normal user session never behaves like this — the issue was our greeter's self-imposed per-output scope, not the compositor. Every earlier "fix" made it more bespoke instead of making moongreet a well-behaved layer-shell client.
- **Tradeoffs**: Reverts the multi-output story entirely. Secondary monitors get nothing from moongreet — the compositor decides what renders there (black, its own wallpaper, whatever). The "wallpaper on every screen" look is gone. In exchange, cursor and keyboard follow normal niri focus rules, nothing is grabbed, no hotplug callbacks, no DisplayLink phantom workarounds.
- **How**: `main.rs::activate` builds **one** greeter window, anchors it to the built-in display picked by `pick_primary_monitor_index`, and calls `setup_layer_shell` with `KeyboardMode::OnDemand`. The hotplug `connect_items_changed` handler is gone. `create_wallpaper_window` is removed. `setup_layer_shell` no longer takes a `keyboard: bool` — there is only one policy.
- **Why**: Code review of v0.10.0 flagged the power-confirm code (ported verbatim from moonlock) as lower-altitude than moonset's: two near-identical reboot/shutdown handlers and a `show_power_confirm` taking loose `message`/`action_fn`/`error_message` params that can drift apart. moonset already solved this with an `ActionDef` table + button factory.
- **Tradeoffs**: A `PowerAction` struct + `power_actions()` table + `create_power_button` factory is slightly more machinery for just two actions, but couples icon/prompt/error/action into one value (mismatch becomes unrepresentable) and makes a third action a one-line table entry. Kept in lockstep with moonlock (same change landed there). Did NOT touch `confirm_box: Rc<RefCell<Option<gtk::Box>>>` — moonset uses the same, it is the shared convention.
- **How**: Replaced the two hand-wired handlers with a loop over `power_actions()`; `show_power_confirm`/`execute_power_action` now take `PowerAction` (Copy) instead of three loose strings. Re-introduced the in-flight re-trigger guard via `power_box.set_sensitive(false)` (re-enabled on failure) — this restores the protection that v0.10.0 dropped, superseding that entry's "no guard" tradeoff.
## 2026-04-24 Cursor theme via config instead of env (v0.9.0)
## 2026-06-02 Inline power confirmation before reboot/shutdown (v0.10.0)
- **Who**: ClaudeCode, Dom
- **Why**: Cursor theme in the greeter was the default fallback even with `XCURSOR_THEME=Sweet-cursors` in `/etc/greetd/config.toml`'s `env` prefix. Cage forwards the env, but GTK4 does not honour `XCURSOR_THEME` reliably under greetd — it picks up the theme from `gtk-cursor-theme-name` on `GtkSettings`, and without a session-level settings.ini or GSettings override in the greeter user's home, that property stays at the GTK default. Adding an env-var hack worked for the wlroots pointer rendered by cage, but GTK widgets (button hover, text input) used their own wrong cursor.
- **Tradeoffs**: Adds two config fields (`cursor-theme`, `cursor-size`) — symmetric with the existing `gtk-theme` field and justified by the same cause (GTK4 under greetd ignores the usual discovery paths). Alternative would have been a system-wide `/etc/gtk-4.0/settings.ini` with `gtk-cursor-theme-name=`, but that couples moongreet's appearance to the host system's GTK config and affects every GTK4 app running as any user.
- **How**: `config.rs` gains `cursor_theme: Option<String>` and `cursor_size: Option<i32>` (range-validated 1256). `greeter.rs::create_greeter_window` applies them via `gtk::Settings::set_gtk_cursor_theme_name()` and `set_gtk_cursor_theme_size()` directly after the existing `gtk-theme` handling, reusing `is_valid_gtk_theme()` for name validation. Moonarch's deployed config gains `cursor-theme = "Sweet-cursors"` + `cursor-size = 24`. The env-prefix hack in `/etc/greetd/config.toml` is now redundant.
- **Why**: Reboot/Shutdown buttons triggered the action immediately on click — one misclick rebooted the machine from the greeter. moonlock already guards power actions with an inline confirm; moongreet should match.
- **Tradeoffs**: Ported moonlock's `show_power_confirm`/`dismiss_power_confirm` verbatim instead of inventing a new widget — keeps the two codebases symmetric (i18n, CSS classes, focus-on-Cancel behaviour all identical). Dropped the `button` parameter from `execute_power_action`: the old per-button `set_sensitive(false)` double-click guard is now redundant because the confirm box itself blocks accidental re-trigger, and after "Yes" there is no button left to re-enable.
- **How**: Inline confirm box appended to the central `login_box` (mirrors moonlock placement). Reboot/Shutdown handlers call `show_power_confirm`; "Yes" dismisses and runs the action, "Cancel" (focused by default) just dismisses. New i18n strings (`reboot_confirm`, `shutdown_confirm`, `confirm_yes`, `confirm_no`) and `.confirm-*` CSS classes ported from moonlock; `.confirm-no` background adapted to moongreet's `alpha(@theme_fg_color, …)` idiom.
## 2026-04-23 Wallpaper-only windows on secondary monitors (v0.8.5)
## 2026-06-02 Cursor theme via GtkSettings, salvaged from unpushed work (v0.9.0)
- **Who**: ClaudeCode, Dom
- **Why**: The v0.8.4 fix (keyboard grab on the built-in panel) only half-worked. The greeter still rejected keystrokes until the user moved the mouse to eDP-1Niri scopes layer-shell keyboard routing by active output, so even though the primary window was the sole `KeyboardMode::Exclusive` surface, keys went nowhere when another output was active. Hardcoding a compositor focus call (e.g. `niri msg action focus-monitor`) would tie moongreet to a specific compositor.
- **Tradeoffs**: Reverts part of 2026-04-08: only the built-in panel shows the full greeter UI, other monitors go back to wallpaper-only. Users with multiple monitors lose the symmetric "login widget on every screen" look, but gain a reliable keyboard path regardless of which output the compositor considers active at startup. Compositor-agnostic — no Niri-specific IPC.
- **How**: New `create_wallpaper_window()` in `greeter.rs` builds a minimal `ApplicationWindow` with the shared background `Picture` (same `blur_cache` as the primary) and no login widgets. `main.rs` uses `create_greeter_window()` for the index returned by `pick_primary_monitor_index()` and `create_wallpaper_window()` for the rest. Hotplugged monitors also get wallpaper-only windows. Both variants use `Layer::Top`; only the primary sets `KeyboardMode::Exclusive`.
- **Why**: On some machines the greeter showed the wrong (GTK-default) cursor. GTK4 under greetd does not honour `XCURSOR_THEME` reliablyniri renders its own pointer from the kdl `cursor` block, but GTK widgets (button hover, text-input I-beam) read `gtk-cursor-theme-name` on `GtkSettings`, which without a session settings.ini stays at the GTK default. This fix was written and tagged v0.9.0 on 2026-04-24 but never pushed — it sat in a local-only branch while the bug kept shipping. Salvaged onto main now (cherry-picked from commit 29ce185).
- **Tradeoffs**: Adds two `[appearance]` config fields (`cursor-theme`, `cursor-size`), symmetric with the existing `gtk-theme` field. Alternative — a system-wide `/etc/gtk-4.0/settings.ini` with `gtk-cursor-theme-name=` — would couple moongreet to host GTK config and affect every GTK4 app; rejected for the same reason as `gtk-theme`.
- **How**: `config.rs` gains `cursor_theme: Option<String>` and `cursor_size: Option<i32>` (range-validated 1256). `greeter::create_greeter_window` applies them via `gtk::Settings::set_gtk_cursor_theme_name()` / `set_gtk_cursor_theme_size()` after the existing gtk-theme handling, reusing `is_valid_gtk_theme()`. Deployed `moongreet.toml` gains `cursor-theme = "Sweet-cursors"` + `cursor-size = 24`. The orphaned April branch (v0.9.0/v0.10.0) is otherwise discarded; its keyboard refactor is superseded by the v0.8.7 single-window fix.
## 2026-04-23 Keyboard focus on built-in display, not first enumerated monitor (v0.8.4)
## 2026-06-02 Power buttons fixed (loginctl→systemctl) + single greeter window (v0.8.7)
- **Who**: ClaudeCode, Dom
- **Why**: With a DisplayLink dock attached, the greeter showed its UI on all monitors but the password entry accepted no input. `display.monitors()` enumerated evdi phantom connectors (`DVI-I-*`) before the laptop panel (`eDP-1`); the v0.8.0 logic gave `KeyboardMode::Exclusive` to index 0, so the keyboard grab landed on an invisible surface. Symptom showed up on 2026-04-23 after kernel 6.19.11 → 6.19.12 + moongreet 0.8.00.8.2 changed evdi enumeration timing — previous Thursdays with the same dock worked.
- **Tradeoffs**: Prefers built-in displays by connector-name pattern (`eDP*`/`LVDS*`/`DSI*`) rather than a generic "primary monitor" concept — Wayland has no portable primary signal, and gdk4's `primary_monitor()` was removed. Pattern-matching covers every current Linux laptop, at the cost of a tiny list to maintain if a new form factor ships a new connector type. Fallback is still index 0, so behavior on desktops without a built-in panel is unchanged.
- **How**: New pure function `pick_primary_monitor_index()` in `main.rs` scans connector names and returns the built-in index (or 0). Used during initial enumeration to decide which window gets `KeyboardMode::Exclusive`. Hotplug branch unchanged — new monitors still get keyboard=false so focus never migrates off the panel. Unit-tested against evdi/eDP/LVDS/DSI/HDMI/DP mixes.
- **Why**: At the greeter the reboot and shutdown buttons always failed with "Neustart/Herunterfahren fehlgeschlagen". Root cause: `power.rs` invoked `/usr/bin/loginctl reboot|poweroff`, but `loginctl` has no such verbs (systemd 260: `Unknown command verb 'reboot'`, exit 1) — power-management verbs belong to `systemctl`. moonlock and moonset already used `systemctl`; moongreet was the outlier (moonset carried the same bug until Mar 29). The polkit rule shipped in v0.8.3 treated the wrong layer — `CanReboot` returns `yes`, polkit was never the blocker. Separately, the multi-monitor greeter (v0.8.0/v0.8.2) gave `KeyboardMode::Exclusive` to only the first enumerated monitor's window, so on a multi-output setup the user could not type the password when focused on any other output.
- **Tradeoffs**: Dropping the per-monitor + hotplug windows leaves secondary monitors blank during login; irrelevant for a login screen (input happens on one output). Exclusive keyboard binds input to the single greeter surface regardless of pointer position — the mouse may wander to a blank output but typing always reaches the greeter (chosen over compositor-level pointer confinement). The polkit rule is kept as a harmless safety net for the agent-less greeter session; its misleading "session is inactive" comment was corrected.
- **How**: (1) `power::reboot`/`shutdown` call `/usr/bin/systemctl --no-ask-password reboot|poweroff` (matches moonlock; `--no-ask-password` fails fast instead of hanging on a missing askpass agent). (2) `main.rs` `activate()` creates one greeter window with no `set_monitor` (compositor places it on the focused output, like moonset) and `KeyboardMode::Exclusive`; the monitor loop, `connect_items_changed` hotplug handler, and the now-unused `glib::clone`/`std::rc::Rc` imports are removed. (3) The missing journal entries were investigated and are **not** a logging bug — the greeter user delivers all priorities to journald (verified live); the two button errors were lost because boot -2 was hard-cut before journald's 5-minute sync.
## 2026-04-24 Audit LOW fixes: stdout null, utf-8 path, debug value, hidden sessions (v0.8.6)
- **Who**: ClaudeCode, Dom
- **Why**: Four LOW findings cleared in a single pass. (1) `power::run_command` piped stdout it never read — structurally fragile even though current callers stay well under the pipe buffer. (2) Relative wallpaper paths were resolved via `to_string_lossy`, silently substituting `U+FFFD` for non-UTF-8 bytes and producing a path that cannot be opened. (3) `MOONGREET_DEBUG` escalated log verbosity on mere presence, so an empty variable leaked auth metadata into the journal. (4) `Hidden=true` and `NoDisplay=true` `.desktop` entries appeared in the session dropdown even though they mark disabled or stub sessions.
- **Tradeoffs**: Gating debug on the literal value `"1"` is slightly stricter than most tools but matches the security-first posture. Filtering Hidden/NoDisplay means legitimately hidden but functional sessions are now unselectable from the greeter — acceptable, that is the convention these keys signal.
- **How**: (1) `.stdout(Stdio::null())` replaces the unused pipe. (2) `to_string_lossy().to_string()` replaced by `to_str().map(|s| s.to_string())` with a `log::warn!` fallback for non-UTF-8 paths. (3) `match std::env::var("MOONGREET_DEBUG").ok().as_deref()``Some("1")` selects Debug, everything else Info. (4) `parse_desktop_file` reads `Hidden=` and `NoDisplay=`, returns `None` if either is `true`.
## 2026-04-24 Audit MEDIUM fixes: FP double-init, async avatar, symlink, FD leak (v0.8.5)
- **Who**: ClaudeCode, Dom
- **Why**: Six MEDIUM findings: (1) i18n test `all_string_fields_nonempty` missed four string fields — future locales could ship empty strings unnoticed. (2) Fast user-switch could spawn two parallel fprintd `init_async` calls because both coroutines saw `fingerprint_probe = None` before either stored its probe. (3) Synchronous avatar decode via `Pixbuf::from_file_at_scale` on the GTK main thread, stalling clicks. (4) Wallpaper `MAX_WALLPAPER_FILE_SIZE = 50 MB` bounded decode at up to ~2 s. (5) Fallback wallpaper path used `is_file()` which follows symlinks, inconsistent with the symlink-rejecting user-config path. (6) After a failed login the cloned `greetd_sock` descriptor remained in shared state until the next user switch, accumulating stale FDs across retries.
- **Tradeoffs**: The init-race guard uses a bool flag on `GreeterState` + a 25 ms polling yield — cheap and race-free, but introduces a very short latency when a second probe waits. Lowering `MAX_WALLPAPER_FILE_SIZE` to 10 MB and `MAX_AVATAR_FILE_SIZE` to 5 MB caps worst-case decode but rejects legitimately huge (4K raw) wallpapers; acceptable for a greeter. Async avatar decode shows the default icon for a frame or two on cache miss.
- **How**: (1) Four new `assert!` lines in `i18n::tests::all_string_fields_nonempty`. (2) New `fingerprint_probe_initializing: bool` on `GreeterState`, atomic check-and-set under `borrow_mut`, losing coroutines yield via `glib::timeout_future` until the winning init completes. (3) `set_avatar_from_file` uses `gio::File::read_future` + `Pixbuf::from_stream_at_scale_future` inside a `glib::spawn_future_local`, sets the default icon first, swaps on success. (4) Lower both size constants. (5) `resolve_background_path_with` now applies the same `symlink_metadata` + `!is_symlink` check to the Moonarch fallback. (6) After the login worker returns, `state.greetd_sock.lock().take()` drops the stale clone regardless of login outcome.
## 2026-04-24 Audit fix: shrink password-in-memory window (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: Security audit flagged the GTK password path as holding more copies of the plaintext password in memory than necessary. `attempt_login` wrapped the already-`Zeroizing<String>` caller value into a second `Zeroizing<String>` (`password.to_string()`), and the GTK `GString` backing `entry.text()` persisted in libc malloc'd memory until the allocator reused the page.
- **Tradeoffs**: The GTK `GString` and the libc `strdup` copy on the PAM FFI boundary remain non-zeroizable — this is an inherent GTK/libc limitation, already documented in CLAUDE.md. This change reduces the Rust-owned copies to one and clears the `PasswordEntry` text field immediately after extraction to shorten the GTK-side window.
- **How**: (1) `attempt_login` now takes `password: Zeroizing<String>` by value instead of `&str`, moving ownership into the `spawn_blocking` closure. (2) The redundant `Zeroizing::new(password.to_string())` inside `attempt_login` is removed. (3) `password_entry.set_text("")` is called right after the password is extracted from the activate handler, shortening the lifetime of the GTK-internal buffer.
## 2026-04-21 Ship polkit rule in moongreet instead of moonarch (v0.8.3)
+1 -1
View File
@@ -12,7 +12,7 @@ Part of the Moonarch ecosystem.
- **Last user/session** — Remembered in `/var/cache/moongreet/`
- **Power actions** — Reboot / Shutdown via `loginctl`
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
- **Multi-monitor + hotplug** — Login UI on the built-in display, wallpaper-only on other monitors; hotplugged monitors get wallpaper windows automatically
- **Multi-monitor + hotplug** — Full greeter UI on all monitors (keyboard input on first), hotplugged monitors get windows automatically
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
- **i18n** — German and English (auto-detected from system locale)
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3
+1 -1
View File
@@ -1,5 +1,5 @@
// ABOUTME: Allow the greeter user to reboot and power off without authentication.
// ABOUTME: Required because greetd's greeter session is inactive in logind.
// ABOUTME: Safety net for the agent-less greeter session — no askpass/polkit agent to answer a challenge.
polkit.addRule(function(action, subject) {
if (subject.user === "greeter" &&
+32
View File
@@ -82,6 +82,38 @@ window.wallpaper {
background-color: alpha(@theme_fg_color, 0.2);
}
/* Power confirmation prompt */
.confirm-label {
font-size: 16px;
color: @theme_fg_color;
margin-bottom: 4px;
}
.confirm-yes {
padding: 8px 24px;
border-radius: 8px;
background-color: @error_color;
color: @theme_bg_color;
border: none;
font-weight: bold;
}
.confirm-yes:hover {
background-color: lighter(@error_color);
}
.confirm-no {
padding: 8px 24px;
border-radius: 8px;
background-color: alpha(@theme_fg_color, 0.15);
color: @theme_fg_color;
border: none;
}
.confirm-no:hover {
background-color: alpha(@theme_fg_color, 0.25);
}
/* Power buttons on the bottom right */
.power-button {
min-width: 48px;
+16 -6
View File
@@ -76,8 +76,14 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if bg_path.is_absolute() {
merged.background_path = Some(bg);
} else if let Some(parent) = path.parent() {
merged.background_path =
Some(parent.join(&bg).to_string_lossy().to_string());
let joined = parent.join(&bg);
match joined.to_str() {
Some(s) => merged.background_path = Some(s.to_string()),
None => log::warn!(
"Ignoring non-UTF-8 background path: {}",
joined.display()
),
}
}
}
if let Some(blur) = appearance.background_blur {
@@ -149,10 +155,14 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path)
log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display());
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
// Moonarch ecosystem default — apply the same symlink rejection as the
// user-configured path for defense in depth. The fallback target is a
// system file, but the caller consumes the result via the same path.
if let Ok(meta) = moonarch_wallpaper.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() {
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
}
}
log::debug!("Wallpaper: no wallpaper found, using GTK background color");
+251 -66
View File
@@ -22,8 +22,8 @@ use crate::sessions::{self, Session};
use crate::users::{self, User};
const AVATAR_SIZE: i32 = 128;
const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 1024 * 1024;
const MAX_AVATAR_FILE_SIZE: u64 = 5 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user";
const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session";
const MAX_USERNAME_LENGTH: usize = 256;
@@ -189,7 +189,7 @@ fn render_blurred_texture(
}
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
/// Uses `blur_cache` to compute the blurred texture only once across all monitors.
/// Uses `blur_cache` to compute the blurred texture only once and reuse it.
fn create_background_picture(
texture: &gdk::Texture,
blur_radius: Option<f32>,
@@ -233,6 +233,9 @@ struct GreeterState {
user_switch_generation: u64,
/// Cached fprintd device proxy — initialized once on first use.
fingerprint_probe: Option<crate::fingerprint::FingerprintProbe>,
/// True while a probe init_async() is in flight. Prevents duplicate D-Bus
/// init when two user-switch probes race (both see probe == None).
fingerprint_probe_initializing: bool,
}
/// Create the main greeter window with login UI.
@@ -299,6 +302,7 @@ pub fn create_greeter_window(
fingerprint_available: false,
user_switch_generation: 0,
fingerprint_probe: None,
fingerprint_probe_initializing: false,
}));
// Root overlay for layering
@@ -369,6 +373,12 @@ pub fn create_greeter_window(
error_label.set_visible(false);
login_box.append(&error_label);
// Confirm box area (for power confirm)
let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0);
confirm_area.set_halign(gtk::Align::Center);
login_box.append(&confirm_area);
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
// Fingerprint label (hidden until probe confirms availability)
let fp_label = gtk::Label::new(None);
fp_label.add_css_class("fingerprint-label");
@@ -424,7 +434,12 @@ pub fn create_greeter_window(
state,
#[strong]
sessions_rc,
#[weak]
confirm_area,
#[strong]
confirm_box,
move |_| {
dismiss_power_confirm(&confirm_area, &confirm_box);
cancel_pending_session(&state);
switch_to_user(
&user_clone,
@@ -457,33 +472,17 @@ pub fn create_greeter_window(
power_box.set_halign(gtk::Align::End);
power_box.set_valign(gtk::Align::End);
let reboot_btn = gtk::Button::new();
reboot_btn.set_icon_name("system-reboot-symbolic");
reboot_btn.add_css_class("power-button");
reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip));
reboot_btn.connect_clicked(clone!(
#[weak]
error_label,
move |btn| {
btn.set_sensitive(false);
execute_power_action(power::reboot, strings.reboot_failed, &error_label, btn);
}
));
power_box.append(&reboot_btn);
let shutdown_btn = gtk::Button::new();
shutdown_btn.set_icon_name("system-shutdown-symbolic");
shutdown_btn.add_css_class("power-button");
shutdown_btn.set_tooltip_text(Some(strings.shutdown_tooltip));
shutdown_btn.connect_clicked(clone!(
#[weak]
error_label,
move |btn| {
btn.set_sensitive(false);
execute_power_action(power::shutdown, strings.shutdown_failed, &error_label, btn);
}
));
power_box.append(&shutdown_btn);
for action in power_actions() {
let button = create_power_button(
action,
strings,
&power_box,
&confirm_area,
&confirm_box,
&error_label,
);
power_box.append(&button);
}
bottom_bar.append(&power_box);
overlay.add_overlay(&bottom_bar);
@@ -510,6 +509,10 @@ pub fn create_greeter_window(
let Some(user) = user else { return };
let password = Zeroizing::new(entry.text().to_string());
// Clear the GTK entry's internal buffer as early as possible. GTK allocates
// the backing `GString` via libc malloc, which `zeroize` cannot reach — the
// best we can do is shorten the window during which it resides in memory.
entry.set_text("");
let session = get_selected_session(&session_dropdown, &sessions_rc);
let Some(session) = session else {
@@ -519,7 +522,7 @@ pub fn create_greeter_window(
attempt_login(
&user,
&password,
password,
&session,
strings,
&state,
@@ -531,17 +534,22 @@ pub fn create_greeter_window(
}
));
// Keyboard handling — Escape clears password and error
// Keyboard handling — Escape clears password, error, and any open power confirm
let key_controller = gtk::EventControllerKey::new();
key_controller.connect_key_pressed(clone!(
#[weak]
password_entry,
#[weak]
error_label,
#[weak]
confirm_area,
#[strong]
confirm_box,
#[upgrade_or]
glib::Propagation::Proceed,
move |_, keyval, _, _| {
if keyval == gdk::Key::Escape {
dismiss_power_confirm(&confirm_area, &confirm_box);
password_entry.set_text("");
error_label.set_visible(false);
glib::Propagation::Stop
@@ -733,12 +741,33 @@ fn switch_to_user(
#[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 {
// Initialize probe on first use, then reuse cached device proxy.
// Atomic check-and-set on fingerprint_probe_initializing prevents
// two concurrent probes (from a fast user switch) from both
// running init_async, which would open duplicate D-Bus connections.
let should_init = {
let mut s = state.borrow_mut();
if s.fingerprint_probe.is_some() || s.fingerprint_probe_initializing {
false
} else {
s.fingerprint_probe_initializing = true;
true
}
};
if should_init {
let mut probe = crate::fingerprint::FingerprintProbe::new();
probe.init_async().await;
state.borrow_mut().fingerprint_probe = Some(probe);
let mut s = state.borrow_mut();
s.fingerprint_probe = Some(probe);
s.fingerprint_probe_initializing = false;
} else {
// Another coroutine is initializing — yield until it publishes.
while state.borrow().fingerprint_probe.is_none()
&& state.borrow().fingerprint_probe_initializing
{
glib::timeout_future(std::time::Duration::from_millis(25)).await;
}
}
// Take probe out of state to avoid holding borrow across await
@@ -795,28 +824,40 @@ fn set_avatar_from_file(
Ok(_) => {}
}
let Some(path_str) = path.to_str() else {
log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
return;
};
// Show fallback immediately; decode asynchronously via GIO so the greeter
// stays responsive during a user-switch click.
image.set_icon_name(Some("avatar-default-symbolic"));
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
if let Some(name) = username {
state
.borrow_mut()
.avatar_cache
.insert(name.to_string(), texture.clone());
let display_path = path.to_path_buf();
let file = gio::File::for_path(path);
let image_clone = image.clone();
let state_clone = state.clone();
let username_owned = username.map(String::from);
glib::spawn_future_local(async move {
let stream = match file.read_future(glib::Priority::default()).await {
Ok(s) => s,
Err(e) => {
log::debug!("Failed to open avatar {}: {e}", display_path.display());
return;
}
};
match Pixbuf::from_stream_at_scale_future(&stream, AVATAR_SIZE, AVATAR_SIZE, true).await {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
if let Some(ref name) = username_owned {
state_clone
.borrow_mut()
.avatar_cache
.insert(name.clone(), texture.clone());
}
image_clone.set_paintable(Some(&texture));
}
Err(e) => {
log::debug!("Failed to decode avatar {}: {e}", display_path.display());
}
image.set_paintable(Some(&texture));
}
Err(e) => {
log::debug!("Failed to load avatar {}: {e}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
}
}
});
}
/// Load the default avatar SVG from GResources, tinted with the foreground color.
@@ -970,7 +1011,7 @@ fn set_login_sensitive(
#[allow(clippy::too_many_arguments)]
fn attempt_login(
user: &User,
password: &str,
password: Zeroizing<String>,
session: &Session,
strings: &'static Strings,
state: &Rc<RefCell<GreeterState>>,
@@ -1009,7 +1050,6 @@ fn attempt_login(
set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone();
let password = Zeroizing::new(password.to_string());
let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone();
@@ -1050,6 +1090,13 @@ fn attempt_login(
glib::timeout_future(min_response - elapsed).await;
}
// The login_worker's own socket is already dropped by now; drop the
// shared clone too so repeated failed attempts do not accumulate
// stale file descriptors in state.greetd_sock.
if let Ok(mut g) = state.borrow().greetd_sock.lock() {
g.take();
}
match result {
Ok(Ok(LoginResult::Success { username })) => {
save_last_user(&username);
@@ -1267,18 +1314,156 @@ fn login_worker(
})
}
/// Execute a power action in a background thread.
fn execute_power_action(
/// Definition for a single power-action button (reboot, shutdown).
/// Couples icon, prompt, error text and action so a button cannot be wired
/// with a mismatched prompt/action pair. Mirrors moonset's `ActionDef`.
#[derive(Clone, Copy)]
struct PowerAction {
icon_name: &'static str,
tooltip_attr: fn(&Strings) -> &'static str,
confirm_attr: fn(&Strings) -> &'static str,
error_attr: fn(&Strings) -> &'static str,
action_fn: fn() -> Result<(), PowerError>,
error_message: &'static str,
}
/// The power actions offered by the greeter.
fn power_actions() -> [PowerAction; 2] {
[
PowerAction {
icon_name: "system-reboot-symbolic",
tooltip_attr: |s| s.reboot_tooltip,
confirm_attr: |s| s.reboot_confirm,
error_attr: |s| s.reboot_failed,
action_fn: power::reboot,
},
PowerAction {
icon_name: "system-shutdown-symbolic",
tooltip_attr: |s| s.shutdown_tooltip,
confirm_attr: |s| s.shutdown_confirm,
error_attr: |s| s.shutdown_failed,
action_fn: power::shutdown,
},
]
}
/// Build a power-action icon button wired to the confirmation flow.
fn create_power_button(
action: PowerAction,
strings: &'static Strings,
power_box: &gtk::Box,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
button: &gtk::Button,
) {
glib::spawn_future_local(clone!(
) -> gtk::Button {
let button = gtk::Button::new();
button.set_icon_name(action.icon_name);
button.add_css_class("power-button");
button.set_tooltip_text(Some((action.tooltip_attr)(strings)));
button.connect_clicked(clone!(
#[weak]
power_box,
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
show_power_confirm(action, strings, &power_box, &confirm_area, &confirm_box, &error_label);
}
));
button
}
/// Show an inline confirmation prompt before executing a power action.
fn show_power_confirm(
action: PowerAction,
strings: &'static Strings,
power_box: &gtk::Box,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) {
dismiss_power_confirm(confirm_area, confirm_box);
error_label.set_visible(false);
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center);
new_box.set_margin_top(16);
let confirm_label = gtk::Label::new(Some((action.confirm_attr)(strings)));
confirm_label.add_css_class("confirm-label");
new_box.append(&confirm_label);
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
button_row.set_halign(gtk::Align::Center);
let yes_btn = gtk::Button::with_label(strings.confirm_yes);
yes_btn.add_css_class("confirm-yes");
yes_btn.connect_clicked(clone!(
#[weak]
button,
power_box,
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
execute_power_action(action, strings, &power_box, &confirm_area, &confirm_box, &error_label);
}
));
button_row.append(&yes_btn);
let no_btn = gtk::Button::with_label(strings.confirm_no);
no_btn.add_css_class("confirm-no");
no_btn.connect_clicked(clone!(
#[weak]
confirm_area,
#[strong]
confirm_box,
move |_| {
dismiss_power_confirm(&confirm_area, &confirm_box);
}
));
button_row.append(&no_btn);
new_box.append(&button_row);
confirm_area.append(&new_box);
*confirm_box.borrow_mut() = Some(new_box);
no_btn.grab_focus();
}
/// Remove the power confirmation prompt.
fn dismiss_power_confirm(confirm_area: &gtk::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
if let Some(box_widget) = confirm_box.borrow_mut().take() {
confirm_area.remove(&box_widget);
}
}
/// Execute a power action in a background thread, guarding against re-trigger.
fn execute_power_action(
action: PowerAction,
strings: &'static Strings,
power_box: &gtk::Box,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) {
dismiss_power_confirm(confirm_area, confirm_box);
let action_fn = action.action_fn;
let error_message = (action.error_attr)(strings);
// Desensitize the power buttons so a double-click or keyboard repeat cannot
// fire the same action twice while it is in flight.
power_box.set_sensitive(false);
glib::spawn_future_local(clone!(
#[weak]
power_box,
#[weak]
error_label,
async move {
let result = gio::spawn_blocking(action_fn).await;
@@ -1288,13 +1473,13 @@ fn execute_power_action(
log::error!("Power action failed: {e}");
error_label.set_text(error_message);
error_label.set_visible(true);
button.set_sensitive(true);
power_box.set_sensitive(true);
}
Err(_) => {
log::error!("Power action panicked");
error_label.set_text(error_message);
error_label.set_visible(true);
button.set_sensitive(true);
power_box.set_sensitive(true);
}
}
}
+22
View File
@@ -16,6 +16,12 @@ pub struct Strings {
pub reboot_tooltip: &'static str,
pub shutdown_tooltip: &'static str,
// Power confirmation prompts
pub reboot_confirm: &'static str,
pub shutdown_confirm: &'static str,
pub confirm_yes: &'static str,
pub confirm_no: &'static str,
// Error messages
pub no_session_selected: &'static str,
pub greetd_sock_not_set: &'static str,
@@ -39,6 +45,10 @@ const STRINGS_DE: Strings = Strings {
password_placeholder: "Passwort",
reboot_tooltip: "Neustart",
shutdown_tooltip: "Herunterfahren",
reboot_confirm: "Wirklich neu starten?",
shutdown_confirm: "Wirklich herunterfahren?",
confirm_yes: "Ja",
confirm_no: "Abbrechen",
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",
@@ -59,6 +69,10 @@ const STRINGS_EN: Strings = Strings {
password_placeholder: "Password",
reboot_tooltip: "Reboot",
shutdown_tooltip: "Shut down",
reboot_confirm: "Really reboot?",
shutdown_confirm: "Really shut down?",
confirm_yes: "Yes",
confirm_no: "Cancel",
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",
@@ -276,6 +290,10 @@ mod tests {
assert!(!s.password_placeholder.is_empty(), "{locale}: password_placeholder");
assert!(!s.reboot_tooltip.is_empty(), "{locale}: reboot_tooltip");
assert!(!s.shutdown_tooltip.is_empty(), "{locale}: shutdown_tooltip");
assert!(!s.reboot_confirm.is_empty(), "{locale}: reboot_confirm");
assert!(!s.shutdown_confirm.is_empty(), "{locale}: shutdown_confirm");
assert!(!s.confirm_yes.is_empty(), "{locale}: confirm_yes");
assert!(!s.confirm_no.is_empty(), "{locale}: confirm_no");
assert!(!s.no_session_selected.is_empty(), "{locale}: no_session_selected");
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
@@ -286,6 +304,10 @@ mod tests {
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response");
assert!(!s.greetd_sock_not_absolute.is_empty(), "{locale}: greetd_sock_not_absolute");
assert!(!s.invalid_session_command.is_empty(), "{locale}: invalid_session_command");
assert!(!s.session_start_failed.is_empty(), "{locale}: session_start_failed");
assert!(!s.socket_error.is_empty(), "{locale}: socket_error");
}
}
+19 -101
View File
@@ -1,5 +1,5 @@
// ABOUTME: Entry point for Moongreet — greetd greeter for Wayland.
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and a single greeter window.
mod config;
mod fingerprint;
@@ -28,33 +28,14 @@ fn setup_layer_shell(window: &gtk::ApplicationWindow, layer: gtk4_layer_shell::L
window.init_layer_shell();
window.set_layer(layer);
window.set_exclusive_zone(-1);
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::OnDemand);
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
// Anchor to all edges for fullscreen
window.set_anchor(gtk4_layer_shell::Edge::Top, true);
window.set_anchor(gtk4_layer_shell::Edge::Bottom, true);
window.set_anchor(gtk4_layer_shell::Edge::Left, true);
window.set_anchor(gtk4_layer_shell::Edge::Right, true);
}
/// Pick the index of the built-in display among the given connector names.
///
/// Prefers `eDP*` / `LVDS*` / `DSI*` over anything else — otherwise keyboard
/// focus can land on DisplayLink/evdi phantom displays (connector `DVI-I-*`)
/// that are enumerated before the laptop panel. Falls back to index 0 when
/// no built-in connector is present.
fn pick_primary_monitor_index<'a, I>(connectors: I) -> usize
where
I: IntoIterator<Item = Option<&'a str>>,
{
for (i, conn) in connectors.into_iter().enumerate() {
if let Some(c) = conn
&& (c.starts_with("eDP") || c.starts_with("LVDS") || c.starts_with("DSI"))
{
return i;
}
}
0
}
fn activate(app: &gtk::Application) {
let display = match gdk::Display::default() {
Some(d) => d,
@@ -81,41 +62,17 @@ fn activate(app: &gtk::Application) {
log::debug!("Layer shell: {use_layer_shell}");
if use_layer_shell {
// Single greeter window anchored to the built-in display. Other
// outputs stay under compositor control — the greeter is just a
// normal layer-shell client, no per-output keyboard grabs.
let monitors = display.monitors();
let count = monitors.n_items();
log::debug!("Monitor count: {count}");
let connectors: Vec<Option<String>> = (0..count)
.map(|i| {
monitors
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
.and_then(|m| m.connector())
.map(|gs| gs.to_string())
})
.collect();
let primary_idx = pick_primary_monitor_index(connectors.iter().map(|o| o.as_deref()));
log::debug!(
"Primary monitor: idx={primary_idx} connector={:?}",
connectors.get(primary_idx).and_then(|o| o.as_deref())
);
if let Some(monitor) = monitors
.item(primary_idx as u32)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
} else {
log::error!("Primary monitor {primary_idx} not available — greeter will not be shown");
}
// Single greeter window. No set_monitor — the compositor places it on the
// focused output (same as moonset). Exclusive keyboard binds input to this
// surface regardless of pointer position; the mouse may wander to other
// outputs but typing always reaches the greeter. The previous per-monitor
// approach gave keyboard only to the first monitor's window, so a user on
// any other output could not type the password.
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
window.present();
} else {
// No layer shell — single window for development
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
greeter_window.present();
}
@@ -132,55 +89,16 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
// Require MOONGREET_DEBUG=1 to raise verbosity. Mere presence (e.g. an
// empty value in a session-setup script) must not escalate the journal
// to Debug, which leaks socket paths, usernames, and auth round counts.
let level = match std::env::var("MOONGREET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
};
log::set_max_level(level);
}
#[cfg(test)]
mod tests {
use super::pick_primary_monitor_index;
#[test]
fn prefers_edp_over_phantom_dvi() {
assert_eq!(
pick_primary_monitor_index([Some("DVI-I-1"), Some("eDP-1"), Some("DVI-I-2")]),
1,
);
}
#[test]
fn prefers_lvds() {
assert_eq!(pick_primary_monitor_index([Some("HDMI-A-1"), Some("LVDS-1")]), 1);
}
#[test]
fn prefers_dsi() {
assert_eq!(pick_primary_monitor_index([Some("DP-1"), Some("DSI-1")]), 1);
}
#[test]
fn falls_back_to_first_without_builtin() {
assert_eq!(
pick_primary_monitor_index([Some("DVI-I-1"), Some("HDMI-A-1")]),
0,
);
}
#[test]
fn skips_missing_connector() {
assert_eq!(pick_primary_monitor_index([None, Some("eDP-1")]), 1);
}
#[test]
fn empty_returns_zero() {
assert_eq!(pick_primary_monitor_index(std::iter::empty::<Option<&str>>()), 0);
}
}
fn main() {
setup_logging();
log::info!("Moongreet starting");
+15 -6
View File
@@ -1,4 +1,4 @@
// ABOUTME: Power actions — reboot and shutdown via loginctl.
// ABOUTME: Power actions — reboot and shutdown via systemctl.
// ABOUTME: Wrappers around system commands for the greeter UI.
use std::fmt;
@@ -40,7 +40,9 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
// stdout is never read; piping without draining would deadlock on any
// command that writes more than one OS pipe buffer before wait() returns.
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {
@@ -97,14 +99,21 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
}
}
/// Reboot the system via loginctl.
/// Reboot the system via systemctl.
///
/// `--no-ask-password` keeps systemctl from spawning an interactive askpass
/// agent — the greeter session has none, so without it a denied authorization
/// would hang instead of failing fast.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
run_command("reboot", "/usr/bin/systemctl", &["--no-ask-password", "reboot"])
}
/// Shut down the system via loginctl.
/// Shut down the system via systemctl.
///
/// `--no-ask-password` for the same reason as [`reboot`] — the agent-less
/// greeter session has nothing to answer an authorization challenge.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
run_command("shutdown", "/usr/bin/systemctl", &["--no-ask-password", "poweroff"])
}
#[cfg(test)]
+11
View File
@@ -23,6 +23,8 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec_cmd: Option<String> = None;
let mut hidden = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
@@ -44,9 +46,18 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
&& exec_cmd.is_none()
{
exec_cmd = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("Hidden=") {
hidden = value.eq_ignore_ascii_case("true");
} else if let Some(value) = line.strip_prefix("NoDisplay=") {
no_display = value.eq_ignore_ascii_case("true");
}
}
if hidden || no_display {
log::debug!("Skipping {}: Hidden/NoDisplay entry", path.display());
return None;
}
let name = name.filter(|s| !s.is_empty());
let exec_cmd = exec_cmd.filter(|s| !s.is_empty());