Compare commits

..

7 Commits

Author SHA1 Message Date
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
13 changed files with 505 additions and 140 deletions
+5 -4
View File
@@ -40,12 +40,12 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- `ipc.rs` — greetd Socket-Kommunikation (4-byte LE header + JSON) - `ipc.rs` — greetd Socket-Kommunikation (4-byte LE header + JSON)
- `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection - `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection
- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files - `sessions.rs` — Wayland/X11 Sessions aus .desktop Files
- `power.rs` — Reboot/Shutdown via loginctl - `power.rs` — Reboot/Shutdown via systemctl (`--no-ask-password`)
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen - `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback - `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200) - `config.rs` — TOML-Config ([appearance] background, gtk-theme, 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) - `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Power-Confirm (Inline-Bestätigung vor Reboot/Shutdown, wie moonlock), Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor mit Hotplug via `items-changed` auf Monitor-ListModel (one greeter window per monitor, first gets keyboard), systemd-journal-logger - `main.rs` — Entry Point, GTK App, Layer Shell Setup, ein Greeter-Fenster auf dem fokussierten Output (kein `set_monitor`), `KeyboardMode::Exclusive`, systemd-journal-logger
- `resources/style.css` — Catppuccin-inspiriertes Theme - `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions ## Design Decisions
@@ -60,6 +60,7 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur) - **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
- **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt - **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt
- **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config - **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config
- **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 - **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600 - **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests - **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
Generated
+1 -1
View File
@@ -575,7 +575,7 @@ dependencies = [
[[package]] [[package]]
name = "moongreet" name = "moongreet"
version = "0.8.3" version = "0.10.1"
dependencies = [ dependencies = [
"gdk-pixbuf", "gdk-pixbuf",
"gdk4", "gdk4",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moongreet" name = "moongreet"
version = "0.8.3" version = "0.10.1"
edition = "2024" edition = "2024"
description = "A greetd greeter for Wayland with GTK4 and Layer Shell" description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
license = "MIT" license = "MIT"
+49
View File
@@ -1,5 +1,54 @@
# Decisions # Decisions
## 2026-06-02 Align power-confirm to moonset's ActionDef pattern (v0.10.1)
- **Who**: ClaudeCode, Dom
- **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-06-02 Inline power confirmation before reboot/shutdown (v0.10.0)
- **Who**: ClaudeCode, Dom
- **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-06-02 Cursor theme via GtkSettings, salvaged from unpushed work (v0.9.0)
- **Who**: ClaudeCode, Dom
- **Why**: On some machines the greeter showed the wrong (GTK-default) cursor. GTK4 under greetd does not honour `XCURSOR_THEME` reliably — niri 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-06-02 Power buttons fixed (loginctl→systemctl) + single greeter window (v0.8.7)
- **Who**: ClaudeCode, Dom
- **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) ## 2026-04-21 Ship polkit rule in moongreet instead of moonarch (v0.8.3)
- **Who**: ClaudeCode, Dom - **Who**: ClaudeCode, Dom
+5
View File
@@ -8,3 +8,8 @@ background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme name — must match a directory in /usr/share/themes/ # GTK theme name — must match a directory in /usr/share/themes/
# Required because GTK4 under greetd does not reliably read settings.ini # Required because GTK4 under greetd does not reliably read settings.ini
gtk-theme = "Colloid-Grey-Dark-Catppuccin" gtk-theme = "Colloid-Grey-Dark-Catppuccin"
# Cursor theme name — must match a directory in /usr/share/icons/
# GTK4 under greetd does not honour XCURSOR_THEME, so set it here.
cursor-theme = "Sweet-cursors"
cursor-size = 24
+1 -1
View File
@@ -1,5 +1,5 @@
// ABOUTME: Allow the greeter user to reboot and power off without authentication. // 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) { polkit.addRule(function(action, subject) {
if (subject.user === "greeter" && if (subject.user === "greeter" &&
+32
View File
@@ -82,6 +82,38 @@ window.wallpaper {
background-color: alpha(@theme_fg_color, 0.2); 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 buttons on the bottom right */
.power-button { .power-button {
min-width: 48px; min-width: 48px;
+77 -7
View File
@@ -25,6 +25,10 @@ struct Appearance {
background_blur: Option<f32>, background_blur: Option<f32>,
#[serde(rename = "gtk-theme")] #[serde(rename = "gtk-theme")]
gtk_theme: Option<String>, gtk_theme: Option<String>,
#[serde(rename = "cursor-theme")]
cursor_theme: Option<String>,
#[serde(rename = "cursor-size")]
cursor_size: Option<i32>,
#[serde(rename = "fingerprint-enabled")] #[serde(rename = "fingerprint-enabled")]
fingerprint_enabled: Option<bool>, fingerprint_enabled: Option<bool>,
} }
@@ -35,6 +39,8 @@ pub struct Config {
pub background_path: Option<String>, pub background_path: Option<String>,
pub background_blur: Option<f32>, pub background_blur: Option<f32>,
pub gtk_theme: Option<String>, pub gtk_theme: Option<String>,
pub cursor_theme: Option<String>,
pub cursor_size: Option<i32>,
pub fingerprint_enabled: bool, pub fingerprint_enabled: bool,
} }
@@ -44,6 +50,8 @@ impl Default for Config {
background_path: None, background_path: None,
background_blur: None, background_blur: None,
gtk_theme: None, gtk_theme: None,
cursor_theme: None,
cursor_size: None,
fingerprint_enabled: true, fingerprint_enabled: true,
} }
} }
@@ -68,8 +76,14 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if bg_path.is_absolute() { if bg_path.is_absolute() {
merged.background_path = Some(bg); merged.background_path = Some(bg);
} else if let Some(parent) = path.parent() { } else if let Some(parent) = path.parent() {
merged.background_path = let joined = parent.join(&bg);
Some(parent.join(&bg).to_string_lossy().to_string()); 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 { if let Some(blur) = appearance.background_blur {
@@ -82,6 +96,16 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if appearance.gtk_theme.is_some() { if appearance.gtk_theme.is_some() {
merged.gtk_theme = appearance.gtk_theme; merged.gtk_theme = appearance.gtk_theme;
} }
if appearance.cursor_theme.is_some() {
merged.cursor_theme = appearance.cursor_theme;
}
if let Some(size) = appearance.cursor_size {
if (1..=256).contains(&size) {
merged.cursor_size = Some(size);
} else {
log::warn!("Ignoring cursor-size out of range (1256): {size}");
}
}
if let Some(fp) = appearance.fingerprint_enabled { if let Some(fp) = appearance.fingerprint_enabled {
merged.fingerprint_enabled = fp; merged.fingerprint_enabled = fp;
} }
@@ -98,7 +122,15 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
} }
} }
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}, fingerprint={}", merged.background_path, merged.background_blur, merged.gtk_theme, merged.fingerprint_enabled); log::debug!(
"Config result: background={:?}, blur={:?}, gtk_theme={:?}, cursor_theme={:?}, cursor_size={:?}, fingerprint={}",
merged.background_path,
merged.background_blur,
merged.gtk_theme,
merged.cursor_theme,
merged.cursor_size,
merged.fingerprint_enabled
);
merged merged
} }
@@ -123,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()); log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display());
} }
// Moonarch ecosystem default // Moonarch ecosystem default — apply the same symlink rejection as the
if moonarch_wallpaper.is_file() { // user-configured path for defense in depth. The fallback target is a
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display()); // system file, but the caller consumes the result via the same path.
return Some(moonarch_wallpaper.to_path_buf()); 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"); log::debug!("Wallpaper: no wallpaper found, using GTK background color");
@@ -321,6 +357,40 @@ mod tests {
assert!(config.background_blur.is_none()); assert!(config.background_blur.is_none());
} }
// -- Cursor theme tests --
#[test]
fn load_config_cursor_theme_and_size() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(
&conf,
"[appearance]\ncursor-theme = \"Sweet-cursors\"\ncursor-size = 32\n",
)
.unwrap();
let config = load_config(Some(&[conf]));
assert_eq!(config.cursor_theme.as_deref(), Some("Sweet-cursors"));
assert_eq!(config.cursor_size, Some(32));
}
#[test]
fn load_config_cursor_size_out_of_range_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\ncursor-size = 9999\n").unwrap();
let config = load_config(Some(&[conf]));
assert!(config.cursor_size.is_none());
}
#[test]
fn load_config_cursor_size_zero_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\ncursor-size = 0\n").unwrap();
let config = load_config(Some(&[conf]));
assert!(config.cursor_size.is_none());
}
#[test] #[test]
fn load_config_blur_inf_rejected() { fn load_config_blur_inf_rejected() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+268 -66
View File
@@ -22,8 +22,8 @@ use crate::sessions::{self, Session};
use crate::users::{self, User}; use crate::users::{self, User};
const AVATAR_SIZE: i32 = 128; const AVATAR_SIZE: i32 = 128;
const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024; const MAX_AVATAR_FILE_SIZE: u64 = 5 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 1024 * 1024; const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user"; const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user";
const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session"; const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session";
const MAX_USERNAME_LENGTH: usize = 256; const MAX_USERNAME_LENGTH: usize = 256;
@@ -189,7 +189,7 @@ fn render_blurred_texture(
} }
/// Create a Picture widget for the wallpaper background, optionally with GPU blur. /// 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( fn create_background_picture(
texture: &gdk::Texture, texture: &gdk::Texture,
blur_radius: Option<f32>, blur_radius: Option<f32>,
@@ -233,6 +233,9 @@ struct GreeterState {
user_switch_generation: u64, user_switch_generation: u64,
/// Cached fprintd device proxy — initialized once on first use. /// Cached fprintd device proxy — initialized once on first use.
fingerprint_probe: Option<crate::fingerprint::FingerprintProbe>, fingerprint_probe: Option<crate::fingerprint::FingerprintProbe>,
/// True while a probe init_async() is in flight. Prevents duplicate D-Bus
/// init when two user-switch probes race (both see probe == None).
fingerprint_probe_initializing: bool,
} }
/// Create the main greeter window with login UI. /// Create the main greeter window with login UI.
@@ -259,6 +262,23 @@ pub fn create_greeter_window(
} }
} }
// Apply cursor theme from config — GTK4 under greetd does not read XCURSOR_THEME
// reliably, so set the gtk-cursor-theme-name property directly.
if let Some(ref cursor) = config.cursor_theme {
if is_valid_gtk_theme(cursor) {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_cursor_theme_name(Some(cursor));
}
} else {
log::warn!("Ignoring invalid cursor theme name: {cursor}");
}
}
if let Some(size) = config.cursor_size {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_cursor_theme_size(size);
}
}
let strings = load_strings(None); let strings = load_strings(None);
let fingerprint_enabled = config.fingerprint_enabled; let fingerprint_enabled = config.fingerprint_enabled;
let all_users = users::get_users(None); let all_users = users::get_users(None);
@@ -282,6 +302,7 @@ pub fn create_greeter_window(
fingerprint_available: false, fingerprint_available: false,
user_switch_generation: 0, user_switch_generation: 0,
fingerprint_probe: None, fingerprint_probe: None,
fingerprint_probe_initializing: false,
})); }));
// Root overlay for layering // Root overlay for layering
@@ -352,6 +373,12 @@ pub fn create_greeter_window(
error_label.set_visible(false); error_label.set_visible(false);
login_box.append(&error_label); 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) // Fingerprint label (hidden until probe confirms availability)
let fp_label = gtk::Label::new(None); let fp_label = gtk::Label::new(None);
fp_label.add_css_class("fingerprint-label"); fp_label.add_css_class("fingerprint-label");
@@ -407,7 +434,12 @@ pub fn create_greeter_window(
state, state,
#[strong] #[strong]
sessions_rc, sessions_rc,
#[weak]
confirm_area,
#[strong]
confirm_box,
move |_| { move |_| {
dismiss_power_confirm(&confirm_area, &confirm_box);
cancel_pending_session(&state); cancel_pending_session(&state);
switch_to_user( switch_to_user(
&user_clone, &user_clone,
@@ -440,33 +472,17 @@ pub fn create_greeter_window(
power_box.set_halign(gtk::Align::End); power_box.set_halign(gtk::Align::End);
power_box.set_valign(gtk::Align::End); power_box.set_valign(gtk::Align::End);
let reboot_btn = gtk::Button::new(); for action in power_actions() {
reboot_btn.set_icon_name("system-reboot-symbolic"); let button = create_power_button(
reboot_btn.add_css_class("power-button"); action,
reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip)); strings,
reboot_btn.connect_clicked(clone!( &power_box,
#[weak] &confirm_area,
error_label, &confirm_box,
move |btn| { &error_label,
btn.set_sensitive(false); );
execute_power_action(power::reboot, strings.reboot_failed, &error_label, btn); power_box.append(&button);
} }
));
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);
bottom_bar.append(&power_box); bottom_bar.append(&power_box);
overlay.add_overlay(&bottom_bar); overlay.add_overlay(&bottom_bar);
@@ -493,6 +509,10 @@ pub fn create_greeter_window(
let Some(user) = user else { return }; let Some(user) = user else { return };
let password = Zeroizing::new(entry.text().to_string()); let password = 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 session = get_selected_session(&session_dropdown, &sessions_rc);
let Some(session) = session else { let Some(session) = session else {
@@ -502,7 +522,7 @@ pub fn create_greeter_window(
attempt_login( attempt_login(
&user, &user,
&password, password,
&session, &session,
strings, strings,
&state, &state,
@@ -514,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(); let key_controller = gtk::EventControllerKey::new();
key_controller.connect_key_pressed(clone!( key_controller.connect_key_pressed(clone!(
#[weak] #[weak]
password_entry, password_entry,
#[weak] #[weak]
error_label, error_label,
#[weak]
confirm_area,
#[strong]
confirm_box,
#[upgrade_or] #[upgrade_or]
glib::Propagation::Proceed, glib::Propagation::Proceed,
move |_, keyval, _, _| { move |_, keyval, _, _| {
if keyval == gdk::Key::Escape { if keyval == gdk::Key::Escape {
dismiss_power_confirm(&confirm_area, &confirm_box);
password_entry.set_text(""); password_entry.set_text("");
error_label.set_visible(false); error_label.set_visible(false);
glib::Propagation::Stop glib::Propagation::Stop
@@ -716,12 +741,33 @@ fn switch_to_user(
#[strong] #[strong]
state, state,
async move { async move {
// Initialize probe on first use, then reuse cached device proxy // Initialize probe on first use, then reuse cached device proxy.
let needs_init = state.borrow().fingerprint_probe.is_none(); // Atomic check-and-set on fingerprint_probe_initializing prevents
if needs_init { // 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(); let mut probe = crate::fingerprint::FingerprintProbe::new();
probe.init_async().await; 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 // Take probe out of state to avoid holding borrow across await
@@ -778,28 +824,40 @@ fn set_avatar_from_file(
Ok(_) => {} Ok(_) => {}
} }
let Some(path_str) = path.to_str() else { // Show fallback immediately; decode asynchronously via GIO so the greeter
log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display()); // stays responsive during a user-switch click.
image.set_icon_name(Some("avatar-default-symbolic")); image.set_icon_name(Some("avatar-default-symbolic"));
return;
};
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) { let display_path = path.to_path_buf();
Ok(pixbuf) => { let file = gio::File::for_path(path);
let texture = gdk::Texture::for_pixbuf(&pixbuf); let image_clone = image.clone();
if let Some(name) = username { let state_clone = state.clone();
state let username_owned = username.map(String::from);
.borrow_mut()
.avatar_cache glib::spawn_future_local(async move {
.insert(name.to_string(), texture.clone()); 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. /// Load the default avatar SVG from GResources, tinted with the foreground color.
@@ -953,7 +1011,7 @@ fn set_login_sensitive(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn attempt_login( fn attempt_login(
user: &User, user: &User,
password: &str, password: Zeroizing<String>,
session: &Session, session: &Session,
strings: &'static Strings, strings: &'static Strings,
state: &Rc<RefCell<GreeterState>>, state: &Rc<RefCell<GreeterState>>,
@@ -992,7 +1050,6 @@ fn attempt_login(
set_login_sensitive(password_entry, session_dropdown, false); set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone(); let username = user.username.clone();
let password = Zeroizing::new(password.to_string());
let exec_cmd = session.exec_cmd.clone(); let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone(); let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone(); let greetd_sock = state.borrow().greetd_sock.clone();
@@ -1033,6 +1090,13 @@ fn attempt_login(
glib::timeout_future(min_response - elapsed).await; glib::timeout_future(min_response - elapsed).await;
} }
// The login_worker's own socket is already dropped by now; drop the
// shared clone too so repeated failed attempts do not accumulate
// stale file descriptors in state.greetd_sock.
if let Ok(mut g) = state.borrow().greetd_sock.lock() {
g.take();
}
match result { match result {
Ok(Ok(LoginResult::Success { username })) => { Ok(Ok(LoginResult::Success { username })) => {
save_last_user(&username); save_last_user(&username);
@@ -1250,18 +1314,156 @@ fn login_worker(
}) })
} }
/// Execute a power action in a background thread. /// Definition for a single power-action button (reboot, shutdown).
fn execute_power_action( /// 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>, 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, error_label: &gtk::Label,
button: &gtk::Button, ) -> gtk::Button {
) { let button = gtk::Button::new();
glib::spawn_future_local(clone!( 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] #[weak]
error_label, 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] #[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 { async move {
let result = gio::spawn_blocking(action_fn).await; let result = gio::spawn_blocking(action_fn).await;
@@ -1271,13 +1473,13 @@ fn execute_power_action(
log::error!("Power action failed: {e}"); log::error!("Power action failed: {e}");
error_label.set_text(error_message); error_label.set_text(error_message);
error_label.set_visible(true); error_label.set_visible(true);
button.set_sensitive(true); power_box.set_sensitive(true);
} }
Err(_) => { Err(_) => {
log::error!("Power action panicked"); log::error!("Power action panicked");
error_label.set_text(error_message); error_label.set_text(error_message);
error_label.set_visible(true); 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 reboot_tooltip: &'static str,
pub shutdown_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 // Error messages
pub no_session_selected: &'static str, pub no_session_selected: &'static str,
pub greetd_sock_not_set: &'static str, pub greetd_sock_not_set: &'static str,
@@ -39,6 +45,10 @@ const STRINGS_DE: Strings = Strings {
password_placeholder: "Passwort", password_placeholder: "Passwort",
reboot_tooltip: "Neustart", reboot_tooltip: "Neustart",
shutdown_tooltip: "Herunterfahren", 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", no_session_selected: "Keine Session ausgewählt",
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt", greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad", greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
@@ -59,6 +69,10 @@ const STRINGS_EN: Strings = Strings {
password_placeholder: "Password", password_placeholder: "Password",
reboot_tooltip: "Reboot", reboot_tooltip: "Reboot",
shutdown_tooltip: "Shut down", 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", no_session_selected: "No session selected",
greetd_sock_not_set: "GREETD_SOCK not set", greetd_sock_not_set: "GREETD_SOCK not set",
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path", greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
@@ -276,6 +290,10 @@ mod tests {
assert!(!s.password_placeholder.is_empty(), "{locale}: password_placeholder"); assert!(!s.password_placeholder.is_empty(), "{locale}: password_placeholder");
assert!(!s.reboot_tooltip.is_empty(), "{locale}: reboot_tooltip"); assert!(!s.reboot_tooltip.is_empty(), "{locale}: reboot_tooltip");
assert!(!s.shutdown_tooltip.is_empty(), "{locale}: shutdown_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.no_session_selected.is_empty(), "{locale}: no_session_selected");
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set"); assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed"); assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
@@ -286,6 +304,10 @@ mod tests {
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining"); assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked"); assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response"); assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response");
assert!(!s.greetd_sock_not_absolute.is_empty(), "{locale}: greetd_sock_not_absolute");
assert!(!s.invalid_session_command.is_empty(), "{locale}: invalid_session_command");
assert!(!s.session_start_failed.is_empty(), "{locale}: session_start_failed");
assert!(!s.socket_error.is_empty(), "{locale}: socket_error");
} }
} }
+18 -54
View File
@@ -1,5 +1,5 @@
// ABOUTME: Entry point for Moongreet — greetd greeter for Wayland. // 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 config;
mod fingerprint; mod fingerprint;
@@ -11,11 +11,9 @@ mod sessions;
mod users; mod users;
use gdk4 as gdk; use gdk4 as gdk;
use glib::clone;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell; use gtk4_layer_shell::LayerShell;
use std::rc::Rc;
fn load_css(display: &gdk::Display) { fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new(); let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css"); css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
@@ -26,13 +24,11 @@ fn load_css(display: &gdk::Display) {
); );
} }
fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) { fn setup_layer_shell(window: &gtk::ApplicationWindow, layer: gtk4_layer_shell::Layer) {
window.init_layer_shell(); window.init_layer_shell();
window.set_layer(layer); window.set_layer(layer);
window.set_exclusive_zone(-1); window.set_exclusive_zone(-1);
if keyboard { window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
}
// Anchor to all edges for fullscreen // Anchor to all edges for fullscreen
window.set_anchor(gtk4_layer_shell::Edge::Top, true); 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::Bottom, true);
@@ -66,49 +62,15 @@ fn activate(app: &gtk::Application) {
log::debug!("Layer shell: {use_layer_shell}"); log::debug!("Layer shell: {use_layer_shell}");
if use_layer_shell { if use_layer_shell {
// One greeter window per monitor — only the first gets keyboard input // Single greeter window. No set_monitor — the compositor places it on the
let monitors = display.monitors(); // focused output (same as moonset). Exclusive keyboard binds input to this
log::debug!("Monitor count: {}", monitors.n_items()); // surface regardless of pointer position; the mouse may wander to other
let mut first = true; // outputs but typing always reaches the greeter. The previous per-monitor
for i in 0..monitors.n_items() { // approach gave keyboard only to the first monitor's window, so a user on
if let Some(monitor) = monitors // any other output could not type the password.
.item(i) let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
{ window.present();
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
setup_layer_shell(&window, first, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
first = false;
}
}
// Handle monitor hotplug — create greeter windows for newly added monitors
// (without keyboard, since the primary monitor already has it)
let bg_texture = Rc::new(bg_texture);
let config = Rc::new(config);
monitors.connect_items_changed(clone!(
#[weak]
app,
#[strong]
blur_cache,
move |list, position, _removed, added| {
for i in position..position + added {
if let Some(monitor) = list
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
log::debug!("Monitor hotplug: creating greeter window");
let window = greeter::create_greeter_window(
bg_texture.as_ref().as_ref(), &config, &blur_cache, &app,
);
setup_layer_shell(&window, false, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
}
}
}
));
} else { } else {
// No layer shell — single window for development // No layer shell — single window for development
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app); let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
@@ -127,10 +89,12 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}"); eprintln!("Failed to create journal logger: {e}");
} }
} }
let level = if std::env::var("MOONGREET_DEBUG").is_ok() { // Require MOONGREET_DEBUG=1 to raise verbosity. Mere presence (e.g. an
log::LevelFilter::Debug // empty value in a session-setup script) must not escalate the journal
} else { // to Debug, which leaks socket paths, usernames, and auth round counts.
log::LevelFilter::Info let level = match std::env::var("MOONGREET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
}; };
log::set_max_level(level); log::set_max_level(level);
} }
+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. // ABOUTME: Wrappers around system commands for the greeter UI.
use std::fmt; 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:?})"); log::debug!("Power action: {action} ({program} {args:?})");
let mut child = Command::new(program) let mut child = Command::new(program)
.args(args) .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()) .stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| PowerError::CommandFailed { .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> { 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> { pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"]) run_command("shutdown", "/usr/bin/systemctl", &["--no-ask-password", "poweroff"])
} }
#[cfg(test)] #[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 in_section = false;
let mut name: Option<String> = None; let mut name: Option<String> = None;
let mut exec_cmd: Option<String> = None; let mut exec_cmd: Option<String> = None;
let mut hidden = false;
let mut no_display = false;
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
@@ -44,9 +46,18 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
&& exec_cmd.is_none() && exec_cmd.is_none()
{ {
exec_cmd = Some(value.to_string()); exec_cmd = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("Hidden=") {
hidden = value.eq_ignore_ascii_case("true");
} else if let Some(value) = line.strip_prefix("NoDisplay=") {
no_display = value.eq_ignore_ascii_case("true");
} }
} }
if hidden || no_display {
log::debug!("Skipping {}: Hidden/NoDisplay entry", path.display());
return None;
}
let name = name.filter(|s| !s.is_empty()); let name = name.filter(|s| !s.is_empty());
let exec_cmd = exec_cmd.filter(|s| !s.is_empty()); let exec_cmd = exec_cmd.filter(|s| !s.is_empty());