Compare commits
4 Commits
main
..
ce9f2196ca
| Author | SHA1 | Date | |
|---|---|---|---|
| ce9f2196ca | |||
| 29ce185886 | |||
| 91b4289748 | |||
| 97165d94f8 |
@@ -1,22 +1,22 @@
|
|||||||
# ABOUTME: Updates pkgver in moonarch-pkgbuilds when a new moongreet tag is pushed.
|
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
|
||||||
# ABOUTME: Reads the latest version tag and bumps the PKGBUILD + .SRCINFO.
|
# ABOUTME: Ensures paru detects new versions of this package.
|
||||||
|
|
||||||
name: Update PKGBUILD version
|
name: Update PKGBUILD version
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- 'v*'
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-pkgver:
|
update-pkgver:
|
||||||
runs-on: moonarch
|
runs-on: moonarch
|
||||||
steps:
|
steps:
|
||||||
- name: Determine pkgver from latest tag
|
- name: Checkout source repo
|
||||||
run: |
|
run: |
|
||||||
git clone --bare http://gitea:3000/nevaforget/greetd-moongreet.git source.git
|
git clone --bare http://gitea:3000/nevaforget/greetd-moongreet.git source.git
|
||||||
cd source.git
|
cd source.git
|
||||||
PKGVER=$(git describe --tags --abbrev=0 | sed 's/^v//')
|
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
|
||||||
echo "New pkgver: $PKGVER"
|
echo "New pkgver: $PKGVER"
|
||||||
echo "$PKGVER" > /tmp/pkgver
|
echo "$PKGVER" > /tmp/pkgver
|
||||||
|
|
||||||
@@ -26,18 +26,18 @@ jobs:
|
|||||||
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
|
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
|
||||||
cd pkgbuilds
|
cd pkgbuilds
|
||||||
|
|
||||||
OLD_VER=$(grep '^pkgver=' moongreet/PKGBUILD | cut -d= -f2)
|
OLD_VER=$(grep '^pkgver=' moongreet-git/PKGBUILD | cut -d= -f2)
|
||||||
if [ "$OLD_VER" = "$PKGVER" ]; then
|
if [ "$OLD_VER" = "$PKGVER" ]; then
|
||||||
echo "pkgver already up to date ($PKGVER)"
|
echo "pkgver already up to date ($PKGVER)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moongreet/PKGBUILD
|
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moongreet-git/PKGBUILD
|
||||||
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moongreet/.SRCINFO
|
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moongreet-git/.SRCINFO
|
||||||
echo "Updated pkgver: $OLD_VER → $PKGVER"
|
echo "Updated pkgver: $OLD_VER → $PKGVER"
|
||||||
|
|
||||||
git config user.name "pkgver-bot"
|
git config user.name "pkgver-bot"
|
||||||
git config user.email "gitea@moonarch.de"
|
git config user.email "gitea@moonarch.de"
|
||||||
git add moongreet/PKGBUILD moongreet/.SRCINFO
|
git add moongreet-git/PKGBUILD moongreet-git/.SRCINFO
|
||||||
git commit -m "chore(moongreet): bump pkgver to $PKGVER"
|
git commit -m "chore(moongreet-git): bump pkgver to $PKGVER"
|
||||||
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
|
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
# Moongreet
|
# Moongreet
|
||||||
|
|
||||||
## Project
|
## Projekt
|
||||||
|
|
||||||
Moongreet is a greetd greeter for Wayland, built with Rust + gtk4-rs + gtk4-layer-shell.
|
Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
|
||||||
Part of the Moonarch ecosystem.
|
Teil des Moonarch-Ökosystems.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech-Stack
|
||||||
|
|
||||||
- Rust (edition 2024), gtk4-rs 0.11, glib 0.22
|
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
|
||||||
- gtk4-layer-shell 0.8 for the Wayland Layer Shell (TOP layer)
|
- gtk4-layer-shell 0.8 für Wayland Layer Shell (TOP Layer)
|
||||||
- greetd IPC over a Unix domain socket (length-prefixed JSON)
|
- greetd IPC über Unix Domain Socket (length-prefixed JSON)
|
||||||
- `cargo test` for unit tests
|
- `cargo test` für Unit-Tests
|
||||||
|
|
||||||
## Project Structure
|
## Projektstruktur
|
||||||
|
|
||||||
- `src/` — Rust source code (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
|
- `src/` — Rust-Quellcode (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
|
||||||
- `resources/` — GResource assets (style.css, default-avatar.svg)
|
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
|
||||||
- `config/` — example configuration files for `/etc/moongreet/` and `/etc/greetd/`
|
- `config/` — Beispiel-Konfigurationsdateien für `/etc/moongreet/` und `/etc/greetd/`
|
||||||
- `pkg/` — PKGBUILD for Arch Linux packaging (`makepkg -sf`)
|
- `pkg/` — PKGBUILD für Arch-Linux-Paketierung (`makepkg -sf`)
|
||||||
|
|
||||||
## Commands
|
## Kommandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run tests
|
# Tests ausführen
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Release build
|
# Release-Build
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Start the greeter in a window (without greetd/Layer Shell)
|
# Greeter im Fenster starten (ohne greetd/Layer Shell)
|
||||||
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
|
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
|
||||||
|
|
||||||
# Build and install the package
|
# Paket bauen und installieren
|
||||||
cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.zst
|
cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architektur
|
||||||
|
|
||||||
- `ipc.rs` — greetd socket communication (4-byte LE header + JSON)
|
- `ipc.rs` — greetd Socket-Kommunikation (4-byte LE header + JSON)
|
||||||
- `users.rs` — users from /etc/passwd, avatars (AccountsService + ~/.face), symlink rejection
|
- `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection
|
||||||
- `sessions.rs` — Wayland/X11 sessions from .desktop files
|
- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files
|
||||||
- `power.rs` — reboot/shutdown via systemctl (`--no-ask-password`)
|
- `power.rs` — Reboot/Shutdown via loginctl
|
||||||
- `i18n.rs` — locale detection (LANG / /etc/locale.conf) and string tables (DE/EN), all UI and login error messages
|
- `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) — device detection and enrollment check for 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, cursor-theme, cursor-size, fingerprint-enabled) + wallpaper fallback + blur validation (finite, clamp 0–200) + cursor-size validation (range 1–256)
|
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, cursor-theme, cursor-size, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0–200) + Cursor-Size-Validierung (range 1–256)
|
||||||
- `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)
|
- `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, one greeter window on the focused output (no `set_monitor`), `KeyboardMode::Exclusive`, systemd-journal-logger
|
- `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-inspired theme
|
- `resources/style.css` — Catppuccin-inspiriertes Theme
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
- **TOP layer instead of OVERLAY**: the greeter runs under greetd, not above Waybar
|
- **TOP Layer statt OVERLAY**: Greeter läuft unter greetd, nicht über Waybar
|
||||||
- **GResource bundle**: CSS, wallpaper and default avatar are compiled into the binary
|
- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert
|
||||||
- **Async login**: `glib::spawn_future_local` + `gio::spawn_blocking` instead of raw threads
|
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
|
||||||
- **Socket cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` for clean cancellation
|
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
|
||||||
- **Avatar cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
|
- **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
|
||||||
- **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).
|
- **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 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.
|
- **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.
|
||||||
- **Symmetry with moonlock/moonset**: same patterns (i18n, config, users, power, GResource, GPU blur)
|
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
|
||||||
- **Session validation**: relative paths allowed (greetd resolves PATH), only `..`/null bytes are rejected
|
- **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt
|
||||||
- **GTK theme validation**: only alphanumeric + `_-+.` allowed, prevents path traversal via config
|
- **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über 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.
|
- **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` instead of file logging — `journalctl -t moongreet`, debug level via the `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 directories 0o700 via `DirBuilder::mode()`, cache files 0o600
|
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
|
||||||
- **Testable persistence**: `save_*_to`/`load_*_from` variants with a configurable path for unit tests
|
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für 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
|
- **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 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`
|
- **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 filtering**: GDK/greetd error details only at `debug!` level, `warn!` without internal details — prevents system-info leak into the journal
|
- **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.0–v0.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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -575,7 +575,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moongreet"
|
name = "moongreet"
|
||||||
version = "0.10.1"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk-pixbuf",
|
"gdk-pixbuf",
|
||||||
"gdk4",
|
"gdk4",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moongreet"
|
name = "moongreet"
|
||||||
version = "0.10.1"
|
version = "0.10.0"
|
||||||
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"
|
||||||
|
|||||||
+16
-37
@@ -1,53 +1,32 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
## 2026-06-02 – Align power-confirm to moonset's ActionDef pattern (v0.10.1)
|
## 2026-04-24 – Single greeter window, no per-output keyboard grab (v0.10.0)
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
- **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.
|
- **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**: 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.
|
- **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**: 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.
|
- **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.
|
||||||
|
|
||||||
## 2026-06-02 – Inline power confirmation before reboot/shutdown (v0.10.0)
|
## 2026-04-24 – Cursor theme via config instead of env (v0.9.0)
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
- **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.
|
- **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**: 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.
|
- **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**: 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.
|
- **How**: `config.rs` gains `cursor_theme: Option<String>` and `cursor_size: Option<i32>` (range-validated 1–256). `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.
|
||||||
|
|
||||||
## 2026-06-02 – Cursor theme via GtkSettings, salvaged from unpushed work (v0.9.0)
|
## 2026-04-23 – Wallpaper-only windows on secondary monitors (v0.8.5)
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
- **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).
|
- **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-1 — Niri 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**: 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`.
|
- **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**: `config.rs` gains `cursor_theme: Option<String>` and `cursor_size: Option<i32>` (range-validated 1–256). `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.
|
- **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`.
|
||||||
|
|
||||||
## 2026-06-02 – Power buttons fixed (loginctl→systemctl) + single greeter window (v0.8.7)
|
## 2026-04-23 – Keyboard focus on built-in display, not first enumerated monitor (v0.8.4)
|
||||||
|
|
||||||
- **Who**: ClaudeCode, Dom
|
- **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.
|
- **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.0 → 0.8.2 changed evdi enumeration timing — previous Thursdays with the same dock worked.
|
||||||
- **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.
|
- **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**: (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.
|
- **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.
|
||||||
|
|
||||||
## 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)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Part of the Moonarch ecosystem.
|
|||||||
- **Last user/session** — Remembered in `/var/cache/moongreet/`
|
- **Last user/session** — Remembered in `/var/cache/moongreet/`
|
||||||
- **Power actions** — Reboot / Shutdown via `loginctl`
|
- **Power actions** — Reboot / Shutdown via `loginctl`
|
||||||
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
|
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
|
||||||
- **Multi-monitor + hotplug** — Full greeter UI on all monitors (keyboard input on first), hotplugged monitors get windows automatically
|
- **Multi-monitor + hotplug** — Login UI on the built-in display, wallpaper-only on other monitors; hotplugged monitors get wallpaper windows automatically
|
||||||
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
|
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
|
||||||
- **i18n** — German and English (auto-detected from system locale)
|
- **i18n** — German and English (auto-detected from system locale)
|
||||||
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3
|
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3
|
||||||
|
|||||||
@@ -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: Safety net for the agent-less greeter session — no askpass/polkit agent to answer a challenge.
|
// ABOUTME: Required because greetd's greeter session is inactive in logind.
|
||||||
|
|
||||||
polkit.addRule(function(action, subject) {
|
polkit.addRule(function(action, subject) {
|
||||||
if (subject.user === "greeter" &&
|
if (subject.user === "greeter" &&
|
||||||
|
|||||||
@@ -82,38 +82,6 @@ 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;
|
||||||
|
|||||||
+4
-14
@@ -76,14 +76,8 @@ 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() {
|
||||||
let joined = parent.join(&bg);
|
merged.background_path =
|
||||||
match joined.to_str() {
|
Some(parent.join(&bg).to_string_lossy().to_string());
|
||||||
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 {
|
||||||
@@ -155,15 +149,11 @@ 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 — apply the same symlink rejection as the
|
// Moonarch ecosystem default
|
||||||
// user-configured path for defense in depth. The fallback target is a
|
if moonarch_wallpaper.is_file() {
|
||||||
// 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());
|
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
|
||||||
return Some(moonarch_wallpaper.to_path_buf());
|
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");
|
||||||
None
|
None
|
||||||
|
|||||||
+55
-240
@@ -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 = 5 * 1024 * 1024;
|
const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||||
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
const MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 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 and reuse it.
|
/// Uses `blur_cache` to compute the blurred texture only once across all monitors.
|
||||||
fn create_background_picture(
|
fn create_background_picture(
|
||||||
texture: &gdk::Texture,
|
texture: &gdk::Texture,
|
||||||
blur_radius: Option<f32>,
|
blur_radius: Option<f32>,
|
||||||
@@ -233,9 +233,6 @@ 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.
|
||||||
@@ -302,7 +299,6 @@ 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
|
||||||
@@ -373,12 +369,6 @@ 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");
|
||||||
@@ -434,12 +424,7 @@ 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,
|
||||||
@@ -472,17 +457,33 @@ 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);
|
||||||
|
|
||||||
for action in power_actions() {
|
let reboot_btn = gtk::Button::new();
|
||||||
let button = create_power_button(
|
reboot_btn.set_icon_name("system-reboot-symbolic");
|
||||||
action,
|
reboot_btn.add_css_class("power-button");
|
||||||
strings,
|
reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip));
|
||||||
&power_box,
|
reboot_btn.connect_clicked(clone!(
|
||||||
&confirm_area,
|
#[weak]
|
||||||
&confirm_box,
|
error_label,
|
||||||
&error_label,
|
move |btn| {
|
||||||
);
|
btn.set_sensitive(false);
|
||||||
power_box.append(&button);
|
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);
|
||||||
|
|
||||||
bottom_bar.append(&power_box);
|
bottom_bar.append(&power_box);
|
||||||
overlay.add_overlay(&bottom_bar);
|
overlay.add_overlay(&bottom_bar);
|
||||||
@@ -509,10 +510,6 @@ 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 {
|
||||||
@@ -522,7 +519,7 @@ pub fn create_greeter_window(
|
|||||||
|
|
||||||
attempt_login(
|
attempt_login(
|
||||||
&user,
|
&user,
|
||||||
password,
|
&password,
|
||||||
&session,
|
&session,
|
||||||
strings,
|
strings,
|
||||||
&state,
|
&state,
|
||||||
@@ -534,22 +531,17 @@ pub fn create_greeter_window(
|
|||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|
||||||
// Keyboard handling — Escape clears password, error, and any open power confirm
|
// Keyboard handling — Escape clears password and error
|
||||||
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
|
||||||
@@ -741,33 +733,12 @@ 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
|
||||||
// Atomic check-and-set on fingerprint_probe_initializing prevents
|
let needs_init = state.borrow().fingerprint_probe.is_none();
|
||||||
// two concurrent probes (from a fast user switch) from both
|
if needs_init {
|
||||||
// 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;
|
||||||
let mut s = state.borrow_mut();
|
state.borrow_mut().fingerprint_probe = Some(probe);
|
||||||
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
|
||||||
@@ -824,40 +795,28 @@ fn set_avatar_from_file(
|
|||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show fallback immediately; decode asynchronously via GIO so the greeter
|
let Some(path_str) = path.to_str() else {
|
||||||
// stays responsive during a user-switch click.
|
log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display());
|
||||||
image.set_icon_name(Some("avatar-default-symbolic"));
|
image.set_icon_name(Some("avatar-default-symbolic"));
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
match Pixbuf::from_stream_at_scale_future(&stream, AVATAR_SIZE, AVATAR_SIZE, true).await {
|
|
||||||
|
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
|
||||||
Ok(pixbuf) => {
|
Ok(pixbuf) => {
|
||||||
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
||||||
if let Some(ref name) = username_owned {
|
if let Some(name) = username {
|
||||||
state_clone
|
state
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.avatar_cache
|
.avatar_cache
|
||||||
.insert(name.clone(), texture.clone());
|
.insert(name.to_string(), texture.clone());
|
||||||
}
|
}
|
||||||
image_clone.set_paintable(Some(&texture));
|
image.set_paintable(Some(&texture));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("Failed to decode avatar {}: {e}", display_path.display());
|
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.
|
||||||
@@ -1011,7 +970,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: Zeroizing<String>,
|
password: &str,
|
||||||
session: &Session,
|
session: &Session,
|
||||||
strings: &'static Strings,
|
strings: &'static Strings,
|
||||||
state: &Rc<RefCell<GreeterState>>,
|
state: &Rc<RefCell<GreeterState>>,
|
||||||
@@ -1050,6 +1009,7 @@ 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();
|
||||||
@@ -1090,13 +1050,6 @@ 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);
|
||||||
@@ -1314,156 +1267,18 @@ fn login_worker(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Definition for a single power-action button (reboot, shutdown).
|
/// Execute a power action in a background thread.
|
||||||
/// 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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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: >k::Box,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::Label,
|
|
||||||
) -> 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: >k::Box,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::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]
|
|
||||||
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: >k::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(
|
fn execute_power_action(
|
||||||
action: PowerAction,
|
action_fn: fn() -> Result<(), PowerError>,
|
||||||
strings: &'static Strings,
|
error_message: &'static str,
|
||||||
power_box: >k::Box,
|
|
||||||
confirm_area: >k::Box,
|
|
||||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
||||||
error_label: >k::Label,
|
error_label: >k::Label,
|
||||||
|
button: >k::Button,
|
||||||
) {
|
) {
|
||||||
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!(
|
glib::spawn_future_local(clone!(
|
||||||
#[weak]
|
|
||||||
power_box,
|
|
||||||
#[weak]
|
#[weak]
|
||||||
error_label,
|
error_label,
|
||||||
|
#[weak]
|
||||||
|
button,
|
||||||
async move {
|
async move {
|
||||||
let result = gio::spawn_blocking(action_fn).await;
|
let result = gio::spawn_blocking(action_fn).await;
|
||||||
|
|
||||||
@@ -1473,13 +1288,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);
|
||||||
power_box.set_sensitive(true);
|
button.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);
|
||||||
power_box.set_sensitive(true);
|
button.set_sensitive(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-22
@@ -16,12 +16,6 @@ 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,
|
||||||
@@ -45,10 +39,6 @@ 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",
|
||||||
@@ -69,10 +59,6 @@ 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",
|
||||||
@@ -290,10 +276,6 @@ 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");
|
||||||
@@ -304,10 +286,6 @@ 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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+98
-16
@@ -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 a single greeter window.
|
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod fingerprint;
|
mod fingerprint;
|
||||||
@@ -28,14 +28,33 @@ fn setup_layer_shell(window: >k::ApplicationWindow, layer: gtk4_layer_shell::L
|
|||||||
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);
|
||||||
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
|
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::OnDemand);
|
||||||
// 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);
|
||||||
window.set_anchor(gtk4_layer_shell::Edge::Left, true);
|
window.set_anchor(gtk4_layer_shell::Edge::Left, true);
|
||||||
window.set_anchor(gtk4_layer_shell::Edge::Right, 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: >k::Application) {
|
fn activate(app: >k::Application) {
|
||||||
let display = match gdk::Display::default() {
|
let display = match gdk::Display::default() {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
@@ -62,17 +81,41 @@ fn activate(app: >k::Application) {
|
|||||||
log::debug!("Layer shell: {use_layer_shell}");
|
log::debug!("Layer shell: {use_layer_shell}");
|
||||||
|
|
||||||
if use_layer_shell {
|
if use_layer_shell {
|
||||||
// Single greeter window. No set_monitor — the compositor places it on the
|
// Single greeter window anchored to the built-in display. Other
|
||||||
// focused output (same as moonset). Exclusive keyboard binds input to this
|
// outputs stay under compositor control — the greeter is just a
|
||||||
// surface regardless of pointer position; the mouse may wander to other
|
// normal layer-shell client, no per-output keyboard grabs.
|
||||||
// outputs but typing always reaches the greeter. The previous per-monitor
|
let monitors = display.monitors();
|
||||||
// approach gave keyboard only to the first monitor's window, so a user on
|
let count = monitors.n_items();
|
||||||
// any other output could not type the password.
|
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);
|
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
|
||||||
setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
|
setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
|
||||||
|
window.set_monitor(Some(&monitor));
|
||||||
window.present();
|
window.present();
|
||||||
} else {
|
} else {
|
||||||
// No layer shell — single window for development
|
log::error!("Primary monitor {primary_idx} not available — greeter will not be shown");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
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);
|
||||||
greeter_window.present();
|
greeter_window.present();
|
||||||
}
|
}
|
||||||
@@ -89,16 +132,55 @@ fn setup_logging() {
|
|||||||
eprintln!("Failed to create journal logger: {e}");
|
eprintln!("Failed to create journal logger: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Require MOONGREET_DEBUG=1 to raise verbosity. Mere presence (e.g. an
|
let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
|
||||||
// empty value in a session-setup script) must not escalate the journal
|
log::LevelFilter::Debug
|
||||||
// to Debug, which leaks socket paths, usernames, and auth round counts.
|
} else {
|
||||||
let level = match std::env::var("MOONGREET_DEBUG").ok().as_deref() {
|
log::LevelFilter::Info
|
||||||
Some("1") => log::LevelFilter::Debug,
|
|
||||||
_ => log::LevelFilter::Info,
|
|
||||||
};
|
};
|
||||||
log::set_max_level(level);
|
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() {
|
fn main() {
|
||||||
setup_logging();
|
setup_logging();
|
||||||
log::info!("Moongreet starting");
|
log::info!("Moongreet starting");
|
||||||
|
|||||||
+6
-15
@@ -1,4 +1,4 @@
|
|||||||
// ABOUTME: Power actions — reboot and shutdown via systemctl.
|
// ABOUTME: Power actions — reboot and shutdown via loginctl.
|
||||||
// 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,9 +40,7 @@ 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 is never read; piping without draining would deadlock on any
|
.stdout(Stdio::piped())
|
||||||
// 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 {
|
||||||
@@ -99,21 +97,14 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reboot the system via systemctl.
|
/// Reboot the system via loginctl.
|
||||||
///
|
|
||||||
/// `--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/systemctl", &["--no-ask-password", "reboot"])
|
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shut down the system via systemctl.
|
/// Shut down the system via loginctl.
|
||||||
///
|
|
||||||
/// `--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/systemctl", &["--no-ask-password", "poweroff"])
|
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ 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();
|
||||||
@@ -46,18 +44,9 @@ 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());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user