Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9b6f50974 | |||
| 3a1af6471f | |||
| 35f1a17cdf | |||
| 48d363bb18 | |||
| 448e4212e3 | |||
| cd42df1095 | |||
| f7e258d402 | |||
| de97d6658e | |||
| 9c1e00d0ef | |||
| 874888391e | |||
| 51157ecb23 | |||
| 183e10c1cc | |||
| 094878fc2e | |||
| cf18105887 | |||
| f6f33a13ab | |||
| 60d294fa37 | |||
| 1d557ea135 | |||
| a2dc89854d | |||
| f3f4db1ab1 | |||
| a61fa4e145 | |||
| f09a04a115 | |||
| a462b2cf06 | |||
| 77b94a560d | |||
| b06b02faac | |||
| 9a89da8b13 | |||
| d5e431d37e | |||
| 7c10516473 | |||
| 09371b5fd2 | |||
| 3c39467508 | |||
| 64470f99c3 | |||
| 293bba32a6 | |||
| 14d6476e5a | |||
| 4c9b436978 | |||
| 96c94f030a | |||
| b91e8d47d1 | |||
| 5db23937ea | |||
| 0d4a1b035a |
43
.gitea/workflows/update-pkgver.yaml
Normal file
43
.gitea/workflows/update-pkgver.yaml
Normal file
@ -0,0 +1,43 @@
|
||||
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
|
||||
# ABOUTME: Ensures paru detects new versions of this package.
|
||||
|
||||
name: Update PKGBUILD version
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
update-pkgver:
|
||||
runs-on: moonarch
|
||||
steps:
|
||||
- name: Checkout source repo
|
||||
run: |
|
||||
git clone --bare http://gitea:3000/nevaforget/greetd-moongreet.git source.git
|
||||
cd source.git
|
||||
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
|
||||
echo "New pkgver: $PKGVER"
|
||||
echo "$PKGVER" > /tmp/pkgver
|
||||
|
||||
- name: Update PKGBUILD
|
||||
run: |
|
||||
PKGVER=$(cat /tmp/pkgver)
|
||||
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
|
||||
cd pkgbuilds
|
||||
|
||||
OLD_VER=$(grep '^pkgver=' moongreet-git/PKGBUILD | cut -d= -f2)
|
||||
if [ "$OLD_VER" = "$PKGVER" ]; then
|
||||
echo "pkgver already up to date ($PKGVER)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moongreet-git/PKGBUILD
|
||||
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moongreet-git/.SRCINFO
|
||||
echo "Updated pkgver: $OLD_VER → $PKGVER"
|
||||
|
||||
git config user.name "pkgver-bot"
|
||||
git config user.email "gitea@moonarch.de"
|
||||
git add moongreet-git/PKGBUILD moongreet-git/.SRCINFO
|
||||
git commit -m "chore(moongreet-git): bump pkgver to $PKGVER"
|
||||
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
|
||||
33
CLAUDE.md
33
CLAUDE.md
@ -1,7 +1,5 @@
|
||||
# Moongreet
|
||||
|
||||
**Name**: Selene (Mondgöttin — passend zu Moon-greet)
|
||||
|
||||
## Projekt
|
||||
|
||||
Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
|
||||
@ -17,8 +15,9 @@ Teil des Moonarch-Ökosystems.
|
||||
## Projektstruktur
|
||||
|
||||
- `src/` — Rust-Quellcode (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
|
||||
- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg)
|
||||
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
|
||||
- `config/` — Beispiel-Konfigurationsdateien für `/etc/moongreet/` und `/etc/greetd/`
|
||||
- `pkg/` — PKGBUILD für Arch-Linux-Paketierung (`makepkg -sf`)
|
||||
|
||||
## Kommandos
|
||||
|
||||
@ -29,8 +28,11 @@ cargo test
|
||||
# Release-Build
|
||||
cargo build --release
|
||||
|
||||
# Greeter starten (nur zum Testen, braucht normalerweise greetd)
|
||||
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
|
||||
# Greeter im Fenster starten (ohne greetd/Layer Shell)
|
||||
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
|
||||
|
||||
# Paket bauen und installieren
|
||||
cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
## Architektur
|
||||
@ -39,10 +41,11 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
|
||||
- `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection
|
||||
- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files
|
||||
- `power.rs` — Reboot/Shutdown via loginctl
|
||||
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN)
|
||||
- `config.rs` — TOML-Config ([appearance] background, gtk-theme) + Wallpaper-Fallback
|
||||
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC, Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence
|
||||
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
|
||||
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
|
||||
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
|
||||
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0–200)
|
||||
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files)
|
||||
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor mit Hotplug via `items-changed` auf Monitor-ListModel (one greeter window per monitor, first gets keyboard), systemd-journal-logger
|
||||
- `resources/style.css` — Catppuccin-inspiriertes Theme
|
||||
|
||||
## Design Decisions
|
||||
@ -52,4 +55,14 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
|
||||
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
|
||||
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
|
||||
- **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
|
||||
- **Symmetrie mit moonset**: Gleiche Patterns (i18n, config, users, power, GResource)
|
||||
- **GPU-Blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` im `connect_realize` Callback — kein CPU-Blur, kein Disk-Cache, kein `image`-Crate. Blurred Texture wird per `Rc<RefCell<Option<gdk::Texture>>>` über alle Monitore gecacht (1x GPU-Renderpass statt N).
|
||||
- **Fingerprint via greetd Multi-Stage PAM**: fprintd D-Bus nur als Probe (Gerät/Enrollment), eigentliche Verifizierung läuft über PAM im greetd-Auth-Loop. `auth_message_type: "secret"` → Passwort, alles andere → `None` (PAM entscheidet). 60s Socket-Timeout bei fprintd. Device-Proxy in `GreeterState` gecacht, Generation-Counter gegen Race Conditions bei schnellem User-Switch.
|
||||
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
|
||||
- **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt
|
||||
- **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config
|
||||
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
|
||||
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
|
||||
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
|
||||
- **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor
|
||||
- **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade → `None`
|
||||
- **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal
|
||||
|
||||
209
Cargo.lock
generated
209
Cargo.lock
generated
@ -2,65 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@ -119,33 +60,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@ -604,42 +522,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
@ -687,21 +575,36 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moongreet"
|
||||
version = "0.3.0"
|
||||
version = "0.8.6"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"gdk-pixbuf",
|
||||
"gdk4",
|
||||
"gio",
|
||||
"glib",
|
||||
"glib-build-tools",
|
||||
"graphene-rs",
|
||||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
"log",
|
||||
"nix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"systemd-journal-logger",
|
||||
"tempfile",
|
||||
"toml 0.8.23",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -710,12 +613,6 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.22.0"
|
||||
@ -752,21 +649,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@ -810,35 +692,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@ -964,6 +817,16 @@ dependencies = [
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "systemd-journal-logger"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.3"
|
||||
@ -1096,12 +959,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@ -1287,6 +1144,12 @@ version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moongreet"
|
||||
version = "0.3.1"
|
||||
version = "0.8.6"
|
||||
edition = "2024"
|
||||
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
|
||||
license = "MIT"
|
||||
@ -15,11 +15,19 @@ gio = "0.22"
|
||||
toml = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
graphene-rs = { version = "0.22", package = "graphene-rs" }
|
||||
nix = { version = "0.29", features = ["signal"] }
|
||||
zeroize = { version = "1", features = ["std"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
systemd-journal-logger = "2.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[build-dependencies]
|
||||
glib-build-tools = "0.22"
|
||||
|
||||
105
DECISIONS.md
Normal file
105
DECISIONS.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Decisions
|
||||
|
||||
## 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)
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: Reboot/shutdown from the greeter silently failed on a fresh install. The polkit rule that grants the `greeter` user `org.freedesktop.login1.{reboot,power-off}` lived in the moonarch repo but was never installed by any PKGBUILD. The laptop worked only because the rule had been hand-deployed once.
|
||||
- **Tradeoffs**: Rule ownership moves from moonarch (system defaults) to moongreet (greeter-specific auth). Cleaner boundary — moonarch no longer needs to know about the greeter's auth requirements — but it means moongreet is now responsible for a system polkit rule that ties it to a fixed username (`greeter`).
|
||||
- **How**: Source file moved to `moongreet/config/polkit/50-moongreet-power.rules`, installed to `/etc/polkit-1/rules.d/` by `moongreet-git/PKGBUILD`. Old file removed from the moonarch repo.
|
||||
|
||||
## 2026-04-09 – Monitor hotplug via ListModel items-changed
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: Greeter windows were only created at startup. If a monitor was hotplugged (e.g. HDMI reconnect), it would show no greeter UI. Aligned with moonlock's hotplug fix (same day).
|
||||
- **Tradeoffs**: Hotplugged monitors get greeter windows without keyboard input (keyboard stays on the primary monitor). Acceptable — user can still interact on the primary screen.
|
||||
- **How**: Connect to `display.monitors().connect_items_changed()` and create new greeter windows for added monitors. Shared state (config, texture, blur_cache) moved to Rc for the closure.
|
||||
|
||||
## 2026-04-08 – Show greeter UI on all monitors instead of just one
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: moonlock showed its UI on all monitors via ext-session-lock-v1, but moongreet only showed the login UI on one monitor (compositor-picked) with wallpaper-only windows on the rest. Inconsistent UX across the ecosystem.
|
||||
- **Tradeoffs**: Each monitor gets its own full greeter widget tree (slightly more memory), but the UI is lightweight. Screen mirroring (e.g., wl-mirror/screencopy) was considered and rejected — it requires an external process, compositor screencopy support, adds latency, and fights Wayland's per-output model. One-window-per-monitor is the established Wayland pattern (swaylock, hyprlock, moonlock all do this).
|
||||
- **How**: Create one `create_greeter_window()` per monitor with `set_monitor()`, only the first gets `KeyboardMode::Exclusive`. Removed `create_wallpaper_window()` (no longer needed). No layer shell fallback keeps single-window mode for development.
|
||||
|
||||
## 2026-04-06 – Restore explicit gtk-theme in moongreet config
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: GTK4 under greetd does not reliably read `/etc/xdg/gtk-4.0/settings.ini` — likely requires a settings daemon that doesn't run in the greeter session. moongreet fell back to Adwaita/Colloid-default (blue accent) instead of Colloid-Grey-Dark-Catppuccin.
|
||||
- **Tradeoffs**: Reverts `094878f` ("Remove gtk-theme from app config, use system-wide GTK settings instead"). Duplicates the theme name between settings.ini and moongreet.toml, but the explicit set via `set_gtk_theme_name()` is the only reliable path in a greetd context.
|
||||
- **How**: Added `gtk-theme = "Colloid-Grey-Dark-Catppuccin"` to example config and deployed `/etc/moongreet/moongreet.toml`.
|
||||
|
||||
## 2026-04-02 – Replace hardcoded CSS colors with GTK theme variables
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: moongreet used hardcoded colors (#1a1a2e, white, #ff6b6b) while moonset already used @theme_bg_color, @theme_fg_color, @error_color etc. Inconsistent across the ecosystem and broke theme flexibility.
|
||||
- **Tradeoffs**: Depends on the active GTK theme defining standard color variables. Catppuccin Colloid provides all needed vars (@theme_bg_color, @theme_fg_color, @error_color, @success_color, @theme_selected_bg_color). Fallback behavior if a theme lacks vars is GTK's default colors — acceptable.
|
||||
- **How**: Replaced all hardcoded hex/named colors with GTK theme variables. Coordinated change across moongreet, moonlock, and moonset (all three now use identical pattern).
|
||||
|
||||
## 2026-03-31 – Fourth audit: power timeout, timing mitigation, release profile, GREETD_SOCK caching
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: Fourth triple audit found moongreet power.rs had no timeout on loginctl (greeter could freeze), username enumeration via timing differential, GREETD_SOCK re-read on every login, missing release profile, and missing GResource compression.
|
||||
- **Tradeoffs**: 500ms minimum login response time adds slight delay on fast auth but prevents timing-based username enumeration. Power timeout (30s + SIGKILL) matches moonset pattern — aggressive but prevents greeter freeze.
|
||||
- **How**: (1) power.rs adapted from moonset with 30s timeout + SIGKILL (nix dependency added). (2) 500ms min response floor in attempt_login via Instant + glib::timeout_future. (3) GREETD_SOCK cached in GreeterState at startup. (4) `[profile.release]` with LTO, codegen-units=1, strip. (5) `compressed="true"` on GResource entries. (6) SYNC comments on duplicated blur/background functions.
|
||||
|
||||
## 2026-03-30 – Full audit fix: security, quality, performance (v0.6.2)
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: Three parallel audits (security, code quality, performance) identified 10 actionable findings across the codebase — from world-readable cache dirs to a GPU blur geometry bug to a race condition in fingerprint probing.
|
||||
- **Tradeoffs**: `too_many_arguments` Clippy warnings suppressed with `#[allow]` rather than introducing a `UiWidgets` struct — GTK's `clone!` macro with `#[weak]` refs requires individual widget parameters, a struct would fight the idiom. Async avatar loading skipped because `Pixbuf` is `!Send`; cache already prevents repeat loads. TOCTOU socket pre-check removed entirely — `connect()` in login_worker already handles errors, the `metadata()` check gave false security guarantees.
|
||||
- **How**: Cache dirs use `DirBuilder::mode(0o700)` instead of `create_dir_all`. Blur config clamped to `0.0..=200.0` with `is_finite()` guard. Blur texture cached in `Rc<RefCell<Option<gdk::Texture>>>` across monitors. FingerprintProbe device proxy cached in `GreeterState` with generation counter to prevent stale async writes. GPU blur geometry fixed (`-pad` origin shift instead of texture stretching). `is_valid_gtk_theme` extracted as testable function. 9 new tests.
|
||||
|
||||
## 2026-03-29 – Fingerprint authentication via greetd multi-stage PAM
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: moonlock supports fprintd but moongreet rejected multi-stage auth. Users with enrolled fingerprints couldn't use them at the login screen.
|
||||
- **Tradeoffs**: Direct fprintd D-Bus verification (like moonlock) can't start a greetd session — greetd controls session creation via PAM. Using greetd multi-stage means PAM decides the auth order (fingerprint first, then password fallback), not truly parallel. Acceptable — matches standard pam_fprintd behavior.
|
||||
- **How**: Replace single-pass auth with a loop over auth_message rounds. Secret prompts get the password, non-secret prompts (fprintd) get None and block until PAM resolves. fprintd D-Bus probe (gio::DBusProxy) only for UI — detecting device availability and enrolled fingers. 60s socket timeout when fingerprint available. Config option `fingerprint-enabled` (default true).
|
||||
|
||||
## 2026-03-28 – Remove embedded wallpaper from binary
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: Wallpaper is installed by moonarch to /usr/share/moonarch/wallpaper.jpg. Embedding a 374K JPEG in the binary is redundant. GTK background color (Catppuccin Mocha base) is a clean fallback.
|
||||
- **Tradeoffs**: Without moonarch installed AND without config, greeter shows plain dark background instead of wallpaper. Acceptable — that's the expected minimal state.
|
||||
- **How**: Remove wallpaper.jpg from GResources, return None from resolve_background_path when no file found, skip wallpaper window creation and background picture when no path available.
|
||||
|
||||
## 2026-03-28 – GPU blur via GskBlurNode replaces CPU blur
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: CPU-side Gaussian blur (`image` crate) blocked the GTK main thread for 500ms–2s on 4K wallpapers at cold cache. Disk cache and async orchestration added significant complexity.
|
||||
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely (~15 transitive crates eliminated). No disk cache needed.
|
||||
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer, producing a concrete `gdk::Texture`. Zero startup latency. Symmetric with moonlock and moonset.
|
||||
|
||||
## 2026-03-28 – Optional background blur via `image` crate (superseded)
|
||||
|
||||
- **Who**: ClaudeCode, Dom
|
||||
- **Why**: Blurred wallpaper as greeter background is a common UX pattern for login screens
|
||||
- **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors.
|
||||
- **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns blurred `gdk::Texture`. Config option `background-blur: Option<f32>` in `[appearance]` TOML section.
|
||||
|
||||
## 2026-03-28 – Audit fixes for shared wallpaper texture (v0.4.1)
|
||||
- **Who**: ClaudeCode, Dominik
|
||||
- **Why**: Quality, performance, and security audits flagged issues in `load_background_texture()`, debug logging, and greetd error handling
|
||||
- **Tradeoffs**: GResource path now requires UTF-8 (returns `None` for non-UTF-8 instead of aborting); 50 MB wallpaper limit is generous but prevents OOM; debug logging off by default trades observability for security
|
||||
- **How**: GResource branch via `resources_lookup_data()` + `from_bytes()` (no abort), file size limit, error details only at debug level, `MOONGREET_DEBUG` env var for log level, greetd retry path truncation matching `show_greetd_error()`
|
||||
18
README.md
18
README.md
@ -12,9 +12,13 @@ Part of the Moonarch ecosystem.
|
||||
- **Last user/session** — Remembered in `/var/cache/moongreet/`
|
||||
- **Power actions** — Reboot / Shutdown via `loginctl`
|
||||
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
|
||||
- **Multi-monitor** — Greeter on primary, wallpaper on all monitors
|
||||
- **Multi-monitor + hotplug** — Full greeter UI on all monitors (keyboard input on first), hotplugged monitors get windows automatically
|
||||
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
|
||||
- **i18n** — German and English (auto-detected from system locale)
|
||||
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3
|
||||
- **Fingerprint** — fprintd support via greetd multi-stage PAM (configurable)
|
||||
- **Journal logging** — `journalctl -t moongreet`, debug level via `MOONGREET_DEBUG` env var
|
||||
- **Password wiping** — Zeroize on drop
|
||||
|
||||
## Requirements
|
||||
|
||||
@ -56,6 +60,14 @@ sudo cp config/moongreet.toml /etc/moongreet/moongreet.toml
|
||||
user = "greeter"
|
||||
```
|
||||
|
||||
4. Install the polkit rule so the greeter user can reboot / power off:
|
||||
```bash
|
||||
sudo install -Dm644 config/polkit/50-moongreet-power.rules \
|
||||
/etc/polkit-1/rules.d/50-moongreet-power.rules
|
||||
```
|
||||
Without this rule, `loginctl reboot` / `loginctl poweroff` fail because
|
||||
greetd's greeter session is inactive in logind.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@ -65,8 +77,8 @@ cargo test
|
||||
# Build release
|
||||
cargo build --release
|
||||
|
||||
# Run locally (without greetd, needs LD_PRELOAD for layer-shell)
|
||||
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
|
||||
# Run locally (without greetd, disables layer-shell)
|
||||
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
2
build.rs
2
build.rs
@ -1,5 +1,5 @@
|
||||
// ABOUTME: Build script for compiling GResource bundle.
|
||||
// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary.
|
||||
// ABOUTME: Bundles style.css and default-avatar.svg into the binary.
|
||||
|
||||
fn main() {
|
||||
glib_build_tools::compile_resources(
|
||||
|
||||
@ -4,5 +4,7 @@
|
||||
[appearance]
|
||||
# Absolute path to wallpaper image
|
||||
background = "/usr/share/backgrounds/wallpaper.jpg"
|
||||
# GTK theme for the greeter UI
|
||||
gtk-theme = "catppuccin-mocha-lavender-standard+default"
|
||||
|
||||
# GTK theme name — must match a directory in /usr/share/themes/
|
||||
# Required because GTK4 under greetd does not reliably read settings.ini
|
||||
gtk-theme = "Colloid-Grey-Dark-Catppuccin"
|
||||
|
||||
12
config/polkit/50-moongreet-power.rules
Normal file
12
config/polkit/50-moongreet-power.rules
Normal file
@ -0,0 +1,12 @@
|
||||
// ABOUTME: Allow the greeter user to reboot and power off without authentication.
|
||||
// ABOUTME: Required because greetd's greeter session is inactive in logind.
|
||||
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user === "greeter" &&
|
||||
(action.id === "org.freedesktop.login1.reboot" ||
|
||||
action.id === "org.freedesktop.login1.reboot-multiple-sessions" ||
|
||||
action.id === "org.freedesktop.login1.power-off" ||
|
||||
action.id === "org.freedesktop.login1.power-off-multiple-sessions")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
@ -4,7 +4,7 @@
|
||||
# Maintainer: Dominik Kressler
|
||||
|
||||
pkgname=moongreet-git
|
||||
pkgver=0.3.0.r0.g0000000
|
||||
pkgver=0.4.0.r7.g77b94a5
|
||||
pkgrel=1
|
||||
pkgdesc="A greetd greeter for Wayland with GTK4 and Layer Shell"
|
||||
arch=('x86_64')
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/dev/moonarch/moongreet">
|
||||
<file>style.css</file>
|
||||
<file>wallpaper.jpg</file>
|
||||
<file>default-avatar.svg</file>
|
||||
<file compressed="true">style.css</file>
|
||||
<file compressed="true">default-avatar.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
/* ABOUTME: GTK4 CSS stylesheet for the Moongreet greeter. */
|
||||
/* ABOUTME: Defines styling for the login screen layout. */
|
||||
/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */
|
||||
|
||||
/* Main window background */
|
||||
window.greeter {
|
||||
background-color: #1a1a2e;
|
||||
background-color: @theme_bg_color;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Wallpaper-only window for secondary monitors */
|
||||
window.wallpaper {
|
||||
background-color: #1a1a2e;
|
||||
background-color: @theme_bg_color;
|
||||
}
|
||||
|
||||
/* Central login area */
|
||||
@ -22,18 +22,18 @@ window.wallpaper {
|
||||
|
||||
/* Round avatar image — size is set via set_size_request() in code */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
border-radius: 9999px;
|
||||
min-width: 128px;
|
||||
min-height: 128px;
|
||||
background-color: @theme_selected_bg_color;
|
||||
border: 3px solid alpha(white, 0.3);
|
||||
border: 3px solid alpha(@theme_fg_color, 0.3);
|
||||
}
|
||||
|
||||
/* Username label */
|
||||
.username-label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
color: @theme_fg_color;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
@ -50,10 +50,17 @@ window.wallpaper {
|
||||
|
||||
/* Error message label */
|
||||
.error-label {
|
||||
color: #ff6b6b;
|
||||
color: @error_color;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Fingerprint prompt label */
|
||||
.fingerprint-label {
|
||||
color: alpha(@theme_fg_color, 0.6);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* User list on the bottom left */
|
||||
.user-list {
|
||||
background-color: transparent;
|
||||
@ -63,16 +70,16 @@ window.wallpaper {
|
||||
.user-list-item {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
color: @theme_fg_color;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-list-item:hover {
|
||||
background-color: alpha(white, 0.15);
|
||||
background-color: alpha(@theme_fg_color, 0.15);
|
||||
}
|
||||
|
||||
.user-list-item:selected {
|
||||
background-color: alpha(white, 0.2);
|
||||
background-color: alpha(@theme_fg_color, 0.2);
|
||||
}
|
||||
|
||||
/* Power buttons on the bottom right */
|
||||
@ -81,12 +88,12 @@ window.wallpaper {
|
||||
min-height: 48px;
|
||||
padding: 0px;
|
||||
border-radius: 24px;
|
||||
background-color: alpha(white, 0.1);
|
||||
color: white;
|
||||
background-color: alpha(@theme_fg_color, 0.1);
|
||||
color: @theme_fg_color;
|
||||
border: none;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.power-button:hover {
|
||||
background-color: alpha(white, 0.25);
|
||||
background-color: alpha(@theme_fg_color, 0.25);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB |
178
src/config.rs
178
src/config.rs
@ -6,7 +6,6 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
|
||||
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet";
|
||||
|
||||
/// Default config search path: system-wide config.
|
||||
fn default_config_paths() -> Vec<PathBuf> {
|
||||
@ -22,15 +21,32 @@ struct TomlConfig {
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct Appearance {
|
||||
background: Option<String>,
|
||||
#[serde(rename = "background-blur")]
|
||||
background_blur: Option<f32>,
|
||||
#[serde(rename = "gtk-theme")]
|
||||
gtk_theme: Option<String>,
|
||||
#[serde(rename = "fingerprint-enabled")]
|
||||
fingerprint_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Greeter configuration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub background_path: Option<String>,
|
||||
pub background_blur: Option<f32>,
|
||||
pub gtk_theme: Option<String>,
|
||||
pub fingerprint_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
background_path: None,
|
||||
background_blur: None,
|
||||
gtk_theme: None,
|
||||
fingerprint_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load config from TOML files. Later paths override earlier ones.
|
||||
@ -40,8 +56,11 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
|
||||
let mut merged = Config::default();
|
||||
for path in paths {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(parsed) = toml::from_str::<TomlConfig>(&content) {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
match toml::from_str::<TomlConfig>(&content) {
|
||||
Ok(parsed) => {
|
||||
log::debug!("Config loaded: {}", path.display());
|
||||
if let Some(appearance) = parsed.appearance {
|
||||
if let Some(bg) = appearance.background {
|
||||
// Resolve relative paths against config file directory
|
||||
@ -49,45 +68,79 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
if bg_path.is_absolute() {
|
||||
merged.background_path = Some(bg);
|
||||
} else if let Some(parent) = path.parent() {
|
||||
merged.background_path =
|
||||
Some(parent.join(&bg).to_string_lossy().to_string());
|
||||
let joined = parent.join(&bg);
|
||||
match joined.to_str() {
|
||||
Some(s) => merged.background_path = Some(s.to_string()),
|
||||
None => log::warn!(
|
||||
"Ignoring non-UTF-8 background path: {}",
|
||||
joined.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(blur) = appearance.background_blur {
|
||||
if blur.is_finite() {
|
||||
merged.background_blur = Some(blur.clamp(0.0, 200.0));
|
||||
} else {
|
||||
log::warn!("Ignoring non-finite background-blur value");
|
||||
}
|
||||
}
|
||||
if appearance.gtk_theme.is_some() {
|
||||
merged.gtk_theme = appearance.gtk_theme;
|
||||
}
|
||||
if let Some(fp) = appearance.fingerprint_enabled {
|
||||
merged.fingerprint_enabled = fp;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Config parse error in {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("Config not found: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}, fingerprint={}", merged.background_path, merged.background_blur, merged.gtk_theme, merged.fingerprint_enabled);
|
||||
merged
|
||||
}
|
||||
|
||||
/// Resolve the wallpaper path using the fallback hierarchy.
|
||||
///
|
||||
/// Priority: config background_path > Moonarch system default > gresource fallback.
|
||||
pub fn resolve_background_path(config: &Config) -> PathBuf {
|
||||
/// Priority: config background_path > Moonarch system default > None (GTK background color).
|
||||
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
|
||||
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
|
||||
}
|
||||
|
||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
|
||||
// User-configured path
|
||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
||||
// User-configured path — reject symlinks to prevent path traversal
|
||||
if let Some(ref bg) = config.background_path {
|
||||
let path = PathBuf::from(bg);
|
||||
if path.is_file() {
|
||||
return path;
|
||||
if let Ok(meta) = path.symlink_metadata() {
|
||||
if meta.is_file() && !meta.file_type().is_symlink() {
|
||||
log::debug!("Wallpaper: using config path {}", path.display());
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display());
|
||||
}
|
||||
|
||||
// Moonarch ecosystem default — apply the same symlink rejection as the
|
||||
// user-configured path for defense in depth. The fallback target is a
|
||||
// system file, but the caller consumes the result via the same path.
|
||||
if let Ok(meta) = moonarch_wallpaper.symlink_metadata() {
|
||||
if meta.is_file() && !meta.file_type().is_symlink() {
|
||||
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
|
||||
return Some(moonarch_wallpaper.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// Moonarch ecosystem default
|
||||
if moonarch_wallpaper.is_file() {
|
||||
return moonarch_wallpaper.to_path_buf();
|
||||
}
|
||||
|
||||
// GResource fallback path (loaded from compiled resources at runtime)
|
||||
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
|
||||
log::debug!("Wallpaper: no wallpaper found, using GTK background color");
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -98,7 +151,9 @@ mod tests {
|
||||
fn default_config_has_none_fields() {
|
||||
let config = Config::default();
|
||||
assert!(config.background_path.is_none());
|
||||
assert!(config.background_blur.is_none());
|
||||
assert!(config.gtk_theme.is_none());
|
||||
assert!(config.fingerprint_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -115,7 +170,7 @@ mod tests {
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(
|
||||
&conf,
|
||||
"[appearance]\nbackground = \"/custom/wallpaper.jpg\"\ngtk-theme = \"catppuccin\"\n",
|
||||
"[appearance]\nbackground = \"/custom/wallpaper.jpg\"\nbackground-blur = 20.0\ngtk-theme = \"catppuccin\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let paths = vec![conf];
|
||||
@ -124,9 +179,20 @@ mod tests {
|
||||
config.background_path.as_deref(),
|
||||
Some("/custom/wallpaper.jpg")
|
||||
);
|
||||
assert_eq!(config.background_blur, Some(20.0));
|
||||
assert_eq!(config.gtk_theme.as_deref(), Some("catppuccin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_blur_optional() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(&conf, "[appearance]\nbackground = \"/bg.jpg\"\n").unwrap();
|
||||
let paths = vec![conf];
|
||||
let config = load_config(Some(&paths));
|
||||
assert!(config.background_blur.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_resolves_relative_background() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@ -180,11 +246,11 @@ mod tests {
|
||||
fs::write(&wallpaper, "fake").unwrap();
|
||||
let config = Config {
|
||||
background_path: Some(wallpaper.to_str().unwrap().to_string()),
|
||||
gtk_theme: None,
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_background_path_with(&config, Path::new("/nonexistent")),
|
||||
wallpaper
|
||||
Some(wallpaper)
|
||||
);
|
||||
}
|
||||
|
||||
@ -192,10 +258,10 @@ mod tests {
|
||||
fn resolve_ignores_config_path_when_file_missing() {
|
||||
let config = Config {
|
||||
background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
|
||||
gtk_theme: None,
|
||||
..Config::default()
|
||||
};
|
||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
||||
assert!(result.to_str().unwrap().contains("moongreet"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -206,14 +272,72 @@ mod tests {
|
||||
let config = Config::default();
|
||||
assert_eq!(
|
||||
resolve_background_path_with(&config, &moonarch_wp),
|
||||
moonarch_wp
|
||||
Some(moonarch_wp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_gresource_fallback_as_last_resort() {
|
||||
fn resolve_returns_none_when_no_wallpaper_found() {
|
||||
let config = Config::default();
|
||||
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
|
||||
assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_fingerprint_enabled_default_true() {
|
||||
let paths = vec![PathBuf::from("/nonexistent/moongreet.toml")];
|
||||
let config = load_config(Some(&paths));
|
||||
assert!(config.fingerprint_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_fingerprint_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(&conf, "[appearance]\nfingerprint-enabled = false\n").unwrap();
|
||||
let paths = vec![conf];
|
||||
let config = load_config(Some(&paths));
|
||||
assert!(!config.fingerprint_enabled);
|
||||
}
|
||||
|
||||
// -- Blur validation tests --
|
||||
|
||||
#[test]
|
||||
fn load_config_blur_clamped_to_max() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(&conf, "[appearance]\nbackground-blur = 999.0\n").unwrap();
|
||||
let config = load_config(Some(&[conf]));
|
||||
assert_eq!(config.background_blur, Some(200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_blur_negative_clamped_to_zero() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(&conf, "[appearance]\nbackground-blur = -5.0\n").unwrap();
|
||||
let config = load_config(Some(&[conf]));
|
||||
assert_eq!(config.background_blur, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_blur_nan_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
// TOML doesn't support NaN literals, but the parser may return NaN for nan
|
||||
fs::write(&conf, "[appearance]\nbackground-blur = nan\n").unwrap();
|
||||
let config = load_config(Some(&[conf]));
|
||||
// nan is not valid TOML float, so the whole config parse fails → no blur
|
||||
assert!(config.background_blur.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_blur_inf_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("moongreet.toml");
|
||||
fs::write(&conf, "[appearance]\nbackground-blur = inf\n").unwrap();
|
||||
let config = load_config(Some(&[conf]));
|
||||
// inf is valid TOML → parsed as f32::INFINITY → rejected by is_finite() guard
|
||||
assert!(config.background_blur.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
142
src/fingerprint.rs
Normal file
142
src/fingerprint.rs
Normal file
@ -0,0 +1,142 @@
|
||||
// ABOUTME: fprintd D-Bus probe for fingerprint device availability.
|
||||
// ABOUTME: Checks if fprintd is running and the user has enrolled fingerprints.
|
||||
|
||||
use gio::prelude::*;
|
||||
use gtk4::gio;
|
||||
|
||||
const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint";
|
||||
const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager";
|
||||
const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
|
||||
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
|
||||
|
||||
const DBUS_TIMEOUT_MS: i32 = 3000;
|
||||
const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/";
|
||||
|
||||
/// Lightweight fprintd probe — detects device availability and finger enrollment.
|
||||
/// Does NOT perform verification (that happens through greetd/PAM).
|
||||
pub struct FingerprintProbe {
|
||||
device_proxy: Option<gio::DBusProxy>,
|
||||
}
|
||||
|
||||
impl FingerprintProbe {
|
||||
/// Create a probe without any D-Bus connections.
|
||||
/// Call `init_async().await` to connect to fprintd.
|
||||
pub fn new() -> Self {
|
||||
FingerprintProbe {
|
||||
device_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to fprintd on the system bus and discover the default device.
|
||||
pub async fn init_async(&mut self) {
|
||||
let manager = match gio::DBusProxy::for_bus_future(
|
||||
gio::BusType::System,
|
||||
gio::DBusProxyFlags::NONE,
|
||||
None,
|
||||
FPRINTD_BUS_NAME,
|
||||
FPRINTD_MANAGER_PATH,
|
||||
FPRINTD_MANAGER_IFACE,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::debug!("fprintd manager not available: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = match manager
|
||||
.call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::debug!("fprintd GetDefaultDevice failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let device_path = match result.child_value(0).get::<String>() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
log::debug!("fprintd: unexpected GetDefaultDevice response type");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if device_path.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
|
||||
log::warn!("Unexpected fprintd device path: {device_path}");
|
||||
return;
|
||||
}
|
||||
|
||||
match gio::DBusProxy::for_bus_future(
|
||||
gio::BusType::System,
|
||||
gio::DBusProxyFlags::NONE,
|
||||
None,
|
||||
FPRINTD_BUS_NAME,
|
||||
&device_path,
|
||||
FPRINTD_DEVICE_IFACE,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(proxy) => {
|
||||
self.device_proxy = Some(proxy);
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("fprintd device proxy failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the user has enrolled fingerprints on the default device.
|
||||
/// Returns false if fprintd is unavailable or the user has no enrollments.
|
||||
pub async fn is_available_async(&self, username: &str) -> bool {
|
||||
let proxy = match &self.device_proxy {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let args = glib::Variant::from((&username,));
|
||||
match proxy
|
||||
.call_future(
|
||||
"ListEnrolledFingers",
|
||||
Some(&args),
|
||||
gio::DBusCallFlags::NONE,
|
||||
DBUS_TIMEOUT_MS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => match result.child_value(0).get::<Vec<String>>() {
|
||||
Some(fingers) => !fingers.is_empty(),
|
||||
None => {
|
||||
log::debug!("fprintd: unexpected ListEnrolledFingers response type");
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_probe_has_no_device() {
|
||||
let probe = FingerprintProbe::new();
|
||||
assert!(probe.device_proxy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_are_defined() {
|
||||
assert!(!FPRINTD_BUS_NAME.is_empty());
|
||||
assert!(!FPRINTD_MANAGER_PATH.is_empty());
|
||||
assert!(!FPRINTD_MANAGER_IFACE.is_empty());
|
||||
assert!(!FPRINTD_DEVICE_IFACE.is_empty());
|
||||
assert!(DBUS_TIMEOUT_MS > 0);
|
||||
}
|
||||
}
|
||||
955
src/greeter.rs
955
src/greeter.rs
File diff suppressed because it is too large
Load Diff
45
src/i18n.rs
45
src/i18n.rs
@ -4,6 +4,7 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
||||
|
||||
@ -19,17 +20,15 @@ pub struct Strings {
|
||||
pub no_session_selected: &'static str,
|
||||
pub greetd_sock_not_set: &'static str,
|
||||
pub greetd_sock_not_absolute: &'static str,
|
||||
pub greetd_sock_not_socket: &'static str,
|
||||
pub greetd_sock_unreachable: &'static str,
|
||||
pub auth_failed: &'static str,
|
||||
pub wrong_password: &'static str,
|
||||
pub multi_stage_unsupported: &'static str,
|
||||
pub fingerprint_prompt: &'static str,
|
||||
pub invalid_session_command: &'static str,
|
||||
pub session_start_failed: &'static str,
|
||||
pub reboot_failed: &'static str,
|
||||
pub shutdown_failed: &'static str,
|
||||
pub connection_error: &'static str,
|
||||
pub socket_error: &'static str,
|
||||
pub unexpected_greetd_response: &'static str,
|
||||
|
||||
// Templates (use .replace("{n}", &count.to_string()))
|
||||
pub faillock_attempts_remaining: &'static str,
|
||||
@ -43,17 +42,15 @@ const STRINGS_DE: Strings = Strings {
|
||||
no_session_selected: "Keine Session ausgewählt",
|
||||
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
|
||||
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
|
||||
greetd_sock_not_socket: "GREETD_SOCK zeigt nicht auf einen Socket",
|
||||
greetd_sock_unreachable: "GREETD_SOCK nicht erreichbar",
|
||||
auth_failed: "Authentifizierung fehlgeschlagen",
|
||||
wrong_password: "Falsches Passwort",
|
||||
multi_stage_unsupported: "Mehrstufige Authentifizierung wird nicht unterstützt",
|
||||
fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
|
||||
invalid_session_command: "Ungültiger Session-Befehl",
|
||||
session_start_failed: "Session konnte nicht gestartet werden",
|
||||
reboot_failed: "Neustart fehlgeschlagen",
|
||||
shutdown_failed: "Herunterfahren fehlgeschlagen",
|
||||
connection_error: "Verbindungsfehler",
|
||||
socket_error: "Socket-Fehler",
|
||||
unexpected_greetd_response: "Unerwartete Antwort von greetd",
|
||||
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
|
||||
faillock_locked: "Konto ist möglicherweise gesperrt",
|
||||
};
|
||||
@ -65,17 +62,15 @@ const STRINGS_EN: Strings = Strings {
|
||||
no_session_selected: "No session selected",
|
||||
greetd_sock_not_set: "GREETD_SOCK not set",
|
||||
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
|
||||
greetd_sock_not_socket: "GREETD_SOCK does not point to a socket",
|
||||
greetd_sock_unreachable: "GREETD_SOCK unreachable",
|
||||
auth_failed: "Authentication failed",
|
||||
wrong_password: "Wrong password",
|
||||
multi_stage_unsupported: "Multi-stage authentication is not supported",
|
||||
fingerprint_prompt: "Place finger on reader or enter password",
|
||||
invalid_session_command: "Invalid session command",
|
||||
session_start_failed: "Failed to start session",
|
||||
reboot_failed: "Reboot failed",
|
||||
shutdown_failed: "Shutdown failed",
|
||||
connection_error: "Connection error",
|
||||
socket_error: "Socket error",
|
||||
unexpected_greetd_response: "Unexpected response from greetd",
|
||||
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
|
||||
faillock_locked: "Account may be locked",
|
||||
};
|
||||
@ -124,20 +119,28 @@ pub fn detect_locale() -> String {
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
|
||||
|
||||
match lang {
|
||||
Some(l) => parse_lang_prefix(&l),
|
||||
let result = match lang {
|
||||
Some(ref l) => parse_lang_prefix(l),
|
||||
None => "en".to_string(),
|
||||
};
|
||||
log::debug!("Detected locale: {result} (source: {})", match lang {
|
||||
Some(_) => "LANG env or locale.conf",
|
||||
None => "default",
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached locale — detected once, reused for the lifetime of the process.
|
||||
static CACHED_LOCALE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Return the string table for the given locale, defaulting to English.
|
||||
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
||||
let locale = match locale {
|
||||
Some(l) => l.to_string(),
|
||||
None => detect_locale(),
|
||||
Some(l) => l,
|
||||
None => CACHED_LOCALE.get_or_init(detect_locale),
|
||||
};
|
||||
|
||||
match locale.as_str() {
|
||||
match locale {
|
||||
"de" => &STRINGS_DE,
|
||||
_ => &STRINGS_EN,
|
||||
}
|
||||
@ -277,10 +280,16 @@ mod tests {
|
||||
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
|
||||
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
|
||||
assert!(!s.wrong_password.is_empty(), "{locale}: wrong_password");
|
||||
assert!(!s.fingerprint_prompt.is_empty(), "{locale}: fingerprint_prompt");
|
||||
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed");
|
||||
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed");
|
||||
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
|
||||
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
|
||||
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response");
|
||||
assert!(!s.greetd_sock_not_absolute.is_empty(), "{locale}: greetd_sock_not_absolute");
|
||||
assert!(!s.invalid_session_command.is_empty(), "{locale}: invalid_session_command");
|
||||
assert!(!s.session_start_failed.is_empty(), "{locale}: session_start_failed");
|
||||
assert!(!s.socket_error.is_empty(), "{locale}: socket_error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
src/ipc.rs
12
src/ipc.rs
@ -75,7 +75,6 @@ fn recv_payload(stream: &mut UnixStream, n: usize) -> Result<Vec<u8>, IpcError>
|
||||
}
|
||||
|
||||
/// Send a length-prefixed JSON message to the greetd socket.
|
||||
/// Header and payload are sent in a single write for atomicity.
|
||||
pub fn send_message(
|
||||
stream: &mut UnixStream,
|
||||
msg: &serde_json::Value,
|
||||
@ -85,11 +84,12 @@ pub fn send_message(
|
||||
return Err(IpcError::PayloadTooLarge(payload.len()));
|
||||
}
|
||||
|
||||
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
log::debug!("IPC send: type={msg_type}, size={} bytes", payload.len());
|
||||
|
||||
let header = (payload.len() as u32).to_le_bytes();
|
||||
let mut buf = Vec::with_capacity(4 + payload.len());
|
||||
buf.extend_from_slice(&header);
|
||||
buf.extend_from_slice(&payload);
|
||||
stream.write_all(&buf)?;
|
||||
stream.write_all(&header)?;
|
||||
stream.write_all(&payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -104,6 +104,8 @@ pub fn recv_message(stream: &mut UnixStream) -> Result<serde_json::Value, IpcErr
|
||||
|
||||
let payload = recv_payload(stream, length)?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&payload)?;
|
||||
let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
log::debug!("IPC recv: type={msg_type}, size={length} bytes");
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
|
||||
103
src/main.rs
103
src/main.rs
@ -2,6 +2,7 @@
|
||||
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
|
||||
|
||||
mod config;
|
||||
mod fingerprint;
|
||||
mod greeter;
|
||||
mod i18n;
|
||||
mod ipc;
|
||||
@ -10,24 +11,24 @@ mod sessions;
|
||||
mod users;
|
||||
|
||||
use gdk4 as gdk;
|
||||
use glib::clone;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{self as gtk, gio};
|
||||
use gtk4_layer_shell::LayerShell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::rc::Rc;
|
||||
fn load_css(display: &gdk::Display) {
|
||||
let css_provider = gtk::CssProvider::new();
|
||||
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
display,
|
||||
&css_provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_USER,
|
||||
);
|
||||
}
|
||||
|
||||
fn setup_layer_shell(window: >k::ApplicationWindow, keyboard: bool) {
|
||||
fn setup_layer_shell(window: >k::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) {
|
||||
window.init_layer_shell();
|
||||
window.set_layer(gtk4_layer_shell::Layer::Top);
|
||||
window.set_layer(layer);
|
||||
window.set_exclusive_zone(-1);
|
||||
if keyboard {
|
||||
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
|
||||
@ -48,56 +49,92 @@ fn activate(app: >k::Application) {
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("Display: {:?}", display);
|
||||
load_css(&display);
|
||||
|
||||
// Load config and resolve wallpaper
|
||||
let config = config::load_config(None);
|
||||
let bg_path = config::resolve_background_path(&config);
|
||||
let bg_texture = config::resolve_background_path(&config)
|
||||
.and_then(|path| {
|
||||
log::debug!("Background path: {}", path.display());
|
||||
greeter::load_background_texture(&path)
|
||||
});
|
||||
|
||||
let blur_cache = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
|
||||
let use_layer_shell = std::env::var("MOONGREET_NO_LAYER_SHELL").is_err();
|
||||
log::debug!("Layer shell: {use_layer_shell}");
|
||||
|
||||
// Main greeter window (login UI) — compositor picks focused monitor
|
||||
let greeter_window = greeter::create_greeter_window(&bg_path, &config, app);
|
||||
if use_layer_shell {
|
||||
setup_layer_shell(&greeter_window, true);
|
||||
}
|
||||
greeter_window.present();
|
||||
|
||||
// Wallpaper-only windows on all monitors (only with layer shell)
|
||||
if use_layer_shell {
|
||||
// One greeter window per monitor — only the first gets keyboard input
|
||||
let monitors = display.monitors();
|
||||
log::debug!("Monitor count: {}", monitors.n_items());
|
||||
let mut first = true;
|
||||
for i in 0..monitors.n_items() {
|
||||
if let Some(monitor) = monitors
|
||||
.item(i)
|
||||
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
|
||||
{
|
||||
let wallpaper = greeter::create_wallpaper_window(&bg_path, app);
|
||||
setup_layer_shell(&wallpaper, false);
|
||||
wallpaper.set_monitor(Some(&monitor));
|
||||
wallpaper.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 {
|
||||
// No layer shell — single window for development
|
||||
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
|
||||
greeter_window.present();
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_logging() {
|
||||
let mut builder = env_logger::Builder::from_default_env();
|
||||
builder.filter_level(log::LevelFilter::Info);
|
||||
|
||||
// Try file logging to /var/cache/moongreet/ — fall back to stderr
|
||||
let log_dir = PathBuf::from("/var/cache/moongreet");
|
||||
if log_dir.is_dir() {
|
||||
let log_file = log_dir.join("moongreet.log");
|
||||
if let Ok(file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
{
|
||||
builder.target(env_logger::Target::Pipe(Box::new(file)));
|
||||
match systemd_journal_logger::JournalLog::new() {
|
||||
Ok(logger) => {
|
||||
if let Err(e) = logger.install() {
|
||||
eprintln!("Failed to install journal logger: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
builder.init();
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create journal logger: {e}");
|
||||
}
|
||||
}
|
||||
// Require MOONGREET_DEBUG=1 to raise verbosity. Mere presence (e.g. an
|
||||
// empty value in a session-setup script) must not escalate the journal
|
||||
// to Debug, which leaks socket paths, usernames, and auth round counts.
|
||||
let level = match std::env::var("MOONGREET_DEBUG").ok().as_deref() {
|
||||
Some("1") => log::LevelFilter::Debug,
|
||||
_ => log::LevelFilter::Info,
|
||||
};
|
||||
log::set_max_level(level);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
72
src/power.rs
72
src/power.rs
@ -2,7 +2,13 @@
|
||||
// ABOUTME: Wrappers around system commands for the greeter UI.
|
||||
|
||||
use std::fmt;
|
||||
use std::process::Command;
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PowerError {
|
||||
@ -25,32 +31,72 @@ impl fmt::Display for PowerError {
|
||||
|
||||
impl std::error::Error for PowerError {}
|
||||
|
||||
/// Run a command and return a PowerError on failure.
|
||||
/// Run a command with timeout and return a PowerError on failure.
|
||||
///
|
||||
/// Uses blocking `child.wait()` with a separate timeout thread that sends
|
||||
/// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`,
|
||||
/// so blocking is expected.
|
||||
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
|
||||
let child = Command::new(program)
|
||||
log::debug!("Power action: {action} ({program} {args:?})");
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
// stdout is never read; piping without draining would deadlock on any
|
||||
// command that writes more than one OS pipe buffer before wait() returns.
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| PowerError::CommandFailed {
|
||||
action,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| PowerError::CommandFailed {
|
||||
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let done_clone = done.clone();
|
||||
|
||||
let timeout_thread = std::thread::spawn(move || {
|
||||
let interval = Duration::from_millis(100);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while elapsed < POWER_TIMEOUT {
|
||||
std::thread::sleep(interval);
|
||||
if done_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
elapsed += interval;
|
||||
}
|
||||
// ESRCH if the process already exited — harmless
|
||||
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
|
||||
});
|
||||
|
||||
let status = child.wait().map_err(|e| PowerError::CommandFailed {
|
||||
action,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(PowerError::CommandFailed {
|
||||
action,
|
||||
message: format!("exit code {}: {}", output.status, stderr.trim()),
|
||||
});
|
||||
done.store(true, Ordering::Relaxed);
|
||||
let _ = timeout_thread.join();
|
||||
|
||||
if status.success() {
|
||||
log::debug!("Power action {action} completed");
|
||||
Ok(())
|
||||
} else {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
if status.signal() == Some(9) {
|
||||
return Err(PowerError::Timeout { action });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let mut stderr_buf = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_buf);
|
||||
}
|
||||
Err(PowerError::CommandFailed {
|
||||
action,
|
||||
message: format!("exit code {}: {}", status, stderr_buf.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reboot the system via loginctl.
|
||||
|
||||
@ -12,6 +12,7 @@ const DEFAULT_XSESSION_DIRS: &[&str] = &["/usr/share/xsessions"];
|
||||
pub struct Session {
|
||||
pub name: String,
|
||||
pub exec_cmd: String,
|
||||
#[allow(dead_code)] // Retained for future Wayland-only filtering
|
||||
pub session_type: String,
|
||||
}
|
||||
|
||||
@ -22,6 +23,8 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
|
||||
let mut in_section = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut exec_cmd: Option<String> = None;
|
||||
let mut hidden = false;
|
||||
let mut no_display = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
@ -35,19 +38,37 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(value) = line.strip_prefix("Name=") {
|
||||
if name.is_none() {
|
||||
if let Some(value) = line.strip_prefix("Name=")
|
||||
&& name.is_none()
|
||||
{
|
||||
name = Some(value.to_string());
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Exec=") {
|
||||
if exec_cmd.is_none() {
|
||||
} else if let Some(value) = line.strip_prefix("Exec=")
|
||||
&& exec_cmd.is_none()
|
||||
{
|
||||
exec_cmd = Some(value.to_string());
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Hidden=") {
|
||||
hidden = value.eq_ignore_ascii_case("true");
|
||||
} else if let Some(value) = line.strip_prefix("NoDisplay=") {
|
||||
no_display = value.eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
|
||||
let name = name.filter(|s| !s.is_empty())?;
|
||||
let exec_cmd = exec_cmd.filter(|s| !s.is_empty())?;
|
||||
if hidden || no_display {
|
||||
log::debug!("Skipping {}: Hidden/NoDisplay entry", path.display());
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = name.filter(|s| !s.is_empty());
|
||||
let exec_cmd = exec_cmd.filter(|s| !s.is_empty());
|
||||
|
||||
if name.is_none() || exec_cmd.is_none() {
|
||||
log::debug!("Skipping {}: missing Name={} Exec={}", path.display(),
|
||||
name.is_some(), exec_cmd.is_some());
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = name?;
|
||||
let exec_cmd = exec_cmd?;
|
||||
|
||||
Some(Session {
|
||||
name,
|
||||
@ -74,7 +95,10 @@ pub fn get_sessions(
|
||||
for (dirs, session_type) in [(wayland, "wayland"), (xsession, "x11")] {
|
||||
for directory in dirs {
|
||||
let entries = match fs::read_dir(directory) {
|
||||
Ok(e) => e,
|
||||
Ok(e) => {
|
||||
log::debug!("Scanning session directory: {}", directory.display());
|
||||
e
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
@ -93,6 +117,7 @@ pub fn get_sessions(
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Found {} session(s)", sessions.len());
|
||||
sessions
|
||||
}
|
||||
|
||||
|
||||
56
src/users.rs
56
src/users.rs
@ -23,9 +23,11 @@ const NOLOGIN_SHELLS: &[&str] = &[
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub username: String,
|
||||
#[allow(dead_code)] // Retained for debugging and future UID-based features
|
||||
pub uid: u32,
|
||||
pub gecos: String,
|
||||
pub home: PathBuf,
|
||||
#[allow(dead_code)] // Retained for debugging and future shell-based filtering
|
||||
pub shell: String,
|
||||
}
|
||||
|
||||
@ -46,29 +48,29 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read passwd file {}: {e}", path.display());
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut users = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() < 7 {
|
||||
let mut fields = line.splitn(7, ':');
|
||||
let (Some(username), Some(_pw), Some(uid_str), Some(_gid), Some(gecos), Some(home), Some(shell)) =
|
||||
(fields.next(), fields.next(), fields.next(), fields.next(),
|
||||
fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
|
||||
let username = parts[0];
|
||||
let uid_str = parts[2];
|
||||
let gecos = parts[4];
|
||||
let home = parts[5];
|
||||
let shell = parts[6];
|
||||
};
|
||||
|
||||
let uid = match uid_str.parse::<u32>() {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if uid < MIN_UID || uid > MAX_UID {
|
||||
if !(MIN_UID..=MAX_UID).contains(&uid) {
|
||||
continue;
|
||||
}
|
||||
if NOLOGIN_SHELLS.contains(&shell) {
|
||||
@ -88,10 +90,11 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
|
||||
});
|
||||
}
|
||||
|
||||
log::debug!("Found {} login user(s)", users.len());
|
||||
users
|
||||
}
|
||||
|
||||
/// Find avatar for a user: AccountsService icon > ~/.face > None.
|
||||
/// Find avatar for a user: ~/.face > AccountsService icon > None.
|
||||
/// Rejects symlinks to prevent path traversal.
|
||||
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
|
||||
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
||||
@ -103,20 +106,31 @@ pub fn get_avatar_path_with(
|
||||
home: &Path,
|
||||
accountsservice_dir: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
// AccountsService icon takes priority
|
||||
// ~/.face takes priority (consistent with moonlock/moonset)
|
||||
let face = home.join(".face");
|
||||
if let Ok(meta) = face.symlink_metadata() {
|
||||
if meta.file_type().is_symlink() {
|
||||
log::warn!("Rejecting symlink avatar for {username}: {}", face.display());
|
||||
} else if meta.is_file() {
|
||||
log::debug!("Avatar for {username}: ~/.face {}", face.display());
|
||||
return Some(face);
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsService icon fallback
|
||||
if accountsservice_dir.exists() {
|
||||
let icon = accountsservice_dir.join(username);
|
||||
if icon.exists() && !icon.is_symlink() {
|
||||
if let Ok(meta) = icon.symlink_metadata() {
|
||||
if meta.file_type().is_symlink() {
|
||||
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
|
||||
} else if meta.is_file() {
|
||||
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
|
||||
return Some(icon);
|
||||
}
|
||||
}
|
||||
|
||||
// ~/.face fallback
|
||||
let face = home.join(".face");
|
||||
if face.exists() && !face.is_symlink() {
|
||||
return Some(face);
|
||||
}
|
||||
|
||||
log::debug!("No avatar found for {username}");
|
||||
None
|
||||
}
|
||||
|
||||
@ -234,7 +248,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accountsservice_icon_takes_priority() {
|
||||
fn face_file_takes_priority_over_accountsservice() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let icons_dir = dir.path().join("icons");
|
||||
fs::create_dir(&icons_dir).unwrap();
|
||||
@ -247,7 +261,7 @@ mod tests {
|
||||
fs::write(&face, "fake face").unwrap();
|
||||
|
||||
let path = get_avatar_path_with("testuser", &home, &icons_dir);
|
||||
assert_eq!(path, Some(icon));
|
||||
assert_eq!(path, Some(face));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user