From 226bbb75e4208f3e6d04976d092e6a8823454841 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Fri, 27 Mar 2026 22:08:33 +0100 Subject: [PATCH] Rewrite moongreet from Python to Rust (v0.3.0) Complete rewrite of the greetd greeter from Python/PyGObject to Rust/gtk4-rs for consistency with moonset, single binary without Python runtime, and improved security through Rust memory safety. Modules: main, greeter, ipc, config, i18n, users, sessions, power 86 unit tests covering all modules including login_worker IPC flow. Security hardening: shell-word splitting for exec_cmd, absolute path validation for session binaries, session-name sanitization, absolute loginctl path, atomic IPC writes. --- .gitignore | 11 +- CLAUDE.md | 46 +- Cargo.lock | 1294 +++++++++++++++ Cargo.toml | 25 + README.md | 53 +- build.rs | 10 + pkg/PKGBUILD | 17 +- pyproject.toml | 30 - .../data => resources}/default-avatar.svg | 0 resources/resources.gresource.xml | 8 + {src/moongreet => resources}/style.css | 5 + .../data => resources}/wallpaper.jpg | Bin src/config.rs | 219 +++ src/greeter.rs | 1415 +++++++++++++++++ src/i18n.rs | 333 ++++ src/ipc.rs | 294 ++++ src/main.rs | 110 ++ src/moongreet/__init__.py | 2 - src/moongreet/config.py | 84 - .../moongreet-default-avatar-symbolic.svg | 1 - src/moongreet/greeter.py | 664 -------- src/moongreet/i18n.py | 117 -- src/moongreet/ipc.py | 64 - src/moongreet/main.py | 162 -- src/moongreet/power.py | 17 - src/moongreet/sessions.py | 63 - src/moongreet/users.py | 109 -- src/power.rs | 112 ++ src/sessions.rs | 228 +++ src/users.rs | 280 ++++ tests/__init__.py | 0 tests/test_config.py | 110 -- tests/test_i18n.py | 126 -- tests/test_integration.py | 478 ------ tests/test_ipc.py | 251 --- tests/test_power.py | 61 - tests/test_sessions.py | 104 -- tests/test_users.py | 215 --- uv.lock | 45 - 39 files changed, 4395 insertions(+), 2768 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 build.rs delete mode 100644 pyproject.toml rename {src/moongreet/data => resources}/default-avatar.svg (100%) create mode 100644 resources/resources.gresource.xml rename {src/moongreet => resources}/style.css (94%) rename {src/moongreet/data => resources}/wallpaper.jpg (100%) create mode 100644 src/config.rs create mode 100644 src/greeter.rs create mode 100644 src/i18n.rs create mode 100644 src/ipc.rs create mode 100644 src/main.rs delete mode 100644 src/moongreet/__init__.py delete mode 100644 src/moongreet/config.py delete mode 100644 src/moongreet/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg delete mode 100644 src/moongreet/greeter.py delete mode 100644 src/moongreet/i18n.py delete mode 100644 src/moongreet/ipc.py delete mode 100644 src/moongreet/main.py delete mode 100644 src/moongreet/power.py delete mode 100644 src/moongreet/sessions.py delete mode 100644 src/moongreet/users.py create mode 100644 src/power.rs create mode 100644 src/sessions.rs create mode 100644 src/users.rs delete mode 100644 tests/__init__.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_i18n.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_ipc.py delete mode 100644 tests/test_power.py delete mode 100644 tests/test_sessions.py delete mode 100644 tests/test_users.py delete mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 47d649f..ff0e150 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,4 @@ -__pycache__/ -*.py[cod] -*$py.class -*.egg-info/ -dist/ -build/ -.venv/ -.pytest_cache/ -.pyright/ -*.egg +/target # makepkg build artifacts pkg/src/ diff --git a/CLAUDE.md b/CLAUDE.md index bff9a29..4f14dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,48 +4,52 @@ ## Projekt -Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Python + GTK4 + gtk4-layer-shell. +Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Rust + gtk4-rs + gtk4-layer-shell. Teil des Moonarch-Ökosystems. ## Tech-Stack -- Python 3.11+, PyGObject (GTK 4.0) -- gtk4-layer-shell für Wayland Layer Shell +- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22 +- gtk4-layer-shell 0.8 für Wayland Layer Shell (TOP Layer) - greetd IPC über Unix Domain Socket (length-prefixed JSON) -- pytest für Tests +- `cargo test` für Unit-Tests ## Projektstruktur -- `src/moongreet/` — Quellcode -- `src/moongreet/data/` — Package-Assets (Default-Avatar, Icons) — werden mit dem Wheel ausgeliefert -- `tests/` — pytest Tests -- `data/` — User-Assets (wallpaper.jpg) — nicht Teil des Packages +- `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) - `config/` — Beispiel-Konfigurationsdateien für `/etc/moongreet/` und `/etc/greetd/` ## Kommandos ```bash # Tests ausführen -uv run pytest tests/ -v +cargo test -# Typ-Checks -uv run pyright src/ +# Release-Build +cargo build --release # Greeter starten (nur zum Testen, braucht normalerweise greetd) -uv run moongreet +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet ``` ## Architektur -- `ipc.py` — greetd Socket-Kommunikation (length-prefixed JSON) -- `users.py` — Benutzer aus /etc/passwd, Avatare, GTK-Themes -- `sessions.py` — Wayland/X11 Sessions aus .desktop Files -- `power.py` — Reboot/Shutdown via loginctl -- `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN) -- `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen, WallpaperWindow für Sekundärmonitore -- `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor-Orchestrierung +- `ipc.rs` — greetd Socket-Kommunikation (4-byte LE header + JSON) +- `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection +- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files +- `power.rs` — Reboot/Shutdown via loginctl +- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN) +- `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 +- `resources/style.css` — Catppuccin-inspiriertes Theme ## Design Decisions -- **Synchrones I/O im GTK-Konstruktor**: `load_config`, `load_strings`, `get_users` und `get_sessions` laufen synchron in `GreeterWindow.__init__`. Async Loading mit Placeholder-UI wäre möglich, erhöht aber die Komplexität erheblich. Der Greeter startet 1x pro Boot auf lokaler Hardware — die Daten sind klein (passwd, locale.conf, wenige .desktop-Files), die Latenz im Normalfall vernachlässigbar. -- **Synchrones Avatar-Decoding**: `GdkPixbuf.Pixbuf.new_from_file_at_scale` läuft synchron auf dem Main Thread. Bei großen Bildern als `.face`-Datei kann die UI kurz stocken. Der Avatar-Cache (`_avatar_cache`) federt das nach dem ersten Laden ab. Async Decoding per Worker-Thread + `GLib.idle_add` wäre die Alternative, rechtfertigt aber den Aufwand nicht für einen Single-User-Greeter. +- **TOP Layer statt OVERLAY**: Greeter läuft unter greetd, nicht über Waybar +- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert +- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads +- **Socket-Cancellation**: `Arc>>` + `AtomicBool` für saubere Abbrüche +- **Avatar-Cache**: `HashMap` in `Rc>` +- **Symmetrie mit moonset**: Gleiche Patterns (i18n, config, users, power, GResource) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0500f9e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1294 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.5" +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", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf3abfd1e60b194dded50f802277c68a59121a5a221701102f02db223cdda27" +dependencies = [ + "gio", +] + +[[package]] +name = "glib-macros" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "moongreet" +version = "0.3.0" +dependencies = [ + "env_logger", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "glib-build-tools", + "gtk4", + "gtk4-layer-shell", + "log", + "serde", + "serde_json", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "once_cell" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.12+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bdac1a2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "moongreet" +version = "0.3.0" +edition = "2024" +description = "A greetd greeter for Wayland with GTK4 and Layer Shell" +license = "MIT" + +[dependencies] +gtk4 = { version = "0.11", features = ["v4_10"] } +gtk4-layer-shell = "0.8" +glib = "0.22" +gdk4 = "0.11" +gdk-pixbuf = "0.22" +gio = "0.22" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" +env_logger = "0.11" + +[dev-dependencies] +tempfile = "3" + +[build-dependencies] +glib-build-tools = "0.22" diff --git a/README.md b/README.md index 5c1ed94..dc76e08 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,58 @@ # Moongreet -A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell. +A greetd greeter for Wayland, built with Rust + GTK4 + gtk4-layer-shell. Part of the Moonarch ecosystem. ## Features - **greetd IPC** — Communicates via `$GREETD_SOCK` (length-prefixed JSON) - **User list** — Parsed from `/etc/passwd` (UID 1000–65533) -- **Avatars** — AccountsService icons, `~/.face` fallback, default SVG +- **Avatars** — AccountsService icons, `~/.face` fallback, default SVG with theme tinting - **Sessions** — Discovered from `/usr/share/wayland-sessions/` and `/usr/share/xsessions/` -- **Last user** — Remembered in `/var/cache/moongreet/last-user` +- **Last user/session** — Remembered in `/var/cache/moongreet/` - **Power actions** — Reboot / Shutdown via `loginctl` -- **Layer Shell** — Fullscreen via gtk4-layer-shell +- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer) +- **Multi-monitor** — Greeter on primary, wallpaper on all monitors +- **i18n** — German and English (auto-detected from system locale) +- **Faillock warning** — Warns after 2 failed attempts, locked message after 3 ## Requirements -- Python 3.11+ -- GTK 4, PyGObject +- GTK 4 - gtk4-layer-shell (for Wayland fullscreen) - greetd +## Building + +```bash +cargo build --release +``` + ## Installation ```bash -uv pip install . +# Install binary +sudo install -Dm755 target/release/moongreet /usr/bin/moongreet + +# Install config +sudo mkdir -p /etc/moongreet +sudo cp config/moongreet.toml /etc/moongreet/moongreet.toml ``` ## System Setup -1. Copy configuration: - ```bash - sudo mkdir -p /etc/moongreet - sudo cp config/moongreet.toml /etc/moongreet/moongreet.toml - ``` +1. Edit `/etc/moongreet/moongreet.toml` — set an absolute path for the wallpaper. -2. Edit `/etc/moongreet/moongreet.toml` — set an absolute path for the wallpaper. - -3. Create cache directory: +2. Create cache directory: ```bash - sudo mkdir -p /var/cache/moongreet + sudo mkdir -p /var/cache/moongreet/last-session sudo chown greeter:greeter /var/cache/moongreet ``` -4. Configure greetd (`/etc/greetd/config.toml`): +3. Configure greetd (`/etc/greetd/config.toml`): ```ini [default_session] - command = "moongreet" + command = "niri -c /etc/greetd/niri-greeter.kdl" user = "greeter" ``` @@ -53,13 +60,13 @@ uv pip install . ```bash # Run tests -uv run pytest tests/ -v +cargo test -# Type checking -uv run pyright src/ +# Build release +cargo build --release -# Run locally (without greetd) -uv run moongreet +# Run locally (without greetd, needs LD_PRELOAD for layer-shell) +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet ``` ## License diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..13986c5 --- /dev/null +++ b/build.rs @@ -0,0 +1,10 @@ +// ABOUTME: Build script for compiling GResource bundle. +// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary. + +fn main() { + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "moongreet.gresource", + ); +} diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index a54c3be..327c080 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -4,24 +4,20 @@ # Maintainer: Dominik Kressler pkgname=moongreet-git -pkgver=0.2.0.r0.g64f08d7 +pkgver=0.3.0.r0.g0000000 pkgrel=1 -pkgdesc="A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell" -arch=('any') +pkgdesc="A greetd greeter for Wayland with GTK4 and Layer Shell" +arch=('x86_64') url="https://gitea.moonarch.de/nevaforget/greetd-moongreet" license=('MIT') depends=( - 'python' - 'python-gobject' 'gtk4' 'gtk4-layer-shell' 'greetd' ) makedepends=( 'git' - 'python-build' - 'python-installer' - 'python-hatchling' + 'cargo' ) provides=('moongreet') conflicts=('moongreet') @@ -36,13 +32,12 @@ pkgver() { build() { cd "$srcdir/greetd-moongreet" - rm -rf dist/ - python -m build --wheel --no-isolation + cargo build --release --locked } package() { cd "$srcdir/greetd-moongreet" - python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm755 target/release/moongreet "$pkgdir/usr/bin/moongreet" # Greeter config install -Dm644 config/moongreet.toml "$pkgdir/etc/moongreet/moongreet.toml" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 5b5247c..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "moongreet" -version = "0.2.1" -description = "A greetd greeter for Wayland with GTK4" -requires-python = ">=3.11" -license = "MIT" -dependencies = [ - "PyGObject>=3.46", -] - -[project.scripts] -moongreet = "moongreet.main:main" - -[tool.hatch.build.targets.wheel] -packages = ["src/moongreet"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["src"] - -[tool.pyright] -pythonVersion = "3.11" -pythonPlatform = "Linux" -venvPath = "." -venv = ".venv" -typeCheckingMode = "standard" diff --git a/src/moongreet/data/default-avatar.svg b/resources/default-avatar.svg similarity index 100% rename from src/moongreet/data/default-avatar.svg rename to resources/default-avatar.svg diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml new file mode 100644 index 0000000..cb938c4 --- /dev/null +++ b/resources/resources.gresource.xml @@ -0,0 +1,8 @@ + + + + style.css + wallpaper.jpg + default-avatar.svg + + diff --git a/src/moongreet/style.css b/resources/style.css similarity index 94% rename from src/moongreet/style.css rename to resources/style.css index 45a0416..f314212 100644 --- a/src/moongreet/style.css +++ b/resources/style.css @@ -8,6 +8,11 @@ window.greeter { background-position: center; } +/* Wallpaper-only window for secondary monitors */ +window.wallpaper { + background-color: #1a1a2e; +} + /* Central login area */ .login-box { padding: 40px; diff --git a/src/moongreet/data/wallpaper.jpg b/resources/wallpaper.jpg similarity index 100% rename from src/moongreet/data/wallpaper.jpg rename to resources/wallpaper.jpg diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6635fd9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,219 @@ +// ABOUTME: Configuration loading for the greeter. +// ABOUTME: Reads moongreet.toml for wallpaper and GTK theme settings with fallback hierarchy. + +use serde::Deserialize; +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 { + vec![PathBuf::from("/etc/moongreet/moongreet.toml")] +} + +/// Raw TOML structure for deserialization. +#[derive(Debug, Clone, Default, Deserialize)] +struct TomlConfig { + appearance: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct Appearance { + background: Option, + #[serde(rename = "gtk-theme")] + gtk_theme: Option, +} + +/// Greeter configuration. +#[derive(Debug, Clone, Default)] +pub struct Config { + pub background_path: Option, + pub gtk_theme: Option, +} + +/// Load config from TOML files. Later paths override earlier ones. +pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { + let default_paths = default_config_paths(); + let paths = config_paths.unwrap_or(&default_paths); + + 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::(&content) { + if let Some(appearance) = parsed.appearance { + if let Some(bg) = appearance.background { + // Resolve relative paths against config file directory + let bg_path = PathBuf::from(&bg); + 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()); + } + } + if appearance.gtk_theme.is_some() { + merged.gtk_theme = appearance.gtk_theme; + } + } + } + } + } + + 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 { + 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 + if let Some(ref bg) = config.background_path { + let path = PathBuf::from(bg); + if path.is_file() { + return path; + } + } + + // 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")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_none_fields() { + let config = Config::default(); + assert!(config.background_path.is_none()); + assert!(config.gtk_theme.is_none()); + } + + #[test] + fn load_config_returns_default_when_no_files_exist() { + let paths = vec![PathBuf::from("/nonexistent/moongreet.toml")]; + let config = load_config(Some(&paths)); + assert!(config.background_path.is_none()); + assert!(config.gtk_theme.is_none()); + } + + #[test] + fn load_config_reads_appearance_section() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moongreet.toml"); + fs::write( + &conf, + "[appearance]\nbackground = \"/custom/wallpaper.jpg\"\ngtk-theme = \"catppuccin\"\n", + ) + .unwrap(); + let paths = vec![conf]; + let config = load_config(Some(&paths)); + assert_eq!( + config.background_path.as_deref(), + Some("/custom/wallpaper.jpg") + ); + assert_eq!(config.gtk_theme.as_deref(), Some("catppuccin")); + } + + #[test] + fn load_config_resolves_relative_background() { + 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)); + let expected = dir.path().join("bg.jpg").to_string_lossy().to_string(); + assert_eq!(config.background_path.as_deref(), Some(expected.as_str())); + } + + #[test] + fn load_config_later_paths_override_earlier() { + let dir = tempfile::tempdir().unwrap(); + let conf1 = dir.path().join("first.toml"); + let conf2 = dir.path().join("second.toml"); + fs::write( + &conf1, + "[appearance]\nbackground = \"/first.jpg\"\ngtk-theme = \"first\"\n", + ) + .unwrap(); + fs::write( + &conf2, + "[appearance]\nbackground = \"/second.jpg\"\ngtk-theme = \"second\"\n", + ) + .unwrap(); + let paths = vec![conf1, conf2]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_path.as_deref(), Some("/second.jpg")); + assert_eq!(config.gtk_theme.as_deref(), Some("second")); + } + + #[test] + fn load_config_skips_missing_files() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("exists.toml"); + fs::write( + &conf, + "[appearance]\nbackground = \"/exists.jpg\"\n", + ) + .unwrap(); + let paths = vec![PathBuf::from("/nonexistent.toml"), conf]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_path.as_deref(), Some("/exists.jpg")); + } + + #[test] + fn resolve_uses_config_path_when_file_exists() { + let dir = tempfile::tempdir().unwrap(); + let wallpaper = dir.path().join("custom.jpg"); + fs::write(&wallpaper, "fake").unwrap(); + let config = Config { + background_path: Some(wallpaper.to_str().unwrap().to_string()), + gtk_theme: None, + }; + assert_eq!( + resolve_background_path_with(&config, Path::new("/nonexistent")), + wallpaper + ); + } + + #[test] + fn resolve_ignores_config_path_when_file_missing() { + let config = Config { + background_path: Some("/nonexistent/wallpaper.jpg".to_string()), + gtk_theme: None, + }; + let result = resolve_background_path_with(&config, Path::new("/nonexistent")); + assert!(result.to_str().unwrap().contains("moongreet")); + } + + #[test] + fn resolve_uses_moonarch_wallpaper_as_second_fallback() { + let dir = tempfile::tempdir().unwrap(); + let moonarch_wp = dir.path().join("wallpaper.jpg"); + fs::write(&moonarch_wp, "fake").unwrap(); + let config = Config::default(); + assert_eq!( + resolve_background_path_with(&config, &moonarch_wp), + moonarch_wp + ); + } + + #[test] + fn resolve_uses_gresource_fallback_as_last_resort() { + let config = Config::default(); + let result = resolve_background_path_with(&config, Path::new("/nonexistent")); + assert!(result.to_str().unwrap().contains("wallpaper.jpg")); + } +} diff --git a/src/greeter.rs b/src/greeter.rs new file mode 100644 index 0000000..be451d1 --- /dev/null +++ b/src/greeter.rs @@ -0,0 +1,1415 @@ +// ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter. +// ABOUTME: Handles user selection, session choice, password entry, and power actions. + +use gdk4 as gdk; +use gdk_pixbuf::Pixbuf; +use glib::clone; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use crate::config::Config; +use crate::i18n::{faillock_warning, load_strings, Strings}; +use crate::ipc; +use crate::power::{self, PowerError}; +use crate::sessions::{self, Session}; +use crate::users::{self, User}; + +const AVATAR_SIZE: i32 = 128; +const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024; +const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user"; +const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session"; +const MAX_USERNAME_LENGTH: usize = 256; +const MAX_SESSION_NAME_LENGTH: usize = 256; +const MAX_GREETD_ERROR_LENGTH: usize = 200; + +/// Split a string into shell words, respecting single and double quotes. +/// Returns None if quotes are unbalanced. +fn split_shell_words(s: &str) -> Option> { + let mut words = Vec::new(); + let mut current = String::new(); + let mut chars = s.chars().peekable(); + let mut in_single = false; + let mut in_double = false; + + while let Some(c) = chars.next() { + match c { + '\'' if !in_double => { + in_single = !in_single; + } + '"' if !in_single => { + in_double = !in_double; + } + '\\' if in_double => { + // In double quotes, backslash escapes the next char + if let Some(next) = chars.next() { + current.push(next); + } + } + '\\' if !in_single && !in_double => { + // Outside quotes, backslash escapes the next char + if let Some(next) = chars.next() { + current.push(next); + } + } + c if c.is_whitespace() && !in_single && !in_double => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + _ => { + current.push(c); + } + } + } + + if in_single || in_double { + return None; + } + + if !current.is_empty() { + words.push(current); + } + + Some(words) +} + +/// Validate a username against safe patterns. +fn is_valid_username(name: &str) -> bool { + if name.is_empty() || name.len() > MAX_USERNAME_LENGTH { + return false; + } + let first = name.chars().next().unwrap(); + if !first.is_ascii_alphanumeric() && first != '_' { + return false; + } + name.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') +} + +/// Create a wallpaper-only window for secondary monitors. +pub fn create_wallpaper_window( + bg_path: &Path, + app: >k::Application, +) -> gtk::ApplicationWindow { + let window = gtk::ApplicationWindow::builder() + .application(app) + .build(); + window.add_css_class("wallpaper"); + + let background = create_background_picture(bg_path); + window.set_child(Some(&background)); + + window +} + +/// Create a Picture widget for the wallpaper background. +fn create_background_picture(bg_path: &Path) -> gtk::Picture { + let background = if bg_path.starts_with("/dev/moonarch/moongreet") { + gtk::Picture::for_resource(bg_path.to_str().unwrap_or("")) + } else { + gtk::Picture::for_filename(bg_path.to_str().unwrap_or("")) + }; + background.set_content_fit(gtk::ContentFit::Cover); + background.set_hexpand(true); + background.set_vexpand(true); + background +} + +/// Shared mutable state for the greeter UI. +struct GreeterState { + selected_user: Option, + avatar_cache: HashMap, + default_avatar_texture: Option, + failed_attempts: HashMap, + greetd_sock: Arc>>, + login_cancelled: Arc, +} + +/// Create the main greeter window with login UI. +pub fn create_greeter_window( + bg_path: &Path, + config: &Config, + app: >k::Application, +) -> gtk::ApplicationWindow { + let window = gtk::ApplicationWindow::builder() + .application(app) + .build(); + window.add_css_class("greeter"); + window.set_default_size(1920, 1080); + + // Apply GTK theme from config + if let Some(ref theme_name) = config.gtk_theme { + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_theme_name(Some(theme_name)); + } + } + + let strings = load_strings(None); + let all_users = users::get_users(None); + let all_sessions = sessions::get_sessions(None, None); + + let state = Rc::new(RefCell::new(GreeterState { + selected_user: None, + avatar_cache: HashMap::new(), + default_avatar_texture: None, + failed_attempts: HashMap::new(), + greetd_sock: Arc::new(Mutex::new(None)), + login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)), + })); + + // Root overlay for layering + let overlay = gtk::Overlay::new(); + window.set_child(Some(&overlay)); + + // Background wallpaper + overlay.set_child(Some(&create_background_picture(bg_path))); + + // Main layout: 3 rows (top spacer, center login, bottom bar) + let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + main_box.set_hexpand(true); + main_box.set_vexpand(true); + overlay.add_overlay(&main_box); + + // Top spacer + let top_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); + top_spacer.set_vexpand(true); + main_box.append(&top_spacer); + + // Center: login box + let login_box = gtk::Box::new(gtk::Orientation::Vertical, 12); + login_box.add_css_class("login-box"); + login_box.set_halign(gtk::Align::Center); + login_box.set_valign(gtk::Align::Center); + + // Avatar + let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0); + avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE); + avatar_frame.set_halign(gtk::Align::Center); + avatar_frame.set_overflow(gtk::Overflow::Hidden); + avatar_frame.add_css_class("avatar"); + let avatar_image = gtk::Image::new(); + avatar_image.set_pixel_size(AVATAR_SIZE); + avatar_frame.append(&avatar_image); + login_box.append(&avatar_frame); + + // Username label + let username_label = gtk::Label::new(Some("")); + username_label.add_css_class("username-label"); + login_box.append(&username_label); + + // Session dropdown + let session_dropdown = gtk::DropDown::builder().build(); + session_dropdown.add_css_class("session-dropdown"); + session_dropdown.set_hexpand(true); + if !all_sessions.is_empty() { + let names: Vec<&str> = all_sessions.iter().map(|s| s.name.as_str()).collect(); + let string_list = gtk::StringList::new(&names); + session_dropdown.set_model(Some(&string_list)); + } + login_box.append(&session_dropdown); + + // Password entry + let password_entry = gtk::PasswordEntry::builder() + .placeholder_text(strings.password_placeholder) + .show_peek_icon(true) + .hexpand(true) + .build(); + password_entry.add_css_class("password-entry"); + login_box.append(&password_entry); + + // Error label (hidden by default) + let error_label = gtk::Label::new(None); + error_label.add_css_class("error-label"); + error_label.set_visible(false); + login_box.append(&error_label); + + login_box.set_halign(gtk::Align::Center); + main_box.append(&login_box); + + // Bottom spacer + let bottom_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); + bottom_spacer.set_vexpand(true); + main_box.append(&bottom_spacer); + + // Bottom bar overlay (user list left, power buttons right) + let bottom_bar = gtk::Box::new(gtk::Orientation::Horizontal, 0); + bottom_bar.set_hexpand(true); + bottom_bar.set_margin_start(16); + bottom_bar.set_margin_end(16); + bottom_bar.set_margin_bottom(16); + bottom_bar.set_valign(gtk::Align::End); + + // User list (left) + let user_list_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + user_list_box.add_css_class("user-list"); + user_list_box.set_halign(gtk::Align::Start); + user_list_box.set_valign(gtk::Align::End); + + let sessions_rc = Rc::new(all_sessions); + let users_rc = Rc::new(all_users); + + for user in users_rc.iter() { + let btn = gtk::Button::with_label(user.display_name()); + btn.add_css_class("user-list-item"); + + let user_clone = user.clone(); + btn.connect_clicked(clone!( + #[weak] + avatar_image, + #[weak] + username_label, + #[weak] + password_entry, + #[weak] + error_label, + #[weak] + session_dropdown, + #[weak] + window, + #[strong] + state, + #[strong] + sessions_rc, + move |_| { + cancel_pending_session(&state); + switch_to_user( + &user_clone, + &state, + &avatar_image, + &username_label, + &password_entry, + &error_label, + &session_dropdown, + &sessions_rc, + &window, + ); + } + )); + user_list_box.append(&btn); + } + + bottom_bar.append(&user_list_box); + + // Spacer between user list and power buttons + let bar_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); + bar_spacer.set_hexpand(true); + bottom_bar.append(&bar_spacer); + + // Power buttons (right) + let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + power_box.set_halign(gtk::Align::End); + power_box.set_valign(gtk::Align::End); + + let reboot_btn = gtk::Button::new(); + reboot_btn.set_icon_name("system-reboot-symbolic"); + reboot_btn.add_css_class("power-button"); + reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip)); + reboot_btn.connect_clicked(clone!( + #[weak] + error_label, + move |btn| { + btn.set_sensitive(false); + execute_power_action(power::reboot, strings.reboot_failed, &error_label); + } + )); + 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); + } + )); + power_box.append(&shutdown_btn); + + bottom_bar.append(&power_box); + overlay.add_overlay(&bottom_bar); + + // Password entry "activate" (Enter key) handler + password_entry.connect_activate(clone!( + #[strong] + state, + #[strong] + sessions_rc, + #[weak] + session_dropdown, + #[weak] + error_label, + #[weak] + password_entry, + #[weak] + app, + move |entry| { + let user = { + let s = state.borrow(); + s.selected_user.clone() + }; + let Some(user) = user else { return }; + + let password = entry.text().to_string(); + + let session = get_selected_session(&session_dropdown, &sessions_rc); + let Some(session) = session else { + show_error(&error_label, &password_entry, strings.no_session_selected); + return; + }; + + attempt_login( + &user, + &password, + &session, + strings, + &state, + &app, + &error_label, + &password_entry, + &session_dropdown, + ); + } + )); + + // Keyboard handling — Escape clears password and error + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(clone!( + #[weak] + password_entry, + #[weak] + error_label, + #[upgrade_or] + glib::Propagation::Proceed, + move |_, keyval, _, _| { + if keyval == gdk::Key::Escape { + password_entry.set_text(""); + error_label.set_visible(false); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + )); + window.add_controller(key_controller); + + // Defer initial user selection until realized (for correct theme colors) + window.connect_realize(clone!( + #[strong] + state, + #[strong] + users_rc, + #[strong] + sessions_rc, + #[weak] + avatar_image, + #[weak] + username_label, + #[weak] + password_entry, + #[weak] + error_label, + #[weak] + session_dropdown, + #[weak] + window, + move |_| { + let state = state.clone(); + let users_rc = users_rc.clone(); + let sessions_rc = sessions_rc.clone(); + glib::idle_add_local_once(clone!( + #[weak] + avatar_image, + #[weak] + username_label, + #[weak] + password_entry, + #[weak] + error_label, + #[weak] + session_dropdown, + #[weak] + window, + move || { + select_initial_user( + &users_rc, + &state, + &avatar_image, + &username_label, + &password_entry, + &error_label, + &session_dropdown, + &sessions_rc, + &window, + ); + } + )); + } + )); + + window +} + +/// Select the last user or the first available user. +fn select_initial_user( + users: &[User], + state: &Rc>, + avatar_image: >k::Image, + username_label: >k::Label, + password_entry: >k::PasswordEntry, + error_label: >k::Label, + session_dropdown: >k::DropDown, + sessions: &[Session], + window: >k::ApplicationWindow, +) { + if users.is_empty() { + return; + } + + let last_username = load_last_user(); + let target = last_username + .as_ref() + .and_then(|name| users.iter().find(|u| &u.username == name)) + .unwrap_or(&users[0]); + + switch_to_user( + target, + state, + avatar_image, + username_label, + password_entry, + error_label, + session_dropdown, + sessions, + window, + ); +} + +/// Update the UI to show the selected user. +fn switch_to_user( + user: &User, + state: &Rc>, + avatar_image: >k::Image, + username_label: >k::Label, + password_entry: >k::PasswordEntry, + error_label: >k::Label, + session_dropdown: >k::DropDown, + sessions: &[Session], + window: >k::ApplicationWindow, +) { + { + let mut s = state.borrow_mut(); + s.selected_user = Some(user.clone()); + } + + username_label.set_text(user.display_name()); + password_entry.set_text(""); + error_label.set_visible(false); + + // Update avatar + let cached = { + let s = state.borrow(); + s.avatar_cache.get(&user.username).cloned() + }; + + if let Some(texture) = cached { + avatar_image.set_paintable(Some(&texture)); + } else { + let avatar_path = users::get_avatar_path(&user.username, &user.home); + if let Some(path) = avatar_path { + // get_avatar_path already checks existence — go straight to loading + set_avatar_from_file(avatar_image, &path, Some(&user.username), state); + } else { + set_default_avatar(avatar_image, window, state); + } + } + + // Pre-select last used session for this user + select_last_session(&user.username, session_dropdown, sessions); + + password_entry.grab_focus(); +} + +/// Load an image file and set it as the avatar. +fn set_avatar_from_file( + image: >k::Image, + path: &Path, + username: Option<&str>, + state: &Rc>, +) { + // Reject oversized files + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > MAX_AVATAR_FILE_SIZE { + image.set_icon_name(Some("avatar-default-symbolic")); + return; + } + } + + match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { + Ok(pixbuf) => { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + if let Some(name) = username { + state + .borrow_mut() + .avatar_cache + .insert(name.to_string(), texture.clone()); + } + image.set_paintable(Some(&texture)); + } + Err(_) => { + image.set_icon_name(Some("avatar-default-symbolic")); + } + } +} + +/// Load the default avatar SVG from GResources, tinted with the foreground color. +fn set_default_avatar( + image: >k::Image, + window: >k::ApplicationWindow, + state: &Rc>, +) { + // Use cached version if available + { + let s = state.borrow(); + if let Some(ref texture) = s.default_avatar_texture { + image.set_paintable(Some(texture)); + return; + } + } + + let resource_path = users::get_default_avatar_path(); + if let Ok(bytes) = + gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) + { + let svg_text = String::from_utf8_lossy(&bytes); + + let rgba = window.color(); + let fg_color = format!( + "#{:02x}{:02x}{:02x}", + (rgba.red() * 255.0) as u8, + (rgba.green() * 255.0) as u8, + (rgba.blue() * 255.0) as u8, + ); + let tinted = svg_text.replace("#PLACEHOLDER", &fg_color); + let svg_bytes = tinted.as_bytes(); + + if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") { + loader.set_size(AVATAR_SIZE, AVATAR_SIZE); + if loader.write(svg_bytes).is_ok() { + let _ = loader.close(); + if let Some(pixbuf) = loader.pixbuf() { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + state.borrow_mut().default_avatar_texture = Some(texture.clone()); + image.set_paintable(Some(&texture)); + return; + } + } + } + } + + // Fallback + image.set_icon_name(Some("avatar-default-symbolic")); +} + +/// Get the currently selected session from the dropdown. +fn get_selected_session( + dropdown: >k::DropDown, + sessions: &[Session], +) -> Option { + if sessions.is_empty() { + return None; + } + let idx = dropdown.selected() as usize; + sessions.get(idx).cloned() +} + +/// Pre-select the last used session for a user in the dropdown. +fn select_last_session( + username: &str, + dropdown: >k::DropDown, + sessions: &[Session], +) { + if sessions.is_empty() { + return; + } + let last_name = load_last_session(username); + if let Some(name) = last_name { + for (i, session) in sessions.iter().enumerate() { + if session.name == name { + dropdown.set_selected(i as u32); + return; + } + } + } +} + +/// Display an error message below the password field. +fn show_error( + error_label: >k::Label, + password_entry: >k::PasswordEntry, + message: &str, +) { + error_label.set_text(message); + error_label.set_visible(true); + password_entry.set_text(""); + password_entry.grab_focus(); +} + +/// Display a greetd error, using a fallback for missing or oversized descriptions. +fn show_greetd_error( + error_label: >k::Label, + password_entry: >k::PasswordEntry, + response: &serde_json::Value, + fallback: &str, +) { + let description = response + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !description.is_empty() && description.len() <= MAX_GREETD_ERROR_LENGTH { + show_error(error_label, password_entry, description); + } else { + show_error(error_label, password_entry, fallback); + } +} + +/// Cancel any in-progress greetd session. +fn cancel_pending_session(state: &Rc>) { + let s = state.borrow(); + s.login_cancelled + .store(true, std::sync::atomic::Ordering::SeqCst); + if let Ok(mut sock_guard) = s.greetd_sock.lock() { + if let Some(sock) = sock_guard.take() { + let _ = sock.shutdown(std::net::Shutdown::Both); + } + } +} + +/// Set login controls sensitivity. +fn set_login_sensitive( + password_entry: >k::PasswordEntry, + session_dropdown: >k::DropDown, + sensitive: bool, +) { + password_entry.set_sensitive(sensitive); + session_dropdown.set_sensitive(sensitive); +} + +/// Attempt to authenticate and start a session via greetd IPC. +#[allow(clippy::too_many_arguments)] +fn attempt_login( + user: &User, + password: &str, + session: &Session, + strings: &'static Strings, + state: &Rc>, + app: >k::Application, + error_label: >k::Label, + password_entry: >k::PasswordEntry, + session_dropdown: >k::DropDown, +) { + let sock_path = match std::env::var("GREETD_SOCK") { + Ok(p) if !p.is_empty() => p, + _ => { + show_error(error_label, password_entry, strings.greetd_sock_not_set); + return; + } + }; + + // Validate socket path + let sock_pathbuf = PathBuf::from(&sock_path); + if !sock_pathbuf.is_absolute() { + show_error( + error_label, + password_entry, + strings.greetd_sock_not_absolute, + ); + return; + } + + match std::fs::metadata(&sock_pathbuf) { + Ok(meta) => { + use std::os::unix::fs::FileTypeExt; + if !meta.file_type().is_socket() { + show_error( + error_label, + password_entry, + strings.greetd_sock_not_socket, + ); + return; + } + } + Err(_) => { + show_error( + error_label, + password_entry, + strings.greetd_sock_unreachable, + ); + return; + } + } + + // Reset cancellation flag and disable UI + { + let s = state.borrow(); + s.login_cancelled + .store(false, std::sync::atomic::Ordering::SeqCst); + } + set_login_sensitive(password_entry, session_dropdown, false); + + let username = user.username.clone(); + let password = password.to_string(); + let exec_cmd = session.exec_cmd.clone(); + let session_name = session.name.clone(); + let greetd_sock = state.borrow().greetd_sock.clone(); + let login_cancelled = state.borrow().login_cancelled.clone(); + + glib::spawn_future_local(clone!( + #[weak] + app, + #[weak] + error_label, + #[weak] + password_entry, + #[weak] + session_dropdown, + #[strong] + state, + async move { + let session_name_clone = session_name.clone(); + let result = gio::spawn_blocking(move || { + login_worker( + &username, + &password, + &exec_cmd, + &sock_path, + &greetd_sock, + &login_cancelled, + ) + }) + .await; + + match result { + Ok(Ok(LoginResult::Success { username })) => { + save_last_user(&username); + save_last_session(&username, &session_name_clone); + app.quit(); + } + Ok(Ok(LoginResult::AuthError { response, username: ref uname })) => { + let mut s = state.borrow_mut(); + let count = s.failed_attempts.entry(uname.clone()).or_insert(0); + *count += 1; + let warning = faillock_warning(*count, strings); + drop(s); + + show_greetd_error( + &error_label, + &password_entry, + &response, + strings.wrong_password, + ); + if let Some(w) = warning { + let current = error_label.text().to_string(); + error_label.set_text(&format!("{current}\n{w}")); + } + set_login_sensitive(&password_entry, &session_dropdown, true); + } + Ok(Ok(LoginResult::Error { message })) => { + show_error(&error_label, &password_entry, &message); + set_login_sensitive(&password_entry, &session_dropdown, true); + } + Ok(Ok(LoginResult::Cancelled)) => { + set_login_sensitive(&password_entry, &session_dropdown, true); + } + Ok(Err(e)) => { + log::error!("Login worker error: {e}"); + show_error(&error_label, &password_entry, strings.socket_error); + set_login_sensitive(&password_entry, &session_dropdown, true); + } + Err(_) => { + log::error!("Login worker panicked"); + show_error(&error_label, &password_entry, strings.socket_error); + set_login_sensitive(&password_entry, &session_dropdown, true); + } + } + } + )); +} + +/// Result of a login attempt from the background thread. +enum LoginResult { + Success { + username: String, + }, + AuthError { + response: serde_json::Value, + username: String, + }, + Error { + message: String, + }, + Cancelled, +} + +/// Run greetd IPC in a background thread. +fn login_worker( + username: &str, + password: &str, + exec_cmd: &str, + sock_path: &str, + greetd_sock: &Arc>>, + login_cancelled: &Arc, +) -> Result { + if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(LoginResult::Cancelled); + } + + let mut sock = UnixStream::connect(sock_path).map_err(|e| e.to_string())?; + sock.set_read_timeout(Some(std::time::Duration::from_secs(10))) + .ok(); + { + let mut guard = greetd_sock.lock().map_err(|e| e.to_string())?; + *guard = Some(sock.try_clone().map_err(|e| e.to_string())?); + } + + // Step 1: Create session — if a stale session exists, cancel it and retry + let mut response = ipc::create_session(&mut sock, username).map_err(|e| e.to_string())?; + + if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(LoginResult::Cancelled); + } + + if response.get("type").and_then(|v| v.as_str()) == Some("error") { + let _ = ipc::cancel_session(&mut sock); + response = ipc::create_session(&mut sock, username).map_err(|e| e.to_string())?; + if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(LoginResult::Cancelled); + } + if response.get("type").and_then(|v| v.as_str()) == Some("error") { + return Ok(LoginResult::Error { + message: response + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Authentication failed") + .to_string(), + }); + } + } + + // Step 2: Send password if auth message received + if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") { + response = + ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?; + + if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(LoginResult::Cancelled); + } + + if response.get("type").and_then(|v| v.as_str()) == Some("error") { + let _ = ipc::cancel_session(&mut sock); + return Ok(LoginResult::AuthError { + response, + username: username.to_string(), + }); + } + + if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") { + // Multi-stage auth is not supported + let _ = ipc::cancel_session(&mut sock); + return Ok(LoginResult::Error { + message: "Multi-stage authentication is not supported".to_string(), + }); + } + } + + // Step 3: Start session + if response.get("type").and_then(|v| v.as_str()) == Some("success") { + let cmd = match split_shell_words(exec_cmd) { + Some(words) if !words.is_empty() => words, + _ => { + let _ = ipc::cancel_session(&mut sock); + return Ok(LoginResult::Error { + message: "Invalid session command".to_string(), + }); + } + }; + + // Validate: first token must be an absolute path to an existing file + let binary = std::path::Path::new(&cmd[0]); + if !binary.is_absolute() || !binary.is_file() { + let _ = ipc::cancel_session(&mut sock); + return Ok(LoginResult::Error { + message: "Invalid session command".to_string(), + }); + } + + response = + ipc::start_session(&mut sock, &cmd).map_err(|e| e.to_string())?; + + if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(LoginResult::Cancelled); + } + + if response.get("type").and_then(|v| v.as_str()) == Some("success") { + return Ok(LoginResult::Success { + username: username.to_string(), + }); + } else { + return Ok(LoginResult::Error { + message: response + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Failed to start session") + .to_string(), + }); + } + } + + Ok(LoginResult::Error { + message: "Unexpected response from greetd".to_string(), + }) +} + +/// Execute a power action in a background thread. +fn execute_power_action( + action_fn: fn() -> Result<(), PowerError>, + error_message: &'static str, + error_label: >k::Label, +) { + glib::spawn_future_local(clone!( + #[weak] + error_label, + async move { + let result = gio::spawn_blocking(move || action_fn()).await; + + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + log::error!("Power action failed: {e}"); + error_label.set_text(error_message); + error_label.set_visible(true); + } + Err(_) => { + log::error!("Power action panicked"); + error_label.set_text(error_message); + error_label.set_visible(true); + } + } + } + )); +} + +// -- Last user/session persistence -- + +fn load_last_user() -> Option { + let content = std::fs::read_to_string(LAST_USER_PATH).ok()?; + let username = content.trim(); + if is_valid_username(username) { + Some(username.to_string()) + } else { + None + } +} + +fn save_last_user(username: &str) { + let path = Path::new(LAST_USER_PATH); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, username); +} + +fn load_last_session(username: &str) -> Option { + let path = Path::new(LAST_SESSION_DIR).join(username); + let content = std::fs::read_to_string(path).ok()?; + let name = content.trim(); + if is_valid_session_name(name) { + Some(name.to_string()) + } else { + None + } +} + +/// Validate a session name — printable ASCII, no path separators or null bytes. +fn is_valid_session_name(name: &str) -> bool { + if name.is_empty() || name.len() > MAX_SESSION_NAME_LENGTH { + return false; + } + // Reject path traversal characters and control chars + name.chars().all(|c| c >= ' ' && c != '/' && c != '\\' && c != '\0') + && !name.contains("..") +} + +fn save_last_session(username: &str, session_name: &str) { + if !is_valid_username(username) { + return; + } + if !is_valid_session_name(session_name) { + return; + } + let dir = Path::new(LAST_SESSION_DIR); + let _ = std::fs::create_dir_all(dir); + let _ = std::fs::write(dir.join(username), session_name); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_usernames() { + assert!(is_valid_username("testuser")); + assert!(is_valid_username("test_user")); + assert!(is_valid_username("test-user")); + assert!(is_valid_username("test.user")); + assert!(is_valid_username("_admin")); + } + + #[test] + fn invalid_usernames() { + assert!(!is_valid_username("")); + assert!(!is_valid_username(".hidden")); + assert!(!is_valid_username("-dash")); + assert!(!is_valid_username("user/name")); + assert!(!is_valid_username(&"a".repeat(MAX_USERNAME_LENGTH + 1))); + } + + #[test] + fn valid_session_names() { + assert!(is_valid_session_name("Niri")); + assert!(is_valid_session_name("Hyprland")); + assert!(is_valid_session_name("sway (wayland)")); + assert!(is_valid_session_name("i3 - X11")); + } + + #[test] + fn invalid_session_names() { + assert!(!is_valid_session_name("")); + assert!(!is_valid_session_name("../../../etc/evil")); + assert!(!is_valid_session_name("name/with/slash")); + assert!(!is_valid_session_name("name\0null")); + assert!(!is_valid_session_name("name\\backslash")); + assert!(!is_valid_session_name(&"a".repeat(MAX_SESSION_NAME_LENGTH + 1))); + } + + #[test] + fn last_user_persistence() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("last-user"); + std::fs::write(&path, "alice").unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + let username = content.trim(); + assert!(is_valid_username(username)); + assert_eq!(username, "alice"); + } + + #[test] + fn last_session_persistence() { + let dir = tempfile::tempdir().unwrap(); + let session_dir = dir.path().join("last-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + std::fs::write(session_dir.join("alice"), "Niri").unwrap(); + + let content = std::fs::read_to_string(session_dir.join("alice")).unwrap(); + assert_eq!(content.trim(), "Niri"); + } + + // -- split_shell_words tests -- + + #[test] + fn shell_words_simple() { + assert_eq!( + split_shell_words("niri-session"), + Some(vec!["niri-session".to_string()]) + ); + } + + #[test] + fn shell_words_with_args() { + assert_eq!( + split_shell_words("sway --config /etc/sway/config"), + Some(vec![ + "sway".to_string(), + "--config".to_string(), + "/etc/sway/config".to_string(), + ]) + ); + } + + #[test] + fn shell_words_double_quotes() { + assert_eq!( + split_shell_words(r#"niri --config "/path with spaces/config""#), + Some(vec![ + "niri".to_string(), + "--config".to_string(), + "/path with spaces/config".to_string(), + ]) + ); + } + + #[test] + fn shell_words_single_quotes() { + assert_eq!( + split_shell_words("bash -c 'echo hello world'"), + Some(vec![ + "bash".to_string(), + "-c".to_string(), + "echo hello world".to_string(), + ]) + ); + } + + #[test] + fn shell_words_unbalanced_quotes() { + assert!(split_shell_words("niri --config \"unclosed").is_none()); + assert!(split_shell_words("bash -c 'unclosed").is_none()); + } + + #[test] + fn shell_words_empty() { + assert_eq!(split_shell_words(""), Some(vec![])); + assert_eq!(split_shell_words(" "), Some(vec![])); + } + + #[test] + fn shell_words_backslash_escape() { + assert_eq!( + split_shell_words(r"path\ with\ spaces"), + Some(vec!["path with spaces".to_string()]) + ); + } + + // -- login_worker tests -- + // These use a real Unix socket pair via UnixListener to simulate greetd. + + use std::os::unix::net::UnixListener; + use crate::ipc; + + /// Helper: spawn a fake greetd server that responds to messages. + fn fake_greetd(handler: F) -> (String, std::thread::JoinHandle<()>) + where + F: FnOnce(&mut UnixStream) + Send + 'static, + { + let dir = tempfile::tempdir().unwrap(); + let sock_path = dir.path().join("greetd.sock"); + let sock_path_str = sock_path.to_str().unwrap().to_string(); + let listener = UnixListener::bind(&sock_path).unwrap(); + + let handle = std::thread::spawn(move || { + let (mut client, _) = listener.accept().unwrap(); + handler(&mut client); + // Keep dir alive until handler finishes + drop(dir); + }); + + (sock_path_str, handle) + } + + fn default_greetd_sock() -> Arc>> { + Arc::new(Mutex::new(None)) + } + + fn default_cancelled() -> Arc { + Arc::new(std::sync::atomic::AtomicBool::new(false)) + } + + #[test] + fn login_worker_auth_error() { + let (sock_path, handle) = fake_greetd(|stream| { + // create_session request + let _msg = ipc::recv_message(stream).unwrap(); + // Respond with auth_message + ipc::send_message(stream, &serde_json::json!({ + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password: ", + })).unwrap(); + + // post_auth_response + let _msg = ipc::recv_message(stream).unwrap(); + // Respond with error (wrong password) + ipc::send_message(stream, &serde_json::json!({ + "type": "error", + "description": "Authentication failure", + })).unwrap(); + + // cancel_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + }); + + let result = login_worker( + "alice", "wrongpass", "/usr/bin/niri", + &sock_path, &default_greetd_sock(), &default_cancelled(), + ); + + let result = result.unwrap(); + assert!(matches!(result, LoginResult::AuthError { .. })); + handle.join().unwrap(); + } + + #[test] + fn login_worker_stale_session_retry() { + let (sock_path, handle) = fake_greetd(|stream| { + // First create_session fails (stale session) + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "error", + "description": "session already in progress", + })).unwrap(); + + // cancel_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + + // Retry create_session succeeds + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password: ", + })).unwrap(); + + // post_auth_response + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + + // start_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + }); + + let result = login_worker( + "alice", "correct", "/usr/bin/bash", + &sock_path, &default_greetd_sock(), &default_cancelled(), + ); + + let result = result.unwrap(); + assert!(matches!(result, LoginResult::Success { .. })); + handle.join().unwrap(); + } + + #[test] + fn login_worker_multi_stage_rejected() { + let (sock_path, handle) = fake_greetd(|stream| { + // create_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password: ", + })).unwrap(); + + // post_auth_response → another auth_message (TOTP) + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "auth_message", + "auth_message_type": "visible", + "auth_message": "TOTP: ", + })).unwrap(); + + // cancel_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + }); + + let result = login_worker( + "alice", "pass", "/usr/bin/niri", + &sock_path, &default_greetd_sock(), &default_cancelled(), + ); + + let result = result.unwrap(); + assert!(matches!(result, LoginResult::Error { .. })); + if let LoginResult::Error { message } = result { + assert!(message.contains("Multi-stage")); + } + handle.join().unwrap(); + } + + #[test] + fn login_worker_start_session_failure() { + let (sock_path, handle) = fake_greetd(|stream| { + // create_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password: ", + })).unwrap(); + + // post_auth_response → success + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + + // start_session → error + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "error", + "description": "session start failed", + })).unwrap(); + }); + + let result = login_worker( + "alice", "pass", "/usr/bin/bash", + &sock_path, &default_greetd_sock(), &default_cancelled(), + ); + + let result = result.unwrap(); + assert!(matches!(result, LoginResult::Error { .. })); + handle.join().unwrap(); + } + + #[test] + fn login_worker_cancelled_before_start() { + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(true)); + // No server needed — should return Cancelled immediately + let result = login_worker( + "alice", "pass", "/usr/bin/niri", + "/nonexistent/sock", &default_greetd_sock(), &cancelled, + ); + + let result = result.unwrap(); + assert!(matches!(result, LoginResult::Cancelled)); + } + + #[test] + fn login_worker_invalid_exec_cmd() { + let (sock_path, handle) = fake_greetd(|stream| { + // create_session + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({ + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password: ", + })).unwrap(); + + // post_auth_response → success + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + + // cancel_session (from invalid exec_cmd path) + let _msg = ipc::recv_message(stream).unwrap(); + ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); + }); + + // Non-absolute exec_cmd + let result = login_worker( + "alice", "pass", "relative-binary", + &sock_path, &default_greetd_sock(), &default_cancelled(), + ); + + let result = result.unwrap(); + assert!(matches!(result, LoginResult::Error { .. })); + handle.join().unwrap(); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..73a0c9f --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,333 @@ +// ABOUTME: Locale detection and string lookup for the greeter UI. +// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. + +use std::env; +use std::fs; +use std::path::Path; + +const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf"; + +/// All user-visible strings for the greeter UI. +#[derive(Debug, Clone)] +pub struct Strings { + // UI labels + pub password_placeholder: &'static str, + pub reboot_tooltip: &'static str, + pub shutdown_tooltip: &'static str, + + // Error messages + 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 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, + + // Templates (use .replace("{n}", &count.to_string())) + pub faillock_attempts_remaining: &'static str, + pub faillock_locked: &'static str, +} + +const STRINGS_DE: Strings = Strings { + password_placeholder: "Passwort", + reboot_tooltip: "Neustart", + shutdown_tooltip: "Herunterfahren", + 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", + 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", + faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!", + faillock_locked: "Konto ist möglicherweise gesperrt", +}; + +const STRINGS_EN: Strings = Strings { + password_placeholder: "Password", + reboot_tooltip: "Reboot", + shutdown_tooltip: "Shut down", + 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", + 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", + faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!", + faillock_locked: "Account may be locked", +}; + +/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de". +/// Returns "en" for empty, "C", or "POSIX" values. +fn parse_lang_prefix(lang: &str) -> String { + if lang.is_empty() || lang == "C" || lang == "POSIX" { + return "en".to_string(); + } + + let prefix = lang + .split('_') + .next() + .unwrap_or(lang) + .split('.') + .next() + .unwrap_or(lang) + .to_lowercase(); + + if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() { + prefix + } else { + "en".to_string() + } +} + +/// Read the LANG= value from a locale.conf file. +fn read_lang_from_conf(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + for line in content.lines() { + if let Some(value) = line.strip_prefix("LANG=") { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +/// Determine the system language from LANG env var or /etc/locale.conf. +pub fn detect_locale() -> String { + let lang = env::var("LANG") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); + + match lang { + Some(l) => parse_lang_prefix(&l), + None => "en".to_string(), + } +} + +/// 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(), + }; + + match locale.as_str() { + "de" => &STRINGS_DE, + _ => &STRINGS_EN, + } +} + +/// Format a faillock warning for the given attempt count. +/// Returns None if no warning is needed yet. +pub fn faillock_warning(attempt_count: u32, strings: &Strings) -> Option { + const FAILLOCK_MAX_ATTEMPTS: u32 = 3; + + if attempt_count >= FAILLOCK_MAX_ATTEMPTS { + return Some(strings.faillock_locked.to_string()); + } + + let remaining = FAILLOCK_MAX_ATTEMPTS - attempt_count; + if remaining == 1 { + return Some( + strings + .faillock_attempts_remaining + .replace("{n}", &remaining.to_string()), + ); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // -- parse_lang_prefix tests -- + + #[test] + fn parse_german_locale() { + assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); + } + + #[test] + fn parse_english_locale() { + assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); + } + + #[test] + fn parse_c_falls_back_to_english() { + assert_eq!(parse_lang_prefix("C"), "en"); + } + + #[test] + fn parse_posix_falls_back_to_english() { + assert_eq!(parse_lang_prefix("POSIX"), "en"); + } + + #[test] + fn parse_empty_falls_back_to_english() { + assert_eq!(parse_lang_prefix(""), "en"); + } + + #[test] + fn parse_unsupported_returns_prefix() { + assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr"); + } + + #[test] + fn parse_bare_language_code() { + assert_eq!(parse_lang_prefix("de"), "de"); + } + + // -- read_lang_from_conf tests -- + + #[test] + fn read_conf_extracts_lang() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LANG=de_DE.UTF-8").unwrap(); + assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string())); + } + + #[test] + fn read_conf_returns_none_for_missing_file() { + assert_eq!( + read_lang_from_conf(Path::new("/nonexistent/locale.conf")), + None + ); + } + + #[test] + fn read_conf_returns_none_for_empty_lang() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LANG=").unwrap(); + assert_eq!(read_lang_from_conf(&conf), None); + } + + #[test] + fn read_conf_skips_non_lang_lines() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LC_ALL=en_US.UTF-8").unwrap(); + writeln!(f, "LANG=de_DE.UTF-8").unwrap(); + assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string())); + } + + // -- load_strings tests -- + + #[test] + fn load_strings_german() { + let strings = load_strings(Some("de")); + assert_eq!(strings.password_placeholder, "Passwort"); + assert_eq!(strings.reboot_tooltip, "Neustart"); + } + + #[test] + fn load_strings_english() { + let strings = load_strings(Some("en")); + assert_eq!(strings.password_placeholder, "Password"); + assert_eq!(strings.reboot_tooltip, "Reboot"); + } + + #[test] + fn load_strings_unknown_falls_back_to_english() { + let strings = load_strings(Some("fr")); + assert_eq!(strings.password_placeholder, "Password"); + } + + #[test] + fn all_string_fields_nonempty() { + for locale in &["de", "en"] { + let s = load_strings(Some(locale)); + assert!(!s.password_placeholder.is_empty(), "{locale}: password_placeholder"); + assert!(!s.reboot_tooltip.is_empty(), "{locale}: reboot_tooltip"); + assert!(!s.shutdown_tooltip.is_empty(), "{locale}: shutdown_tooltip"); + assert!(!s.no_session_selected.is_empty(), "{locale}: no_session_selected"); + assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set"); + assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed"); + assert!(!s.wrong_password.is_empty(), "{locale}: wrong_password"); + 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"); + } + } + + // -- faillock_warning tests -- + + #[test] + fn faillock_no_warning_at_zero_attempts() { + let s = load_strings(Some("en")); + assert!(faillock_warning(0, s).is_none()); + } + + #[test] + fn faillock_no_warning_at_first_attempt() { + let s = load_strings(Some("en")); + assert!(faillock_warning(1, s).is_none()); + } + + #[test] + fn faillock_warning_at_second_attempt() { + let s = load_strings(Some("en")); + let warning = faillock_warning(2, s); + assert!(warning.is_some()); + assert!(warning.unwrap().contains("1")); + } + + #[test] + fn faillock_locked_at_third_attempt() { + let s = load_strings(Some("en")); + let warning = faillock_warning(3, s); + assert!(warning.is_some()); + assert_eq!(warning.unwrap(), "Account may be locked"); + } + + #[test] + fn faillock_locked_beyond_max() { + let s = load_strings(Some("en")); + let warning = faillock_warning(5, s); + assert!(warning.is_some()); + assert_eq!(warning.unwrap(), "Account may be locked"); + } + + #[test] + fn faillock_german_strings() { + let s = load_strings(Some("de")); + let warning = faillock_warning(2, s).unwrap(); + assert!(warning.contains("Kontosperrung")); + let locked = faillock_warning(3, s).unwrap(); + assert!(locked.contains("gesperrt")); + } +} diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..2377859 --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,294 @@ +// ABOUTME: greetd IPC protocol implementation — communicates via Unix socket. +// ABOUTME: Uses length-prefixed JSON encoding as specified by the greetd IPC protocol. + +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; + +const MAX_PAYLOAD_SIZE: usize = 65536; + +/// Errors from greetd IPC communication. +#[derive(Debug)] +pub enum IpcError { + Io(io::Error), + PayloadTooLarge(usize), + Json(serde_json::Error), + ConnectionClosed, +} + +impl std::fmt::Display for IpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IpcError::Io(e) => write!(f, "IPC I/O error: {e}"), + IpcError::PayloadTooLarge(size) => { + write!(f, "Payload too large: {size} bytes (max {MAX_PAYLOAD_SIZE})") + } + IpcError::Json(e) => write!(f, "IPC JSON error: {e}"), + IpcError::ConnectionClosed => write!(f, "Connection closed while reading data"), + } + } +} + +impl std::error::Error for IpcError {} + +impl From for IpcError { + fn from(e: io::Error) -> Self { + IpcError::Io(e) + } +} + +impl From for IpcError { + fn from(e: serde_json::Error) -> Self { + IpcError::Json(e) + } +} + +/// Read exactly 4 bytes (length header) from the stream into a stack array. +fn recv_header(stream: &mut UnixStream) -> Result<[u8; 4], IpcError> { + let mut buf = [0u8; 4]; + let mut filled = 0; + + while filled < 4 { + let bytes_read = stream.read(&mut buf[filled..])?; + if bytes_read == 0 { + return Err(IpcError::ConnectionClosed); + } + filled += bytes_read; + } + + Ok(buf) +} + +/// Receive exactly n bytes from the stream, looping on partial reads. +fn recv_payload(stream: &mut UnixStream, n: usize) -> Result, IpcError> { + let mut buf = vec![0u8; n]; + let mut filled = 0; + + while filled < n { + let bytes_read = stream.read(&mut buf[filled..])?; + if bytes_read == 0 { + return Err(IpcError::ConnectionClosed); + } + filled += bytes_read; + } + + Ok(buf) +} + +/// 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, +) -> Result<(), IpcError> { + let payload = serde_json::to_vec(msg)?; + if payload.len() > MAX_PAYLOAD_SIZE { + return Err(IpcError::PayloadTooLarge(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)?; + Ok(()) +} + +/// Receive a length-prefixed JSON message from the greetd socket. +pub fn recv_message(stream: &mut UnixStream) -> Result { + let header = recv_header(stream)?; + let length = u32::from_le_bytes(header) as usize; + + if length > MAX_PAYLOAD_SIZE { + return Err(IpcError::PayloadTooLarge(length)); + } + + let payload = recv_payload(stream, length)?; + let value: serde_json::Value = serde_json::from_slice(&payload)?; + Ok(value) +} + +/// Send a create_session request to greetd and return the response. +pub fn create_session( + stream: &mut UnixStream, + username: &str, +) -> Result { + let msg = serde_json::json!({ + "type": "create_session", + "username": username, + }); + send_message(stream, &msg)?; + recv_message(stream) +} + +/// Send an authentication response (e.g. password) to greetd. +pub fn post_auth_response( + stream: &mut UnixStream, + response: Option<&str>, +) -> Result { + let msg = serde_json::json!({ + "type": "post_auth_message_response", + "response": response, + }); + send_message(stream, &msg)?; + recv_message(stream) +} + +/// Send a start_session request to launch the user's session. +pub fn start_session( + stream: &mut UnixStream, + cmd: &[String], +) -> Result { + let msg = serde_json::json!({ + "type": "start_session", + "cmd": cmd, + }); + send_message(stream, &msg)?; + recv_message(stream) +} + +/// Cancel the current authentication session. +pub fn cancel_session(stream: &mut UnixStream) -> Result { + let msg = serde_json::json!({"type": "cancel_session"}); + send_message(stream, &msg)?; + recv_message(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixStream; + + /// Create a connected pair of Unix sockets for testing. + fn socket_pair() -> (UnixStream, UnixStream) { + UnixStream::pair().unwrap() + } + + #[test] + fn send_and_receive_message() { + let (mut client, mut server) = socket_pair(); + + let msg = serde_json::json!({"type": "create_session", "username": "test"}); + send_message(&mut client, &msg).unwrap(); + + let received = recv_message(&mut server).unwrap(); + assert_eq!(received["type"], "create_session"); + assert_eq!(received["username"], "test"); + } + + #[test] + fn create_session_roundtrip() { + let (mut client, mut server) = socket_pair(); + + // Simulate greetd response in a thread + let handle = std::thread::spawn(move || { + let msg = recv_message(&mut server).unwrap(); + assert_eq!(msg["type"], "create_session"); + assert_eq!(msg["username"], "alice"); + + let response = serde_json::json!({ + "type": "auth_message", + "auth_message_type": "visible", + "auth_message": "Password: ", + }); + send_message(&mut server, &response).unwrap(); + }); + + let response = create_session(&mut client, "alice").unwrap(); + assert_eq!(response["type"], "auth_message"); + handle.join().unwrap(); + } + + #[test] + fn post_auth_response_roundtrip() { + let (mut client, mut server) = socket_pair(); + + let handle = std::thread::spawn(move || { + let msg = recv_message(&mut server).unwrap(); + assert_eq!(msg["type"], "post_auth_message_response"); + assert_eq!(msg["response"], "secret123"); + + let response = serde_json::json!({"type": "success"}); + send_message(&mut server, &response).unwrap(); + }); + + let response = post_auth_response(&mut client, Some("secret123")).unwrap(); + assert_eq!(response["type"], "success"); + handle.join().unwrap(); + } + + #[test] + fn start_session_roundtrip() { + let (mut client, mut server) = socket_pair(); + + let handle = std::thread::spawn(move || { + let msg = recv_message(&mut server).unwrap(); + assert_eq!(msg["type"], "start_session"); + assert_eq!(msg["cmd"], serde_json::json!(["niri-session"])); + + let response = serde_json::json!({"type": "success"}); + send_message(&mut server, &response).unwrap(); + }); + + let cmd = vec!["niri-session".to_string()]; + let response = start_session(&mut client, &cmd).unwrap(); + assert_eq!(response["type"], "success"); + handle.join().unwrap(); + } + + #[test] + fn cancel_session_roundtrip() { + let (mut client, mut server) = socket_pair(); + + let handle = std::thread::spawn(move || { + let msg = recv_message(&mut server).unwrap(); + assert_eq!(msg["type"], "cancel_session"); + + let response = serde_json::json!({"type": "success"}); + send_message(&mut server, &response).unwrap(); + }); + + let response = cancel_session(&mut client).unwrap(); + assert_eq!(response["type"], "success"); + handle.join().unwrap(); + } + + #[test] + fn connection_closed_returns_error() { + let (mut client, server) = socket_pair(); + drop(server); + + let result = recv_message(&mut client); + assert!(result.is_err()); + } + + #[test] + fn oversized_payload_rejected_on_send() { + let (mut client, _server) = socket_pair(); + + let big_string = "x".repeat(MAX_PAYLOAD_SIZE + 1); + let msg = serde_json::json!({"data": big_string}); + let result = send_message(&mut client, &msg); + assert!(result.is_err()); + } + + #[test] + fn oversized_payload_rejected_on_receive() { + let (mut client, mut server) = socket_pair(); + + // Manually send a header claiming a huge payload + let fake_length: u32 = (MAX_PAYLOAD_SIZE as u32) + 1; + server.write_all(&fake_length.to_le_bytes()).unwrap(); + + let result = recv_message(&mut client); + assert!(matches!(result, Err(IpcError::PayloadTooLarge(_)))); + } + + #[test] + fn ipc_error_display() { + let err = IpcError::ConnectionClosed; + assert_eq!(err.to_string(), "Connection closed while reading data"); + + let err = IpcError::PayloadTooLarge(99999); + assert!(err.to_string().contains("99999")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2890e97 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,110 @@ +// ABOUTME: Entry point for Moongreet — greetd greeter for Wayland. +// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows. + +mod config; +mod greeter; +mod i18n; +mod ipc; +mod power; +mod sessions; +mod users; + +use gdk4 as gdk; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use gtk4_layer_shell::LayerShell; +use std::path::PathBuf; + +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, + ); +} + +fn setup_layer_shell(window: >k::ApplicationWindow, keyboard: bool) { + window.init_layer_shell(); + window.set_layer(gtk4_layer_shell::Layer::Top); + window.set_exclusive_zone(-1); + if keyboard { + window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive); + } + // Anchor to all edges for fullscreen + window.set_anchor(gtk4_layer_shell::Edge::Top, true); + window.set_anchor(gtk4_layer_shell::Edge::Bottom, true); + window.set_anchor(gtk4_layer_shell::Edge::Left, true); + window.set_anchor(gtk4_layer_shell::Edge::Right, true); +} + +fn activate(app: >k::Application) { + let display = match gdk::Display::default() { + Some(d) => d, + None => { + log::error!("No display available — cannot start greeter UI"); + return; + } + }; + + load_css(&display); + + // Load config and resolve wallpaper + let config = config::load_config(None); + let bg_path = config::resolve_background_path(&config); + + // Main greeter window (login UI) — compositor picks focused monitor + let greeter_window = greeter::create_greeter_window(&bg_path, &config, app); + setup_layer_shell(&greeter_window, true); + greeter_window.present(); + + // Wallpaper-only windows on all monitors + let monitors = display.monitors(); + for i in 0..monitors.n_items() { + if let Some(monitor) = monitors + .item(i) + .and_then(|obj| obj.downcast::().ok()) + { + let wallpaper = greeter::create_wallpaper_window(&bg_path, app); + setup_layer_shell(&wallpaper, false); + wallpaper.set_monitor(Some(&monitor)); + wallpaper.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))); + } + } + + builder.init(); +} + +fn main() { + setup_logging(); + log::info!("Moongreet starting"); + + // Register compiled GResources + gio::resources_register_include!("moongreet.gresource").expect("Failed to register resources"); + + let app = gtk::Application::builder() + .application_id("dev.moonarch.moongreet") + .build(); + + app.connect_activate(activate); + app.run(); +} diff --git a/src/moongreet/__init__.py b/src/moongreet/__init__.py deleted file mode 100644 index 61840b5..0000000 --- a/src/moongreet/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4. -# ABOUTME: Part of the Moonarch ecosystem. diff --git a/src/moongreet/config.py b/src/moongreet/config.py deleted file mode 100644 index 6f9f135..0000000 --- a/src/moongreet/config.py +++ /dev/null @@ -1,84 +0,0 @@ -# ABOUTME: Configuration loading from moongreet.toml. -# ABOUTME: Parses appearance and behavior settings with wallpaper path resolution. - -import tomllib -from contextlib import AbstractContextManager -from dataclasses import dataclass -from importlib.resources import as_file, files -from pathlib import Path - -DEFAULT_CONFIG_PATHS = [ - Path("/etc/moongreet/moongreet.toml"), -] - - -@dataclass -class Config: - """Greeter configuration loaded from moongreet.toml.""" - - background: Path | None = None - gtk_theme: str | None = None - - -def load_config(config_path: Path | None = None) -> Config: - """Load configuration from a TOML file. - - Relative paths in the config are resolved against the config file's directory. - """ - if config_path is None: - for path in DEFAULT_CONFIG_PATHS: - if path.exists(): - config_path = path - break - if config_path is None: - return Config() - - if not config_path.exists(): - return Config() - - try: - with open(config_path, "rb") as f: - data = tomllib.load(f) - except (tomllib.TOMLDecodeError, OSError): - return Config() - - config = Config() - appearance = data.get("appearance", {}) - - bg = appearance.get("background") - if bg: - bg_path = Path(bg) - if not bg_path.is_absolute(): - bg_path = config_path.parent / bg_path - config.background = bg_path - - gtk_theme = appearance.get("gtk-theme") - if gtk_theme: - config.gtk_theme = gtk_theme - - return config - - -_PACKAGE_DATA = files("moongreet") / "data" -_DEFAULT_WALLPAPER_PATH = _PACKAGE_DATA / "wallpaper.jpg" - - -def resolve_wallpaper_path( - config: Config, -) -> tuple[Path, AbstractContextManager | None]: - """Resolve the wallpaper path from config or fall back to the package default. - - Returns (path, context_manager). The context_manager is non-None when a - package resource was extracted to a temporary file — the caller must keep - it alive and call __exit__ when done. - """ - if config.background and config.background.exists(): - return config.background, None - - ctx = as_file(_DEFAULT_WALLPAPER_PATH) - try: - path = ctx.__enter__() - except Exception: - ctx.__exit__(None, None, None) - raise - return path, ctx diff --git a/src/moongreet/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg b/src/moongreet/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg deleted file mode 100644 index 9db9ddc..0000000 --- a/src/moongreet/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/moongreet/greeter.py b/src/moongreet/greeter.py deleted file mode 100644 index feb058b..0000000 --- a/src/moongreet/greeter.py +++ /dev/null @@ -1,664 +0,0 @@ -# ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter. -# ABOUTME: Handles user selection, session choice, password entry, and power actions. - -import logging -import os -import re -import shlex -import shutil -import socket -import stat -import subprocess -import threading -from importlib.resources import files -from pathlib import Path - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk, GLib, GdkPixbuf - -from moongreet.config import Config, load_config, resolve_wallpaper_path -from moongreet.i18n import load_strings, Strings -from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session -from moongreet.users import User, get_users, get_avatar_path -from moongreet.sessions import Session, get_sessions -from moongreet.power import reboot, shutdown - -logger = logging.getLogger(__name__) - -LAST_USER_PATH = Path("/var/cache/moongreet/last-user") -LAST_SESSION_DIR = Path("/var/cache/moongreet/last-session") -FAILLOCK_MAX_ATTEMPTS = 3 -VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$") -MAX_USERNAME_LENGTH = 256 -PACKAGE_DATA = files("moongreet") / "data" -DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg" -AVATAR_SIZE = 128 -MAX_AVATAR_FILE_SIZE = 10 * 1024 * 1024 # 10 MB - - -def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str | None: - """Return a warning if the user is approaching or has reached the faillock limit.""" - if strings is None: - strings = load_strings() - remaining = FAILLOCK_MAX_ATTEMPTS - attempt_count - if remaining <= 0: - return strings.faillock_locked - if remaining == 1: - return strings.faillock_attempts_remaining.format(n=remaining) - return None - - -def _build_wallpaper_widget(bg_path: Path | None) -> Gtk.Widget: - """Create a wallpaper widget that fills the available space.""" - if bg_path and bg_path.exists(): - background = Gtk.Picture() - background.set_filename(str(bg_path)) - background.set_content_fit(Gtk.ContentFit.COVER) - background.set_hexpand(True) - background.set_vexpand(True) - return background - background = Gtk.Box() - background.set_hexpand(True) - background.set_vexpand(True) - return background - - -class WallpaperWindow(Gtk.ApplicationWindow): - """A window that shows only the wallpaper — used for secondary monitors.""" - - def __init__(self, bg_path: Path | None = None, **kwargs) -> None: - super().__init__(**kwargs) - self.add_css_class("greeter") - self.set_default_size(1920, 1080) - self.set_child(_build_wallpaper_widget(bg_path)) - - -class GreeterWindow(Gtk.ApplicationWindow): - """The main greeter window with login UI.""" - - def __init__(self, bg_path: Path | None = None, config: Config | None = None, **kwargs) -> None: - super().__init__(**kwargs) - self.add_css_class("greeter") - self.set_default_size(1920, 1080) - - self._config = config if config is not None else load_config() - self._strings = load_strings() - self._users = get_users() - self._sessions = get_sessions() - self._selected_user: User | None = None - self._greetd_sock: socket.socket | None = None - self._greetd_sock_lock = threading.Lock() - self._login_cancelled = threading.Event() - self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None - self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {} - self._failed_attempts: dict[str, int] = {} - self._bg_path = bg_path - - self._apply_global_theme() - self._build_ui() - self._setup_keyboard_navigation() - # Defer initial user selection until the window is realized, - # so get_color() returns the actual theme foreground for SVG tinting - self.connect("realize", self._on_realize) - - def _on_realize(self, widget: Gtk.Widget) -> None: - """Called when the window is realized — select initial user. - - Deferred from __init__ so get_color() returns actual theme values - for SVG tinting. Uses idle_add so the first frame renders before - avatar loading blocks the main loop. - """ - GLib.idle_add(self._select_initial_user) - - def _build_ui(self) -> None: - """Build the complete greeter UI layout.""" - # Root overlay for layering - overlay = Gtk.Overlay() - self.set_child(overlay) - - # Background wallpaper - overlay.set_child(_build_wallpaper_widget(self._bg_path)) - - # Main layout: 3 rows (top spacer, center login, bottom bar) - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - main_box.set_hexpand(True) - main_box.set_vexpand(True) - overlay.add_overlay(main_box) - - # Top spacer - top_spacer = Gtk.Box() - top_spacer.set_vexpand(True) - main_box.append(top_spacer) - - # Center: login box - center_box = self._build_login_box() - center_box.set_halign(Gtk.Align.CENTER) - main_box.append(center_box) - - # Bottom spacer - bottom_spacer = Gtk.Box() - bottom_spacer.set_vexpand(True) - main_box.append(bottom_spacer) - - # Bottom bar overlay (user list left, power buttons right) - bottom_bar = self._build_bottom_bar() - bottom_bar.set_valign(Gtk.Align.END) - overlay.add_overlay(bottom_bar) - - def _build_login_box(self) -> Gtk.Box: - """Build the central login area with avatar, name, session, password.""" - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.add_css_class("login-box") - box.set_halign(Gtk.Align.CENTER) - box.set_valign(Gtk.Align.CENTER) - box.set_spacing(12) - - # Avatar — wrapped in a clipping frame for round shape - avatar_frame = Gtk.Box() - avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE) - avatar_frame.set_halign(Gtk.Align.CENTER) - avatar_frame.set_overflow(Gtk.Overflow.HIDDEN) - avatar_frame.add_css_class("avatar") - self._avatar_image = Gtk.Image() - self._avatar_image.set_pixel_size(AVATAR_SIZE) - avatar_frame.append(self._avatar_image) - box.append(avatar_frame) - - # Username label - self._username_label = Gtk.Label(label="") - self._username_label.add_css_class("username-label") - box.append(self._username_label) - - # Session dropdown - self._session_dropdown = Gtk.DropDown() - self._session_dropdown.add_css_class("session-dropdown") - self._session_dropdown.set_hexpand(True) - if self._sessions: - session_names = [s.name for s in self._sessions] - string_list = Gtk.StringList.new(session_names) - self._session_dropdown.set_model(string_list) - box.append(self._session_dropdown) - - # Password entry - self._password_entry = Gtk.PasswordEntry() - self._password_entry.set_hexpand(True) - self._password_entry.set_property("placeholder-text", self._strings.password_placeholder) - self._password_entry.set_property("show-peek-icon", True) - self._password_entry.add_css_class("password-entry") - self._password_entry.connect("activate", self._on_login_activate) - box.append(self._password_entry) - - # Error label (hidden by default) - self._error_label = Gtk.Label(label="") - self._error_label.add_css_class("error-label") - self._error_label.set_visible(False) - box.append(self._error_label) - - return box - - def _build_bottom_bar(self) -> Gtk.Box: - """Build the bottom bar with user list (left) and power buttons (right).""" - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - bar.set_hexpand(True) - bar.set_margin_start(16) - bar.set_margin_end(16) - bar.set_margin_bottom(16) - - # User list (left) - user_list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - user_list_box.add_css_class("user-list") - user_list_box.set_halign(Gtk.Align.START) - user_list_box.set_valign(Gtk.Align.END) - - for user in self._users: - btn = Gtk.Button(label=user.display_name) - btn.add_css_class("user-list-item") - btn.connect("clicked", self._on_user_clicked, user) - user_list_box.append(btn) - - bar.append(user_list_box) - - # Spacer - spacer = Gtk.Box() - spacer.set_hexpand(True) - bar.append(spacer) - - # Power buttons (right) - power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - power_box.set_halign(Gtk.Align.END) - power_box.set_valign(Gtk.Align.END) - power_box.set_spacing(8) - - reboot_btn = Gtk.Button() - reboot_btn.set_icon_name("system-reboot-symbolic") - reboot_btn.add_css_class("power-button") - reboot_btn.set_tooltip_text(self._strings.reboot_tooltip) - reboot_btn.connect("clicked", self._on_reboot_clicked) - power_box.append(reboot_btn) - - shutdown_btn = Gtk.Button() - shutdown_btn.set_icon_name("system-shutdown-symbolic") - shutdown_btn.add_css_class("power-button") - shutdown_btn.set_tooltip_text(self._strings.shutdown_tooltip) - shutdown_btn.connect("clicked", self._on_shutdown_clicked) - power_box.append(shutdown_btn) - - bar.append(power_box) - - return bar - - def _select_initial_user(self) -> bool: - """Select the last user or the first available user. - - Returns False to deregister from GLib.idle_add after a single invocation. - """ - if not self._users: - return False - - # Try to load last user - last_username = self._load_last_user() - target_user = None - - if last_username: - for user in self._users: - if user.username == last_username: - target_user = user - break - - if target_user is None: - target_user = self._users[0] - - self._switch_to_user(target_user) - return False - - def _switch_to_user(self, user: User) -> None: - """Update the UI to show the selected user.""" - self._selected_user = user - self._username_label.set_text(user.display_name) - self._password_entry.set_text("") - self._error_label.set_visible(False) - - # Update avatar (use cache if available) - if user.username in self._avatar_cache: - self._avatar_image.set_from_pixbuf(self._avatar_cache[user.username]) - else: - avatar_path = get_avatar_path( - user.username, home_dir=user.home - ) - if avatar_path and avatar_path.exists(): - self._set_avatar_from_file(avatar_path, user.username) - else: - # Default avatar — _set_default_avatar uses Traversable.read_text() - # which works in ZIP wheels too, no exists() check needed - self._set_default_avatar() - - # Pre-select last used session for this user - self._select_last_session(user) - - # Focus password entry - self._password_entry.grab_focus() - - def _apply_global_theme(self) -> None: - """Apply the GTK theme from moongreet.toml configuration.""" - theme_name = self._config.gtk_theme - if not theme_name: - return - - settings = Gtk.Settings.get_default() - if settings is None: - return - - settings.set_property("gtk-theme-name", theme_name) - - def _get_foreground_color(self) -> str: - """Get the current GTK theme foreground color as a hex string.""" - rgba = self.get_color() - r = int(rgba.red * 255) - g = int(rgba.green * 255) - b = int(rgba.blue * 255) - return f"#{r:02x}{g:02x}{b:02x}" - - def _set_default_avatar(self) -> None: - """Load the default avatar SVG, tinted with the GTK foreground color.""" - if self._default_avatar_pixbuf: - self._avatar_image.set_from_pixbuf(self._default_avatar_pixbuf) - return - try: - svg_text = DEFAULT_AVATAR_PATH.read_text() - fg_color = self._get_foreground_color() - svg_text = svg_text.replace("#PLACEHOLDER", fg_color) - svg_bytes = svg_text.encode("utf-8") - loader = GdkPixbuf.PixbufLoader.new_with_type("svg") - loader.set_size(AVATAR_SIZE, AVATAR_SIZE) - loader.write(svg_bytes) - loader.close() - pixbuf = loader.get_pixbuf() - if pixbuf: - self._default_avatar_pixbuf = pixbuf - self._avatar_image.set_from_pixbuf(pixbuf) - except (GLib.Error, OSError): - self._avatar_image.set_from_icon_name("avatar-default-symbolic") - - def _set_avatar_from_file(self, path: Path, username: str | None = None) -> None: - """Load an image file and set it as the avatar, scaled to AVATAR_SIZE.""" - try: - if path.stat().st_size > MAX_AVATAR_FILE_SIZE: - self._avatar_image.set_from_icon_name("avatar-default-symbolic") - return - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - str(path), AVATAR_SIZE, AVATAR_SIZE, True - ) - if username: - self._avatar_cache[username] = pixbuf - self._avatar_image.set_from_pixbuf(pixbuf) - except GLib.Error: - self._avatar_image.set_from_icon_name("avatar-default-symbolic") - - def _setup_keyboard_navigation(self) -> None: - """Set up keyboard shortcuts.""" - controller = Gtk.EventControllerKey() - controller.connect("key-pressed", self._on_key_pressed) - self.add_controller(controller) - - def _on_key_pressed( - self, - controller: Gtk.EventControllerKey, - keyval: int, - keycode: int, - state: Gdk.ModifierType, - ) -> bool: - """Handle global key presses.""" - if keyval == Gdk.KEY_Escape: - self._password_entry.set_text("") - self._error_label.set_visible(False) - return True - return False - - def _on_user_clicked(self, button: Gtk.Button, user: User) -> None: - """Handle user selection from the user list.""" - self._cancel_pending_session() - self._switch_to_user(user) - - def _on_login_activate(self, entry: Gtk.PasswordEntry) -> None: - """Handle Enter key in the password field — attempt login.""" - if not self._selected_user: - return - - password = entry.get_text() - session = self._get_selected_session() - if not session: - self._show_error(self._strings.no_session_selected) - return - - self._attempt_login(self._selected_user, password, session) - - def _validate_greetd_sock(self, sock_path: str) -> bool: - """Validate that GREETD_SOCK points to an absolute path and a real socket.""" - path = Path(sock_path) - if not path.is_absolute(): - self._show_error(self._strings.greetd_sock_not_absolute) - return False - try: - mode = path.stat().st_mode - if not stat.S_ISSOCK(mode): - self._show_error(self._strings.greetd_sock_not_socket) - return False - except OSError: - self._show_error(self._strings.greetd_sock_unreachable) - return False - return True - - def _close_greetd_sock(self) -> None: - """Close the greetd socket and reset the reference.""" - with self._greetd_sock_lock: - if self._greetd_sock: - try: - self._greetd_sock.close() - except OSError: - pass - self._greetd_sock = None - - def _set_login_sensitive(self, sensitive: bool) -> None: - """Enable or disable login controls during authentication.""" - self._password_entry.set_sensitive(sensitive) - self._session_dropdown.set_sensitive(sensitive) - - def _attempt_login(self, user: User, password: str, session: Session) -> None: - """Attempt to authenticate and start a session via greetd IPC.""" - sock_path = os.environ.get("GREETD_SOCK") - if not sock_path: - self._show_error(self._strings.greetd_sock_not_set) - return - - if not self._validate_greetd_sock(sock_path): - return - - # Disable UI while authenticating — the IPC runs in a background thread - self._login_cancelled.clear() - self._set_login_sensitive(False) - thread = threading.Thread( - target=self._login_worker, - args=(user, password, session, sock_path), - daemon=True, - ) - thread.start() - - def _login_worker(self, user: User, password: str, session: Session, sock_path: str) -> None: - """Run greetd IPC in a background thread to avoid blocking the GTK main loop.""" - try: - if self._login_cancelled.is_set(): - return - - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(10.0) - sock.connect(sock_path) - with self._greetd_sock_lock: - self._greetd_sock = sock - - # Step 1: Create session — if a stale session exists, cancel it and retry - response = create_session(sock, user.username) - - if self._login_cancelled.is_set(): - return - - if response.get("type") == "error": - cancel_session(sock) - response = create_session(sock, user.username) - if self._login_cancelled.is_set(): - return - if response.get("type") == "error": - GLib.idle_add(self._on_login_error, response, self._strings.auth_failed) - return - - # Step 2: Send password if auth message received - if response.get("type") == "auth_message": - response = post_auth_response(sock, password) - - if self._login_cancelled.is_set(): - return - - if response.get("type") == "error": - self._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1 - warning = faillock_warning(self._failed_attempts[user.username], self._strings) - cancel_session(sock) - GLib.idle_add(self._on_login_auth_error, response, warning) - return - - if response.get("type") == "auth_message": - # Multi-stage auth (e.g. TOTP) is not supported - cancel_session(sock) - GLib.idle_add(self._on_login_error, None, self._strings.multi_stage_unsupported) - return - - # Step 3: Start session - if response.get("type") == "success": - cmd = shlex.split(session.exec_cmd) - if not cmd or not shutil.which(cmd[0]): - cancel_session(sock) - GLib.idle_add(self._on_login_error, None, self._strings.invalid_session_command) - return - response = start_session(sock, cmd) - - if self._login_cancelled.is_set(): - return - - if response.get("type") == "success": - self._save_last_user(user.username) - self._save_last_session(user.username, session.name) - GLib.idle_add(self.get_application().quit) - return - else: - GLib.idle_add(self._on_login_error, response, self._strings.session_start_failed) - return - - except (ConnectionError, OSError, ValueError) as e: - if self._login_cancelled.is_set(): - # Socket was closed by _cancel_pending_session — exit silently - return - logger.error("greetd IPC error: %s", e) - if isinstance(e, ConnectionError): - GLib.idle_add(self._on_login_error, None, self._strings.connection_error) - else: - GLib.idle_add(self._on_login_error, None, self._strings.socket_error) - finally: - self._close_greetd_sock() - - def _on_login_error(self, response: dict | None, message: str) -> None: - """Handle login error on the GTK main thread.""" - if response: - self._show_greetd_error(response, message) - else: - self._show_error(message) - self._set_login_sensitive(True) - - def _on_login_auth_error(self, response: dict, warning: str | None) -> None: - """Handle authentication failure with optional faillock warning on the GTK main thread.""" - self._show_greetd_error(response, self._strings.wrong_password) - if warning: - current = self._error_label.get_text() - self._error_label.set_text(f"{current}\n{warning}") - self._set_login_sensitive(True) - - def _cancel_pending_session(self) -> None: - """Cancel any in-progress greetd session. - - Sets the cancellation event and closes the socket to interrupt - any blocking I/O in the login worker. The worker checks the - event and exits silently instead of showing an error. - """ - self._login_cancelled.set() - self._close_greetd_sock() - - def _get_selected_session(self) -> Session | None: - """Get the currently selected session from the dropdown.""" - if not self._sessions: - return None - idx = self._session_dropdown.get_selected() - if idx < len(self._sessions): - return self._sessions[idx] - return None - - def _select_last_session(self, user: User) -> None: - """Pre-select the last used session for a user in the dropdown.""" - if not self._sessions: - return - last_session_name = self._load_last_session(user.username) - if not last_session_name: - return - for i, session in enumerate(self._sessions): - if session.name == last_session_name: - self._session_dropdown.set_selected(i) - return - - MAX_GREETD_ERROR_LENGTH = 200 - - def _show_greetd_error(self, response: dict, fallback: str) -> None: - """Display an error from greetd, using a fallback for missing or oversized descriptions.""" - description = response.get("description", "") - if description and len(description) <= self.MAX_GREETD_ERROR_LENGTH: - self._show_error(description) - else: - self._show_error(fallback) - - def _show_error(self, message: str) -> None: - """Display an error message below the password field.""" - self._error_label.set_text(message) - self._error_label.set_visible(True) - self._password_entry.set_text("") - self._password_entry.grab_focus() - - def _on_reboot_clicked(self, button: Gtk.Button) -> None: - """Handle reboot button click.""" - button.set_sensitive(False) - threading.Thread( - target=self._power_worker, args=(reboot, self._strings.reboot_failed), - daemon=True, - ).start() - - def _on_shutdown_clicked(self, button: Gtk.Button) -> None: - """Handle shutdown button click.""" - button.set_sensitive(False) - threading.Thread( - target=self._power_worker, args=(shutdown, self._strings.shutdown_failed), - daemon=True, - ).start() - - def _power_worker(self, action, error_msg: str) -> None: - """Run a power action in a background thread to avoid blocking the GTK main loop.""" - try: - action() - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - GLib.idle_add(self._show_error, error_msg) - - @staticmethod - def _load_last_user() -> str | None: - """Load the last logged-in username from cache.""" - if LAST_USER_PATH.exists(): - try: - username = LAST_USER_PATH.read_text().strip() - except OSError: - return None - if len(username) > MAX_USERNAME_LENGTH or not VALID_USERNAME.match(username): - return None - return username - return None - - @staticmethod - def _save_last_user(username: str) -> None: - """Save the last logged-in username to cache.""" - try: - LAST_USER_PATH.parent.mkdir(parents=True, exist_ok=True) - LAST_USER_PATH.write_text(username) - except OSError: - pass # Non-critical — cache dir may not be writable - - MAX_SESSION_NAME_LENGTH = 256 - - @staticmethod - def _save_last_session(username: str, session_name: str) -> None: - """Save the last used session name for a user to cache.""" - if not VALID_USERNAME.match(username) or len(username) > MAX_USERNAME_LENGTH: - return - if not session_name or len(session_name) > GreeterWindow.MAX_SESSION_NAME_LENGTH: - return - try: - LAST_SESSION_DIR.mkdir(parents=True, exist_ok=True) - (LAST_SESSION_DIR / username).write_text(session_name) - except OSError: - pass # Non-critical — cache dir may not be writable - - @staticmethod - def _load_last_session(username: str) -> str | None: - """Load the last used session name for a user from cache.""" - session_file = LAST_SESSION_DIR / username - if not session_file.exists(): - return None - try: - name = session_file.read_text().strip() - except OSError: - return None - if not name or len(name) > GreeterWindow.MAX_SESSION_NAME_LENGTH: - return None - return name diff --git a/src/moongreet/i18n.py b/src/moongreet/i18n.py deleted file mode 100644 index f445a92..0000000 --- a/src/moongreet/i18n.py +++ /dev/null @@ -1,117 +0,0 @@ -# ABOUTME: Locale detection and string lookup for the greeter UI. -# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. - -import os -from dataclasses import dataclass -from pathlib import Path - -DEFAULT_LOCALE_CONF = Path("/etc/locale.conf") - - -@dataclass(frozen=True) -class Strings: - """All user-visible strings for the greeter UI.""" - - # UI labels - password_placeholder: str - reboot_tooltip: str - shutdown_tooltip: str - - # Error messages - no_session_selected: str - greetd_sock_not_set: str - greetd_sock_not_absolute: str - greetd_sock_not_socket: str - greetd_sock_unreachable: str - auth_failed: str - wrong_password: str - multi_stage_unsupported: str - invalid_session_command: str - session_start_failed: str - reboot_failed: str - shutdown_failed: str - - # Error messages (continued) - connection_error: str - socket_error: str - - # Templates (use .format()) - faillock_attempts_remaining: str - faillock_locked: str - - -_STRINGS_DE = Strings( - password_placeholder="Passwort", - reboot_tooltip="Neustart", - shutdown_tooltip="Herunterfahren", - 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", - 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", - faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!", - faillock_locked="Konto ist möglicherweise gesperrt", -) - -_STRINGS_EN = Strings( - password_placeholder="Password", - reboot_tooltip="Reboot", - shutdown_tooltip="Shut down", - 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", - 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", - faillock_attempts_remaining="{n} attempt(s) remaining before lockout!", - faillock_locked="Account may be locked", -) - -_LOCALE_MAP: dict[str, Strings] = { - "de": _STRINGS_DE, - "en": _STRINGS_EN, -} - - -def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str: - """Determine the system language from LANG env var or /etc/locale.conf.""" - lang = os.environ.get("LANG") - - if not lang and locale_conf_path.exists(): - for line in locale_conf_path.read_text().splitlines(): - if line.startswith("LANG="): - lang = line.split("=", 1)[1].strip() - break - - if not lang or lang in ("C", "POSIX"): - return "en" - - # Extract language prefix: "de_DE.UTF-8" → "de" - lang = lang.split("_")[0].split(".")[0].lower() - if not lang.isalpha(): - return "en" - return lang - - -def load_strings(locale: str | None = None) -> Strings: - """Return the string table for the given locale, defaulting to English.""" - if locale is None: - locale = detect_locale() - return _LOCALE_MAP.get(locale, _STRINGS_EN) diff --git a/src/moongreet/ipc.py b/src/moongreet/ipc.py deleted file mode 100644 index 657a638..0000000 --- a/src/moongreet/ipc.py +++ /dev/null @@ -1,64 +0,0 @@ -# ABOUTME: greetd IPC protocol implementation — communicates via Unix socket. -# ABOUTME: Uses length-prefixed JSON encoding as specified by the greetd IPC protocol. - -import json -import struct -from typing import Any - -MAX_PAYLOAD_SIZE = 65536 - - -def _recvall(sock: Any, n: int) -> bytes: - """Receive exactly n bytes from socket, looping on partial reads.""" - buf = bytearray() - while len(buf) < n: - chunk = sock.recv(n - len(buf)) - if not chunk: - raise ConnectionError("Connection closed while reading data") - buf.extend(chunk) - return bytes(buf) - - -def send_message(sock: Any, msg: dict) -> None: - """Send a length-prefixed JSON message to the greetd socket.""" - payload = json.dumps(msg).encode("utf-8") - if len(payload) > MAX_PAYLOAD_SIZE: - raise ValueError(f"Payload too large: {len(payload)} bytes (max {MAX_PAYLOAD_SIZE})") - header = struct.pack("=I", len(payload)) - sock.sendall(header + payload) - - -def recv_message(sock: Any) -> dict: - """Receive a length-prefixed JSON message from the greetd socket.""" - header = _recvall(sock, 4) - length = struct.unpack("=I", header)[0] - - if length > MAX_PAYLOAD_SIZE: - raise ConnectionError(f"Payload too large: {length} bytes (max {MAX_PAYLOAD_SIZE})") - - payload = _recvall(sock, length) - return json.loads(payload.decode("utf-8")) - - -def create_session(sock: Any, username: str) -> dict: - """Send a create_session request to greetd and return the response.""" - send_message(sock, {"type": "create_session", "username": username}) - return recv_message(sock) - - -def post_auth_response(sock: Any, response: str | None) -> dict: - """Send an authentication response (e.g. password) to greetd.""" - send_message(sock, {"type": "post_auth_message_response", "response": response}) - return recv_message(sock) - - -def start_session(sock: Any, cmd: list[str]) -> dict: - """Send a start_session request to launch the user's session.""" - send_message(sock, {"type": "start_session", "cmd": cmd}) - return recv_message(sock) - - -def cancel_session(sock: Any) -> dict: - """Cancel the current authentication session.""" - send_message(sock, {"type": "cancel_session"}) - return recv_message(sock) diff --git a/src/moongreet/main.py b/src/moongreet/main.py deleted file mode 100644 index 25d7c8b..0000000 --- a/src/moongreet/main.py +++ /dev/null @@ -1,162 +0,0 @@ -# ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell. -# ABOUTME: Handles multi-monitor setup: login UI on primary, wallpaper on secondary monitors. - -import logging -import sys -from importlib.resources import files -from pathlib import Path - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk - -from moongreet.config import load_config, resolve_wallpaper_path -from moongreet.greeter import GreeterWindow, WallpaperWindow - -# gtk4-layer-shell is optional for development/testing -try: - gi.require_version("Gtk4LayerShell", "1.0") - from gi.repository import Gtk4LayerShell - HAS_LAYER_SHELL = True -except (ValueError, ImportError): - HAS_LAYER_SHELL = False - -LOG_DIR = Path("/var/cache/moongreet") -LOG_FILE = LOG_DIR / "moongreet.log" - -logger = logging.getLogger(__name__) - - -def _setup_logging() -> None: - """Configure logging to file and stderr.""" - root = logging.getLogger() - root.setLevel(logging.INFO) - - formatter = logging.Formatter( - "%(asctime)s %(levelname)s %(name)s: %(message)s" - ) - - # Always log to stderr - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.INFO) - stderr_handler.setFormatter(formatter) - root.addHandler(stderr_handler) - - # Log to file if the directory is writable - if LOG_DIR.is_dir(): - try: - file_handler = logging.FileHandler(LOG_FILE) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(formatter) - root.addHandler(file_handler) - except PermissionError: - logger.warning("Cannot write to %s", LOG_FILE) - - -class MoongreetApp(Gtk.Application): - """GTK Application for the Moongreet greeter.""" - - def __init__(self) -> None: - super().__init__(application_id="dev.moonarch.moongreet") - self._wallpaper_ctx = None - self._secondary_windows: list[WallpaperWindow] = [] - - def do_activate(self) -> None: - """Create and present greeter windows on all monitors.""" - display = Gdk.Display.get_default() - if display is None: - logger.error("No display available — cannot start greeter UI") - return - - self._register_icons(display) - self._load_css(display) - - # Resolve wallpaper once, share across all windows - config = load_config() - bg_path, self._wallpaper_ctx = resolve_wallpaper_path(config) - - monitors = display.get_monitors() - primary_monitor = None - - # Find primary monitor — fall back to first available - for i in range(monitors.get_n_items()): - monitor = monitors.get_item(i) - if hasattr(monitor, 'is_primary') and monitor.is_primary(): - primary_monitor = monitor - break - if primary_monitor is None and monitors.get_n_items() > 0: - primary_monitor = monitors.get_item(0) - - # Main greeter window (login UI) on primary monitor - greeter = GreeterWindow(bg_path=bg_path, config=config, application=self) - if HAS_LAYER_SHELL: - self._setup_layer_shell(greeter, keyboard=True) - if primary_monitor is not None: - Gtk4LayerShell.set_monitor(greeter, primary_monitor) - greeter.present() - - # Wallpaper-only windows on secondary monitors - for i in range(monitors.get_n_items()): - monitor = monitors.get_item(i) - if monitor == primary_monitor: - continue - wallpaper_win = WallpaperWindow(bg_path=bg_path, application=self) - if HAS_LAYER_SHELL: - self._setup_layer_shell(wallpaper_win, keyboard=False) - Gtk4LayerShell.set_monitor(wallpaper_win, monitor) - wallpaper_win.present() - self._secondary_windows.append(wallpaper_win) - - def do_shutdown(self) -> None: - """Clean up wallpaper context manager on exit.""" - if self._wallpaper_ctx is not None: - self._wallpaper_ctx.__exit__(None, None, None) - self._wallpaper_ctx = None - Gtk.Application.do_shutdown(self) - - def _register_icons(self, display: Gdk.Display) -> None: - """Register custom icons from the package data/icons directory.""" - icons_dir = files("moongreet") / "data" / "icons" - icon_theme = Gtk.IconTheme.get_for_display(display) - icon_theme.add_search_path(str(icons_dir)) - - def _load_css(self, display: Gdk.Display) -> None: - """Load the CSS stylesheet for the greeter.""" - css_provider = Gtk.CssProvider() - css_path = files("moongreet") / "style.css" - css_provider.load_from_path(str(css_path)) - Gtk.StyleContext.add_provider_for_display( - display, - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - def _setup_layer_shell(self, window: Gtk.Window, keyboard: bool = False) -> None: - """Configure gtk4-layer-shell for fullscreen display.""" - Gtk4LayerShell.init_for_window(window) - Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP) - if keyboard: - Gtk4LayerShell.set_keyboard_mode( - window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE - ) - # Anchor to all edges for fullscreen - for edge in [ - Gtk4LayerShell.Edge.TOP, - Gtk4LayerShell.Edge.BOTTOM, - Gtk4LayerShell.Edge.LEFT, - Gtk4LayerShell.Edge.RIGHT, - ]: - Gtk4LayerShell.set_anchor(window, edge, True) - - -def main() -> None: - """Run the Moongreet application.""" - _setup_logging() - logger.info("Moongreet starting") - app = MoongreetApp() - app.run(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/src/moongreet/power.py b/src/moongreet/power.py deleted file mode 100644 index 6d6b3c2..0000000 --- a/src/moongreet/power.py +++ /dev/null @@ -1,17 +0,0 @@ -# ABOUTME: Power actions — reboot and shutdown via loginctl. -# ABOUTME: Simple wrappers around system commands for the greeter UI. - -import subprocess - - -POWER_TIMEOUT = 30 - - -def reboot() -> None: - """Reboot the system via loginctl.""" - subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT) - - -def shutdown() -> None: - """Shut down the system via loginctl.""" - subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT) diff --git a/src/moongreet/sessions.py b/src/moongreet/sessions.py deleted file mode 100644 index fca1dad..0000000 --- a/src/moongreet/sessions.py +++ /dev/null @@ -1,63 +0,0 @@ -# ABOUTME: Session detection — discovers available Wayland and X11 sessions. -# ABOUTME: Parses .desktop files from standard session directories. - -import configparser -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path - -DEFAULT_WAYLAND_DIRS = (Path("/usr/share/wayland-sessions"),) -DEFAULT_XSESSION_DIRS = (Path("/usr/share/xsessions"),) - - -@dataclass -class Session: - """Represents an available login session.""" - - name: str - exec_cmd: str - session_type: str # "wayland" or "x11" - - -def _parse_desktop_file(path: Path, session_type: str) -> Session | None: - """Parse a .desktop file and return a Session, or None if invalid.""" - config = configparser.ConfigParser(interpolation=None) - config.read(path) - - section = "Desktop Entry" - if not config.has_section(section): - return None - - name = config.get(section, "Name", fallback=None) - exec_cmd = config.get(section, "Exec", fallback=None) - - if not name or not exec_cmd: - return None - - return Session(name=name, exec_cmd=exec_cmd, session_type=session_type) - - -def get_sessions( - wayland_dirs: Sequence[Path] = DEFAULT_WAYLAND_DIRS, - xsession_dirs: Sequence[Path] = DEFAULT_XSESSION_DIRS, -) -> list[Session]: - """Discover available sessions from .desktop files.""" - sessions: list[Session] = [] - - for directory in wayland_dirs: - if not directory.exists(): - continue - for desktop_file in sorted(directory.glob("*.desktop")): - session = _parse_desktop_file(desktop_file, "wayland") - if session: - sessions.append(session) - - for directory in xsession_dirs: - if not directory.exists(): - continue - for desktop_file in sorted(directory.glob("*.desktop")): - session = _parse_desktop_file(desktop_file, "x11") - if session: - sessions.append(session) - - return sessions diff --git a/src/moongreet/users.py b/src/moongreet/users.py deleted file mode 100644 index 7af37a2..0000000 --- a/src/moongreet/users.py +++ /dev/null @@ -1,109 +0,0 @@ -# ABOUTME: User detection — parses /etc/passwd for login users, finds avatars and GTK themes. -# ABOUTME: Provides User dataclass and helper functions for the greeter UI. - -import configparser -from dataclasses import dataclass -from pathlib import Path - -NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"} -MIN_UID = 1000 -MAX_UID = 65533 - -DEFAULT_PASSWD = Path("/etc/passwd") -DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons") - - -@dataclass -class User: - """Represents a system user suitable for login.""" - - username: str - uid: int - gecos: str - home: Path - shell: str - - @property - def display_name(self) -> str: - """Return gecos if available, otherwise username.""" - return self.gecos if self.gecos else self.username - - -def get_users(passwd_path: Path = DEFAULT_PASSWD) -> list[User]: - """Parse /etc/passwd and return users with UID in the login range.""" - users: list[User] = [] - - if not passwd_path.exists(): - return users - - for line in passwd_path.read_text().splitlines(): - parts = line.split(":") - if len(parts) < 7: - continue - - username, _, uid_str, _, gecos, home, shell = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] - - try: - uid = int(uid_str) - except ValueError: - continue - - if uid < MIN_UID or uid > MAX_UID: - continue - if shell in NOLOGIN_SHELLS: - continue - if "/" in username or username.startswith("."): - continue - - users.append(User( - username=username, - uid=uid, - gecos=gecos, - home=Path(home), - shell=shell, - )) - - return users - - -def get_avatar_path( - username: str, - accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR, - home_dir: Path | None = None, -) -> Path | None: - """Find avatar for a user: AccountsService icon → ~/.face → None.""" - # AccountsService icon - icon = accountsservice_dir / username - if icon.exists() and not icon.is_symlink(): - return icon - - # ~/.face fallback - if home_dir is not None: - face = home_dir / ".face" - if face.exists() and not face.is_symlink(): - return face - - return None - - -def get_user_gtk_theme(config_dir: Path | None = None) -> str | None: - """Read the GTK theme name from a user's gtk-4.0/settings.ini.""" - if config_dir is None: - return None - - settings_file = config_dir / "settings.ini" - if not settings_file.exists(): - return None - - config = configparser.ConfigParser(interpolation=None) - try: - config.read(settings_file) - except configparser.Error: - return None - - if config.has_option("Settings", "gtk-theme-name"): - theme = config.get("Settings", "gtk-theme-name") - if theme: - return theme - - return None diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..b9f3ca1 --- /dev/null +++ b/src/power.rs @@ -0,0 +1,112 @@ +// ABOUTME: Power actions — reboot and shutdown via loginctl. +// ABOUTME: Wrappers around system commands for the greeter UI. + +use std::fmt; +use std::process::Command; + +#[derive(Debug)] +pub enum PowerError { + CommandFailed { action: &'static str, message: String }, + Timeout { action: &'static str }, +} + +impl fmt::Display for PowerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PowerError::CommandFailed { action, message } => { + write!(f, "{action} failed: {message}") + } + PowerError::Timeout { action } => { + write!(f, "{action} timed out") + } + } + } +} + +impl std::error::Error for PowerError {} + +/// Run a command and return a PowerError on failure. +fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { + let child = Command::new(program) + .args(args) + .spawn() + .map_err(|e| PowerError::CommandFailed { + action, + message: e.to_string(), + })?; + + let output = child + .wait_with_output() + .map_err(|e| PowerError::CommandFailed { + action, + message: e.to_string(), + })?; + + 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()), + }); + } + + Ok(()) +} + +/// Reboot the system via loginctl. +pub fn reboot() -> Result<(), PowerError> { + run_command("reboot", "/usr/bin/loginctl", &["reboot"]) +} + +/// Shut down the system via loginctl. +pub fn shutdown() -> Result<(), PowerError> { + run_command("shutdown", "/usr/bin/loginctl", &["poweroff"]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_error_command_failed_display() { + let err = PowerError::CommandFailed { + action: "reboot", + message: "No such file or directory".to_string(), + }; + assert_eq!(err.to_string(), "reboot failed: No such file or directory"); + } + + #[test] + fn power_error_timeout_display() { + let err = PowerError::Timeout { action: "shutdown" }; + assert_eq!(err.to_string(), "shutdown timed out"); + } + + #[test] + fn run_command_returns_error_for_missing_binary() { + let result = run_command("test", "nonexistent-binary-xyz", &[]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, PowerError::CommandFailed { action: "test", .. })); + } + + #[test] + fn run_command_returns_error_on_nonzero_exit() { + let result = run_command("test", "false", &[]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, PowerError::CommandFailed { action: "test", .. })); + } + + #[test] + fn run_command_succeeds_for_true() { + let result = run_command("test", "true", &[]); + assert!(result.is_ok()); + } + + #[test] + fn run_command_passes_args() { + let result = run_command("test", "echo", &["hello", "world"]); + assert!(result.is_ok()); + } +} diff --git a/src/sessions.rs b/src/sessions.rs new file mode 100644 index 0000000..7bd7d03 --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,228 @@ +// ABOUTME: Session detection — discovers available Wayland and X11 sessions. +// ABOUTME: Parses .desktop files from standard session directories. + +use std::fs; +use std::path::{Path, PathBuf}; + +const DEFAULT_WAYLAND_DIRS: &[&str] = &["/usr/share/wayland-sessions"]; +const DEFAULT_XSESSION_DIRS: &[&str] = &["/usr/share/xsessions"]; + +/// Represents an available login session. +#[derive(Debug, Clone)] +pub struct Session { + pub name: String, + pub exec_cmd: String, + pub session_type: String, +} + +/// Parse a .desktop file and return a Session, or None if invalid. +fn parse_desktop_file(path: &Path, session_type: &str) -> Option { + let content = fs::read_to_string(path).ok()?; + + let mut in_section = false; + let mut name: Option = None; + let mut exec_cmd: Option = None; + + for line in content.lines() { + let line = line.trim(); + + if line.starts_with('[') { + in_section = line == "[Desktop Entry]"; + continue; + } + + if !in_section { + continue; + } + + if let Some(value) = line.strip_prefix("Name=") { + if name.is_none() { + name = Some(value.to_string()); + } + } else if let Some(value) = line.strip_prefix("Exec=") { + if exec_cmd.is_none() { + exec_cmd = Some(value.to_string()); + } + } + } + + let name = name.filter(|s| !s.is_empty())?; + let exec_cmd = exec_cmd.filter(|s| !s.is_empty())?; + + Some(Session { + name, + exec_cmd, + session_type: session_type.to_string(), + }) +} + +/// Discover available sessions from .desktop files. +pub fn get_sessions( + wayland_dirs: Option<&[PathBuf]>, + xsession_dirs: Option<&[PathBuf]>, +) -> Vec { + let default_wayland: Vec = + DEFAULT_WAYLAND_DIRS.iter().map(PathBuf::from).collect(); + let default_xsession: Vec = + DEFAULT_XSESSION_DIRS.iter().map(PathBuf::from).collect(); + + let wayland = wayland_dirs.unwrap_or(&default_wayland); + let xsession = xsession_dirs.unwrap_or(&default_xsession); + + let mut sessions = Vec::new(); + + for (dirs, session_type) in [(wayland, "wayland"), (xsession, "x11")] { + for directory in dirs { + let entries = match fs::read_dir(directory) { + Ok(e) => e, + Err(_) => continue, + }; + + let mut paths: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "desktop")) + .collect(); + paths.sort(); + + for path in paths { + if let Some(session) = parse_desktop_file(&path, session_type) { + sessions.push(session); + } + } + } + } + + sessions +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_desktop(dir: &Path, name: &str, content: &str) { + fs::write(dir.join(name), content).unwrap(); + } + + #[test] + fn parse_valid_desktop_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.desktop"); + fs::write( + &file, + "[Desktop Entry]\nName=Niri\nExec=niri-session\n", + ) + .unwrap(); + let session = parse_desktop_file(&file, "wayland").unwrap(); + assert_eq!(session.name, "Niri"); + assert_eq!(session.exec_cmd, "niri-session"); + assert_eq!(session.session_type, "wayland"); + } + + #[test] + fn parse_desktop_file_missing_name() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.desktop"); + fs::write(&file, "[Desktop Entry]\nExec=niri-session\n").unwrap(); + assert!(parse_desktop_file(&file, "wayland").is_none()); + } + + #[test] + fn parse_desktop_file_missing_exec() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.desktop"); + fs::write(&file, "[Desktop Entry]\nName=Niri\n").unwrap(); + assert!(parse_desktop_file(&file, "wayland").is_none()); + } + + #[test] + fn parse_desktop_file_wrong_section() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.desktop"); + fs::write( + &file, + "[Other Section]\nName=Niri\nExec=niri-session\n", + ) + .unwrap(); + assert!(parse_desktop_file(&file, "wayland").is_none()); + } + + #[test] + fn get_sessions_finds_wayland_and_x11() { + let wayland_dir = tempfile::tempdir().unwrap(); + let x11_dir = tempfile::tempdir().unwrap(); + + write_desktop( + wayland_dir.path(), + "niri.desktop", + "[Desktop Entry]\nName=Niri\nExec=niri-session\n", + ); + write_desktop( + x11_dir.path(), + "i3.desktop", + "[Desktop Entry]\nName=i3\nExec=i3\n", + ); + + let wayland_paths = vec![wayland_dir.path().to_path_buf()]; + let x11_paths = vec![x11_dir.path().to_path_buf()]; + let sessions = get_sessions(Some(&wayland_paths), Some(&x11_paths)); + + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].name, "Niri"); + assert_eq!(sessions[0].session_type, "wayland"); + assert_eq!(sessions[1].name, "i3"); + assert_eq!(sessions[1].session_type, "x11"); + } + + #[test] + fn get_sessions_skips_missing_dirs() { + let sessions = get_sessions( + Some(&[PathBuf::from("/nonexistent")]), + Some(&[PathBuf::from("/also-nonexistent")]), + ); + assert!(sessions.is_empty()); + } + + #[test] + fn get_sessions_skips_invalid_files() { + let dir = tempfile::tempdir().unwrap(); + write_desktop( + dir.path(), + "valid.desktop", + "[Desktop Entry]\nName=Valid\nExec=valid\n", + ); + write_desktop( + dir.path(), + "invalid.desktop", + "[Desktop Entry]\nName=Invalid\n", + ); + // Non-.desktop file + fs::write(dir.path().join("readme.txt"), "not a session").unwrap(); + + let paths = vec![dir.path().to_path_buf()]; + let sessions = get_sessions(Some(&paths), Some(&[])); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].name, "Valid"); + } + + #[test] + fn sessions_sorted_alphabetically() { + let dir = tempfile::tempdir().unwrap(); + write_desktop( + dir.path(), + "z-sway.desktop", + "[Desktop Entry]\nName=Sway\nExec=sway\n", + ); + write_desktop( + dir.path(), + "a-niri.desktop", + "[Desktop Entry]\nName=Niri\nExec=niri-session\n", + ); + + let paths = vec![dir.path().to_path_buf()]; + let sessions = get_sessions(Some(&paths), Some(&[])); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].name, "Niri"); + assert_eq!(sessions[1].name, "Sway"); + } +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..e4f65ef --- /dev/null +++ b/src/users.rs @@ -0,0 +1,280 @@ +// ABOUTME: User detection — parses /etc/passwd for login users and finds avatars. +// ABOUTME: Provides User struct and helpers for the greeter UI. + +use std::fs; +use std::path::{Path, PathBuf}; + +const MIN_UID: u32 = 1000; +const MAX_UID: u32 = 65533; + +const DEFAULT_PASSWD: &str = "/etc/passwd"; +const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons"; +const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet"; + +/// Shells that indicate a user cannot log in. +const NOLOGIN_SHELLS: &[&str] = &[ + "/usr/sbin/nologin", + "/sbin/nologin", + "/bin/false", + "/usr/bin/nologin", +]; + +/// Represents a system user suitable for login. +#[derive(Debug, Clone)] +pub struct User { + pub username: String, + pub uid: u32, + pub gecos: String, + pub home: PathBuf, + pub shell: String, +} + +impl User { + /// Return the display name (GECOS if available, otherwise username). + pub fn display_name(&self) -> &str { + if self.gecos.is_empty() { + &self.username + } else { + &self.gecos + } + } +} + +/// Parse /etc/passwd and return users with UID in the login range. +pub fn get_users(passwd_path: Option<&Path>) -> Vec { + let path = passwd_path.unwrap_or(Path::new(DEFAULT_PASSWD)); + + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let mut users = Vec::new(); + + for line in content.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 7 { + 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::() { + Ok(u) => u, + Err(_) => continue, + }; + + if uid < MIN_UID || uid > MAX_UID { + continue; + } + if NOLOGIN_SHELLS.contains(&shell) { + continue; + } + // Path traversal prevention + if username.contains('/') || username.starts_with('.') { + continue; + } + + users.push(User { + username: username.to_string(), + uid, + gecos: gecos.to_string(), + home: PathBuf::from(home), + shell: shell.to_string(), + }); + } + + users +} + +/// Find avatar for a user: AccountsService icon > ~/.face > None. +/// Rejects symlinks to prevent path traversal. +pub fn get_avatar_path(username: &str, home: &Path) -> Option { + get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR)) +} + +/// Find avatar with configurable AccountsService dir (for testing). +pub fn get_avatar_path_with( + username: &str, + home: &Path, + accountsservice_dir: &Path, +) -> Option { + // AccountsService icon takes priority + if accountsservice_dir.exists() { + let icon = accountsservice_dir.join(username); + if icon.exists() && !icon.is_symlink() { + return Some(icon); + } + } + + // ~/.face fallback + let face = home.join(".face"); + if face.exists() && !face.is_symlink() { + return Some(face); + } + + None +} + +/// Return the GResource path to the default avatar SVG. +pub fn get_default_avatar_path() -> String { + format!("{GRESOURCE_PREFIX}/default-avatar.svg") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_passwd(dir: &Path, content: &str) -> PathBuf { + let path = dir.join("passwd"); + fs::write(&path, content).unwrap(); + path + } + + #[test] + fn parse_normal_user() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd( + dir.path(), + "testuser:x:1000:1000:Test User:/home/testuser:/bin/bash\n", + ); + let users = get_users(Some(&path)); + assert_eq!(users.len(), 1); + assert_eq!(users[0].username, "testuser"); + assert_eq!(users[0].uid, 1000); + assert_eq!(users[0].display_name(), "Test User"); + assert_eq!(users[0].home, PathBuf::from("/home/testuser")); + } + + #[test] + fn skip_system_users() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd(dir.path(), "root:x:0:0:root:/root:/bin/bash\n"); + let users = get_users(Some(&path)); + assert!(users.is_empty()); + } + + #[test] + fn skip_nologin_users() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd( + dir.path(), + "nobody:x:1000:1000::/home/nobody:/usr/sbin/nologin\n", + ); + let users = get_users(Some(&path)); + assert!(users.is_empty()); + } + + #[test] + fn skip_users_with_slash_in_name() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd( + dir.path(), + "bad/user:x:1000:1000::/home/bad:/bin/bash\n", + ); + let users = get_users(Some(&path)); + assert!(users.is_empty()); + } + + #[test] + fn skip_users_starting_with_dot() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd( + dir.path(), + ".hidden:x:1000:1000::/home/hidden:/bin/bash\n", + ); + let users = get_users(Some(&path)); + assert!(users.is_empty()); + } + + #[test] + fn empty_gecos_uses_username() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd( + dir.path(), + "testuser:x:1000:1000::/home/testuser:/bin/bash\n", + ); + let users = get_users(Some(&path)); + assert_eq!(users[0].display_name(), "testuser"); + } + + #[test] + fn multiple_users() { + let dir = tempfile::tempdir().unwrap(); + let path = make_passwd( + dir.path(), + "alice:x:1000:1000:Alice:/home/alice:/bin/bash\n\ + bob:x:1001:1001:Bob:/home/bob:/bin/zsh\n", + ); + let users = get_users(Some(&path)); + assert_eq!(users.len(), 2); + assert_eq!(users[0].username, "alice"); + assert_eq!(users[1].username, "bob"); + } + + #[test] + fn returns_empty_for_missing_file() { + let users = get_users(Some(Path::new("/nonexistent/passwd"))); + assert!(users.is_empty()); + } + + #[test] + fn accountsservice_icon_takes_priority() { + let dir = tempfile::tempdir().unwrap(); + let icons_dir = dir.path().join("icons"); + fs::create_dir(&icons_dir).unwrap(); + let icon = icons_dir.join("testuser"); + fs::write(&icon, "fake image").unwrap(); + + let home = dir.path().join("home"); + fs::create_dir(&home).unwrap(); + let face = home.join(".face"); + fs::write(&face, "fake face").unwrap(); + + let path = get_avatar_path_with("testuser", &home, &icons_dir); + assert_eq!(path, Some(icon)); + } + + #[test] + fn face_file_used_when_no_accountsservice() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + fs::create_dir(&home).unwrap(); + let face = home.join(".face"); + fs::write(&face, "fake face").unwrap(); + + let path = get_avatar_path_with("testuser", &home, Path::new("/nonexistent")); + assert_eq!(path, Some(face)); + } + + #[test] + fn returns_none_when_no_avatar() { + let dir = tempfile::tempdir().unwrap(); + let path = get_avatar_path_with("testuser", dir.path(), Path::new("/nonexistent")); + assert!(path.is_none()); + } + + #[test] + fn rejects_symlink_avatar() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + fs::create_dir(&home).unwrap(); + let real_file = dir.path().join("real-avatar"); + fs::write(&real_file, "fake").unwrap(); + std::os::unix::fs::symlink(&real_file, home.join(".face")).unwrap(); + + let path = get_avatar_path_with("testuser", &home, Path::new("/nonexistent")); + assert!(path.is_none()); + } + + #[test] + fn default_avatar_path_is_gresource() { + let path = get_default_avatar_path(); + assert!(path.contains("default-avatar.svg")); + assert!(path.starts_with("/dev/moonarch/moongreet")); + } +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 0b7f872..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,110 +0,0 @@ -# ABOUTME: Tests for configuration loading from moongreet.toml. -# ABOUTME: Verifies parsing of appearance and behavior settings. - -from pathlib import Path - -import pytest - -from moongreet.config import load_config, resolve_wallpaper_path, Config - - -class TestLoadConfig: - """Tests for loading moongreet.toml configuration.""" - - def test_loads_background_path(self, tmp_path: Path) -> None: - toml_file = tmp_path / "moongreet.toml" - toml_file.write_text( - "[appearance]\n" - 'background = "/usr/share/backgrounds/test.jpg"\n' - ) - - config = load_config(toml_file) - - assert config.background == Path("/usr/share/backgrounds/test.jpg") - - def test_returns_none_background_when_missing(self, tmp_path: Path) -> None: - toml_file = tmp_path / "moongreet.toml" - toml_file.write_text("[appearance]\n") - - config = load_config(toml_file) - - assert config.background is None - - def test_returns_defaults_for_missing_file(self, tmp_path: Path) -> None: - config = load_config(tmp_path / "nonexistent.toml") - - assert config.background is None - - def test_returns_defaults_for_corrupt_toml(self, tmp_path: Path) -> None: - toml_file = tmp_path / "moongreet.toml" - toml_file.write_text("this is not valid [[[ toml !!!") - - config = load_config(toml_file) - - assert config.background is None - - def test_loads_gtk_theme(self, tmp_path: Path) -> None: - toml_file = tmp_path / "moongreet.toml" - toml_file.write_text( - "[appearance]\n" - 'gtk-theme = "Catppuccin-Mocha-Standard-Blue-Dark"\n' - ) - - config = load_config(toml_file) - - assert config.gtk_theme == "Catppuccin-Mocha-Standard-Blue-Dark" - - def test_returns_none_gtk_theme_when_missing(self, tmp_path: Path) -> None: - toml_file = tmp_path / "moongreet.toml" - toml_file.write_text("[appearance]\n") - - config = load_config(toml_file) - - assert config.gtk_theme is None - - - def test_resolves_relative_path_against_config_dir(self, tmp_path: Path) -> None: - toml_file = tmp_path / "moongreet.toml" - toml_file.write_text( - "[appearance]\n" - 'background = "wallpaper.jpg"\n' - ) - - config = load_config(toml_file) - - assert config.background == tmp_path / "wallpaper.jpg" - - -class TestResolveWallpaperPath: - """Tests for resolving the wallpaper path from config or package default.""" - - def test_uses_configured_path_when_exists(self, tmp_path: Path) -> None: - wallpaper = tmp_path / "custom.jpg" - wallpaper.write_bytes(b"fake-image") - config = Config(background=wallpaper) - - path, ctx = resolve_wallpaper_path(config) - - assert path == wallpaper - assert ctx is None - - def test_falls_back_to_package_default(self) -> None: - config = Config(background=None) - - path, ctx = resolve_wallpaper_path(config) - - assert path is not None - assert path.exists() - assert ctx is not None - # Clean up context manager - ctx.__exit__(None, None, None) - - def test_falls_back_when_configured_path_missing(self, tmp_path: Path) -> None: - config = Config(background=tmp_path / "nonexistent.jpg") - - path, ctx = resolve_wallpaper_path(config) - - assert path is not None - assert path.exists() - assert ctx is not None - ctx.__exit__(None, None, None) diff --git a/tests/test_i18n.py b/tests/test_i18n.py deleted file mode 100644 index 34c0d4e..0000000 --- a/tests/test_i18n.py +++ /dev/null @@ -1,126 +0,0 @@ -# ABOUTME: Tests for locale detection and string lookup. -# ABOUTME: Verifies DE/EN selection based on system locale. - -from pathlib import Path - -import pytest - -from moongreet.i18n import detect_locale, load_strings, Strings - - -class TestDetectLocale: - """Tests for system locale detection.""" - - def test_reads_lang_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LANG", "de_DE.UTF-8") - - result = detect_locale() - - assert result == "de" - - def test_reads_lang_without_region(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LANG", "en_US.UTF-8") - - result = detect_locale() - - assert result == "en" - - def test_falls_back_to_locale_conf(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("LANG", raising=False) - locale_conf = tmp_path / "locale.conf" - locale_conf.write_text("LANG=de_AT.UTF-8\n") - - result = detect_locale(locale_conf_path=locale_conf) - - assert result == "de" - - def test_defaults_to_english(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("LANG", raising=False) - missing = tmp_path / "nonexistent" - - result = detect_locale(locale_conf_path=missing) - - assert result == "en" - - def test_handles_bare_language_code(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LANG", "de") - - result = detect_locale() - - assert result == "de" - - def test_handles_c_locale(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LANG", "C") - - result = detect_locale() - - assert result == "en" - - def test_handles_posix_locale(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LANG", "POSIX") - - result = detect_locale() - - assert result == "en" - - def test_rejects_non_alpha_lang(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LANG", "../../etc") - - result = detect_locale() - - assert result == "en" - - -class TestLoadStrings: - """Tests for loading the correct string table.""" - - def test_loads_german_strings(self) -> None: - strings = load_strings("de") - - assert strings.password_placeholder == "Passwort" - assert strings.reboot_tooltip == "Neustart" - assert strings.shutdown_tooltip == "Herunterfahren" - - def test_loads_english_strings(self) -> None: - strings = load_strings("en") - - assert strings.password_placeholder == "Password" - assert strings.reboot_tooltip == "Reboot" - assert strings.shutdown_tooltip == "Shut down" - - def test_unknown_locale_falls_back_to_english(self) -> None: - strings = load_strings("fr") - - assert strings.password_placeholder == "Password" - - def test_returns_strings_dataclass(self) -> None: - strings = load_strings("de") - - assert isinstance(strings, Strings) - - def test_error_messages_are_present(self) -> None: - strings = load_strings("en") - - assert strings.wrong_password - assert strings.auth_failed - assert strings.reboot_failed - assert strings.shutdown_failed - assert strings.no_session_selected - assert strings.multi_stage_unsupported - assert strings.invalid_session_command - assert strings.session_start_failed - assert strings.faillock_locked - - def test_faillock_warning_template(self) -> None: - strings = load_strings("de") - - # Template should accept an int for remaining attempts - result = strings.faillock_attempts_remaining.format(n=1) - assert "1" in result - - def test_connection_error_is_generic(self) -> None: - strings = load_strings("en") - - # Error messages should not contain format placeholders (no info leakage) - assert "{" not in strings.connection_error - assert "{" not in strings.socket_error diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 0786478..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,478 +0,0 @@ -# ABOUTME: Integration tests — verifies the login flow end-to-end via a mock greetd socket. -# ABOUTME: Tests the IPC sequence: create_session → post_auth → start_session. - -import json -import os -import socket -import struct -import threading -from pathlib import Path - -import pytest - -from moongreet.greeter import faillock_warning, FAILLOCK_MAX_ATTEMPTS, LAST_SESSION_DIR -from moongreet.i18n import load_strings -from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session - - -class MockGreetd: - """A mock greetd server that listens on a Unix socket and responds to IPC messages.""" - - def __init__(self, sock_path: Path) -> None: - self.sock_path = sock_path - self._responses: list[dict] = [] - self._received: list[dict] = [] - self._server: socket.socket | None = None - - def expect(self, response: dict) -> None: - """Queue a response to send for the next received message.""" - self._responses.append(response) - - @property - def received(self) -> list[dict]: - return self._received - - def start(self) -> None: - """Start the mock server in a background thread.""" - self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self._server.bind(str(self.sock_path)) - self._server.listen(1) - self._thread = threading.Thread(target=self._serve, daemon=True) - self._thread.start() - - @staticmethod - def _recvall(conn: socket.socket, n: int) -> bytes: - """Receive exactly n bytes from a socket, handling fragmented reads.""" - buf = bytearray() - while len(buf) < n: - chunk = conn.recv(n - len(buf)) - if not chunk: - break - buf.extend(chunk) - return bytes(buf) - - def _serve(self) -> None: - conn, _ = self._server.accept() - try: - for response in self._responses: - # Receive a message - header = self._recvall(conn, 4) - if len(header) < 4: - break - length = struct.unpack("=I", header)[0] - payload = self._recvall(conn, length) - msg = json.loads(payload.decode("utf-8")) - self._received.append(msg) - - # Send response - resp_payload = json.dumps(response).encode("utf-8") - conn.sendall(struct.pack("=I", len(resp_payload)) + resp_payload) - finally: - conn.close() - - def stop(self) -> None: - if self._server: - self._server.close() - - -class TestLoginFlow: - """Integration tests for the complete login flow via mock greetd.""" - - def test_successful_login(self, tmp_path: Path) -> None: - """Simulate a complete successful login: create → auth → start.""" - sock_path = tmp_path / "greetd.sock" - mock = MockGreetd(sock_path) - mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) - mock.expect({"type": "success"}) - mock.expect({"type": "success"}) - mock.start() - - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(str(sock_path)) - - # Step 1: Create session - response = create_session(sock, "dominik") - assert response["type"] == "auth_message" - - # Step 2: Send password - response = post_auth_response(sock, "geheim") - assert response["type"] == "success" - - # Step 3: Start session - response = start_session(sock, ["Hyprland"]) - assert response["type"] == "success" - - sock.close() - finally: - mock.stop() - - # Verify what the mock received - assert mock.received[0] == {"type": "create_session", "username": "dominik"} - assert mock.received[1] == {"type": "post_auth_message_response", "response": "geheim"} - assert mock.received[2] == {"type": "start_session", "cmd": ["Hyprland"]} - - def test_wrong_password_sends_cancel(self, tmp_path: Path) -> None: - """After a failed login, cancel_session must be sent to free the greetd session.""" - sock_path = tmp_path / "greetd.sock" - mock = MockGreetd(sock_path) - mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) - mock.expect({"type": "error", "error_type": "auth_error", "description": "Authentication failed"}) - mock.expect({"type": "success"}) # Response to cancel_session - mock.start() - - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(str(sock_path)) - - response = create_session(sock, "dominik") - assert response["type"] == "auth_message" - - response = post_auth_response(sock, "falsch") - assert response["type"] == "error" - assert response["description"] == "Authentication failed" - - # The greeter must cancel the session after auth failure - response = cancel_session(sock) - assert response["type"] == "success" - - sock.close() - finally: - mock.stop() - - assert mock.received[2] == {"type": "cancel_session"} - - def test_stale_session_cancel_and_retry(self, tmp_path: Path) -> None: - """When create_session fails due to a stale session, cancel and retry.""" - sock_path = tmp_path / "greetd.sock" - mock = MockGreetd(sock_path) - # First create_session → error (stale session) - mock.expect({"type": "error", "error_type": "error", "description": "a session is already being configured"}) - # cancel_session → success - mock.expect({"type": "success"}) - # Second create_session → auth_message (retry succeeds) - mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) - # post_auth_response → success - mock.expect({"type": "success"}) - # start_session → success - mock.expect({"type": "success"}) - mock.start() - - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(str(sock_path)) - - # Step 1: Create session fails - response = create_session(sock, "dominik") - assert response["type"] == "error" - - # Step 2: Cancel stale session - response = cancel_session(sock) - assert response["type"] == "success" - - # Step 3: Retry create session - response = create_session(sock, "dominik") - assert response["type"] == "auth_message" - - # Step 4: Send password - response = post_auth_response(sock, "geheim") - assert response["type"] == "success" - - # Step 5: Start session - response = start_session(sock, ["niri-session"]) - assert response["type"] == "success" - - sock.close() - finally: - mock.stop() - - assert mock.received[0] == {"type": "create_session", "username": "dominik"} - assert mock.received[1] == {"type": "cancel_session"} - assert mock.received[2] == {"type": "create_session", "username": "dominik"} - - def test_multi_stage_auth_sends_cancel(self, tmp_path: Path) -> None: - """When greetd sends a second auth_message after password, cancel the session.""" - sock_path = tmp_path / "greetd.sock" - mock = MockGreetd(sock_path) - mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) - mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "TOTP:"}) - mock.expect({"type": "success"}) # Response to cancel_session - mock.start() - - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(str(sock_path)) - - # Step 1: Create session - response = create_session(sock, "dominik") - assert response["type"] == "auth_message" - - # Step 2: Send password — greetd responds with another auth_message - response = post_auth_response(sock, "geheim") - assert response["type"] == "auth_message" - - # Step 3: Cancel because multi-stage auth is not supported - response = cancel_session(sock) - assert response["type"] == "success" - - sock.close() - finally: - mock.stop() - - # Verify cancel was sent - assert mock.received[2] == {"type": "cancel_session"} - - def test_cancel_session(self, tmp_path: Path) -> None: - """Simulate cancelling a session after create.""" - sock_path = tmp_path / "greetd.sock" - mock = MockGreetd(sock_path) - mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) - mock.expect({"type": "success"}) - mock.start() - - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(str(sock_path)) - - create_session(sock, "dominik") - response = cancel_session(sock) - assert response["type"] == "success" - - sock.close() - finally: - mock.stop() - - assert mock.received[1] == {"type": "cancel_session"} - - -class TestSessionCancellation: - """Tests for cancelling an in-progress greetd session during user switch.""" - - def test_cancel_closes_socket_and_sets_event(self, tmp_path: Path) -> None: - """_cancel_pending_session should close the socket and set the cancelled event.""" - from moongreet.greeter import GreeterWindow - - win = GreeterWindow.__new__(GreeterWindow) - win._greetd_sock_lock = threading.Lock() - win._login_cancelled = threading.Event() - - # Create a real socket pair to verify close - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock_path = tmp_path / "test.sock" - server.bind(str(sock_path)) - server.listen(1) - client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - client.connect(str(sock_path)) - server.close() - - win._greetd_sock = client - win._cancel_pending_session() - - assert win._login_cancelled.is_set() - assert win._greetd_sock is None - - def test_cancel_is_noop_without_socket(self) -> None: - """_cancel_pending_session should be safe to call when no socket exists.""" - from moongreet.greeter import GreeterWindow - - win = GreeterWindow.__new__(GreeterWindow) - win._greetd_sock_lock = threading.Lock() - win._login_cancelled = threading.Event() - win._greetd_sock = None - - win._cancel_pending_session() - - assert win._login_cancelled.is_set() - assert win._greetd_sock is None - - def test_cancel_does_not_block_main_thread(self, tmp_path: Path) -> None: - """_cancel_pending_session must not do blocking I/O — only close the socket.""" - from moongreet.greeter import GreeterWindow - - win = GreeterWindow.__new__(GreeterWindow) - win._greetd_sock_lock = threading.Lock() - win._login_cancelled = threading.Event() - - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - win._greetd_sock = sock - - # Should complete nearly instantly (no IPC calls) - import time - start = time.monotonic() - win._cancel_pending_session() - elapsed = time.monotonic() - start - - assert elapsed < 0.1 # No blocking I/O - - def test_worker_exits_silently_when_cancelled(self, tmp_path: Path) -> None: - """_login_worker should exit without showing an error when cancelled mid-flight.""" - from unittest.mock import MagicMock, patch - from moongreet.greeter import GreeterWindow - from moongreet.users import User - - win = GreeterWindow.__new__(GreeterWindow) - win._greetd_sock_lock = threading.Lock() - win._login_cancelled = threading.Event() - win._greetd_sock = None - win._failed_attempts = {} - win._strings = MagicMock() - - # Set cancelled before the worker runs - win._login_cancelled.set() - - # Create a socket that will fail (simulating closed socket) - sock_path = tmp_path / "greetd.sock" - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(str(sock_path)) - server.listen(1) - - user = User(username="dom", uid=1000, gecos="Dominik", home=Path("/home/dom"), shell="/bin/zsh") - - with patch("moongreet.greeter.GLib.idle_add") as mock_idle: - win._login_worker(user, "pw", MagicMock(exec_cmd="niri-session"), str(sock_path)) - - # Should NOT have scheduled any error callback - for call in mock_idle.call_args_list: - func = call[0][0] - assert func != win._on_login_error, "Worker should not show error when cancelled" - assert func != win._on_login_auth_error, "Worker should not show auth error when cancelled" - - server.close() - - -class TestFaillockWarning: - """Tests for the faillock warning message logic.""" - - def test_no_warning_on_zero_attempts(self) -> None: - strings = load_strings("de") - assert faillock_warning(0, strings) is None - - def test_no_warning_on_first_attempt(self) -> None: - strings = load_strings("de") - assert faillock_warning(1, strings) is None - - def test_warning_on_second_attempt(self) -> None: - strings = load_strings("de") - warning = faillock_warning(2, strings) - assert warning is not None - assert "1" in warning # 1 Versuch übrig - - def test_warning_on_third_attempt(self) -> None: - strings = load_strings("de") - warning = faillock_warning(3, strings) - assert warning is not None - assert warning == strings.faillock_locked - - def test_warning_beyond_max_attempts(self) -> None: - strings = load_strings("de") - warning = faillock_warning(4, strings) - assert warning is not None - assert warning == strings.faillock_locked - - def test_max_attempts_constant_is_three(self) -> None: - assert FAILLOCK_MAX_ATTEMPTS == 3 - - -class TestLastUser: - """Tests for saving and loading the last logged-in user.""" - - def test_save_and_load_last_user(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - cache_path = tmp_path / "cache" / "moongreet" / "last-user" - monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path) - - from moongreet.greeter import GreeterWindow - GreeterWindow._save_last_user("dominik") - - assert cache_path.exists() - assert cache_path.read_text() == "dominik" - - result = GreeterWindow._load_last_user() - assert result == "dominik" - - def test_load_last_user_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - cache_path = tmp_path / "nonexistent" / "last-user" - monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path) - - from moongreet.greeter import GreeterWindow - result = GreeterWindow._load_last_user() - assert result is None - - def test_load_last_user_rejects_oversized_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - cache_path = tmp_path / "last-user" - cache_path.write_text("a" * 300) - monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path) - - from moongreet.greeter import GreeterWindow - result = GreeterWindow._load_last_user() - assert result is None - - def test_load_last_user_rejects_invalid_characters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - cache_path = tmp_path / "last-user" - cache_path.write_text("../../etc/passwd") - monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path) - - from moongreet.greeter import GreeterWindow - result = GreeterWindow._load_last_user() - assert result is None - - -class TestLastSession: - """Tests for saving and loading the last session per user.""" - - def test_save_and_load_last_session(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path) - - from moongreet.greeter import GreeterWindow - GreeterWindow._save_last_session("dominik", "Niri") - - session_file = tmp_path / "dominik" - assert session_file.exists() - assert session_file.read_text() == "Niri" - - result = GreeterWindow._load_last_session("dominik") - assert result == "Niri" - - def test_load_last_session_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path) - - from moongreet.greeter import GreeterWindow - result = GreeterWindow._load_last_session("nobody") - assert result is None - - def test_load_last_session_rejects_oversized_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path) - (tmp_path / "dominik").write_text("A" * 300) - - from moongreet.greeter import GreeterWindow - result = GreeterWindow._load_last_session("dominik") - assert result is None - - def test_save_last_session_validates_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Usernames with path traversal should not create files outside the cache dir.""" - monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path) - - from moongreet.greeter import GreeterWindow - GreeterWindow._save_last_session("../../etc/evil", "Niri") - - # Should not have created any file - assert not (tmp_path / "../../etc/evil").exists() - - def test_regex_rejects_dot_dot_username(self) -> None: - """Username '..' must not pass VALID_USERNAME validation.""" - from moongreet.greeter import VALID_USERNAME - assert VALID_USERNAME.match("..") is None - - def test_regex_rejects_dot_username(self) -> None: - """Username '.' must not pass VALID_USERNAME validation.""" - from moongreet.greeter import VALID_USERNAME - assert VALID_USERNAME.match(".") is None - - def test_regex_allows_dot_in_middle(self) -> None: - """Usernames like 'first.last' must still be valid.""" - from moongreet.greeter import VALID_USERNAME - assert VALID_USERNAME.match("first.last") is not None - - def test_regex_rejects_leading_dot(self) -> None: - """Usernames starting with '.' are rejected (hidden files).""" - from moongreet.greeter import VALID_USERNAME - assert VALID_USERNAME.match(".hidden") is None diff --git a/tests/test_ipc.py b/tests/test_ipc.py deleted file mode 100644 index 5d105f0..0000000 --- a/tests/test_ipc.py +++ /dev/null @@ -1,251 +0,0 @@ -# ABOUTME: Tests for greetd IPC protocol — socket communication with length-prefixed JSON. -# ABOUTME: Uses mock sockets to verify message encoding/decoding and greetd request types. - -import json -import struct -import socket -from unittest.mock import MagicMock, patch - -import pytest - -from moongreet.ipc import ( - send_message, - recv_message, - create_session, - post_auth_response, - start_session, - cancel_session, -) - - -class FakeSocket: - """A fake socket that records sent data and provides canned receive data.""" - - def __init__(self, recv_data: bytes = b""): - self.sent = bytearray() - self._recv_data = recv_data - self._recv_offset = 0 - - def sendall(self, data: bytes) -> None: - self.sent.extend(data) - - def recv(self, n: int, flags: int = 0) -> bytes: - chunk = self._recv_data[self._recv_offset : self._recv_offset + n] - self._recv_offset += n - return chunk - - @classmethod - def with_response(cls, response: dict) -> "FakeSocket": - """Create a FakeSocket pre-loaded with a length-prefixed JSON response.""" - payload = json.dumps(response).encode("utf-8") - data = struct.pack("=I", len(payload)) + payload - return cls(recv_data=data) - - -class FragmentingSocket: - """A fake socket that delivers data in small chunks to simulate fragmentation.""" - - def __init__(self, data: bytes, chunk_size: int = 3): - self.sent = bytearray() - self._data = data - self._offset = 0 - self._chunk_size = chunk_size - - def sendall(self, data: bytes) -> None: - self.sent.extend(data) - - def recv(self, n: int, flags: int = 0) -> bytes: - available = min(n, self._chunk_size, len(self._data) - self._offset) - if available <= 0: - return b"" - chunk = self._data[self._offset : self._offset + available] - self._offset += available - return chunk - - -class TestSendMessage: - """Tests for encoding and sending length-prefixed JSON messages.""" - - def test_sends_length_prefixed_json(self) -> None: - sock = FakeSocket() - msg = {"type": "create_session", "username": "testuser"} - - send_message(sock, msg) - - payload = json.dumps(msg).encode("utf-8") - expected = struct.pack("=I", len(payload)) + payload - assert bytes(sock.sent) == expected - - def test_sends_empty_dict(self) -> None: - sock = FakeSocket() - - send_message(sock, {}) - - payload = json.dumps({}).encode("utf-8") - expected = struct.pack("=I", len(payload)) + payload - assert bytes(sock.sent) == expected - - def test_sends_nested_message(self) -> None: - sock = FakeSocket() - msg = {"type": "post_auth_message_response", "response": "secret123"} - - send_message(sock, msg) - - # Verify the payload is correctly length-prefixed - length_bytes = bytes(sock.sent[:4]) - length = struct.unpack("=I", length_bytes)[0] - decoded = json.loads(sock.sent[4:]) - assert length == len(json.dumps(msg).encode("utf-8")) - assert decoded == msg - - - def test_rejects_oversized_payload(self) -> None: - sock = FakeSocket() - msg = {"type": "huge", "data": "x" * 100000} - - with pytest.raises(ValueError, match="Payload too large"): - send_message(sock, msg) - - -class TestRecvMessage: - """Tests for receiving and decoding length-prefixed JSON messages.""" - - def test_receives_valid_message(self) -> None: - response = {"type": "success"} - sock = FakeSocket.with_response(response) - - result = recv_message(sock) - - assert result == response - - def test_receives_complex_message(self) -> None: - response = { - "type": "auth_message", - "auth_message_type": "secret", - "auth_message": "Password:", - } - sock = FakeSocket.with_response(response) - - result = recv_message(sock) - - assert result == response - - def test_raises_on_empty_recv(self) -> None: - sock = FakeSocket(recv_data=b"") - - with pytest.raises(ConnectionError): - recv_message(sock) - - def test_receives_fragmented_data(self) -> None: - """recv() may return fewer bytes than requested — must loop.""" - response = {"type": "success"} - payload = json.dumps(response).encode("utf-8") - data = struct.pack("=I", len(payload)) + payload - sock = FragmentingSocket(data, chunk_size=3) - - result = recv_message(sock) - - assert result == response - - def test_rejects_oversized_payload(self) -> None: - """Payloads exceeding the size limit must be rejected.""" - header = struct.pack("=I", 10_000_000) - sock = FakeSocket(recv_data=header) - - with pytest.raises(ConnectionError, match="too large"): - recv_message(sock) - - -class TestCreateSession: - """Tests for the create_session greetd request.""" - - def test_sends_create_session_request(self) -> None: - response = { - "type": "auth_message", - "auth_message_type": "secret", - "auth_message": "Password:", - } - sock = FakeSocket.with_response(response) - - result = create_session(sock, "dominik") - - # Verify sent message - length = struct.unpack("=I", bytes(sock.sent[:4]))[0] - sent_msg = json.loads(sock.sent[4 : 4 + length]) - assert sent_msg == {"type": "create_session", "username": "dominik"} - assert result == response - - -class TestPostAuthResponse: - """Tests for posting authentication responses (passwords).""" - - def test_sends_password_response(self) -> None: - response = {"type": "success"} - sock = FakeSocket.with_response(response) - - result = post_auth_response(sock, "mypassword") - - length = struct.unpack("=I", bytes(sock.sent[:4]))[0] - sent_msg = json.loads(sock.sent[4 : 4 + length]) - assert sent_msg == { - "type": "post_auth_message_response", - "response": "mypassword", - } - assert result == response - - def test_sends_none_response(self) -> None: - """For auth types that don't require a response.""" - response = {"type": "success"} - sock = FakeSocket.with_response(response) - - result = post_auth_response(sock, None) - - length = struct.unpack("=I", bytes(sock.sent[:4]))[0] - sent_msg = json.loads(sock.sent[4 : 4 + length]) - assert sent_msg == { - "type": "post_auth_message_response", - "response": None, - } - - -class TestStartSession: - """Tests for starting a session after authentication.""" - - def test_sends_start_session_request(self) -> None: - response = {"type": "success"} - sock = FakeSocket.with_response(response) - - result = start_session(sock, ["Hyprland"]) - - length = struct.unpack("=I", bytes(sock.sent[:4]))[0] - sent_msg = json.loads(sock.sent[4 : 4 + length]) - assert sent_msg == {"type": "start_session", "cmd": ["Hyprland"]} - assert result == response - - def test_sends_multi_arg_command(self) -> None: - response = {"type": "success"} - sock = FakeSocket.with_response(response) - - result = start_session(sock, ["sway", "--config", "/etc/sway/config"]) - - length = struct.unpack("=I", bytes(sock.sent[:4]))[0] - sent_msg = json.loads(sock.sent[4 : 4 + length]) - assert sent_msg == { - "type": "start_session", - "cmd": ["sway", "--config", "/etc/sway/config"], - } - - -class TestCancelSession: - """Tests for cancelling an in-progress session.""" - - def test_sends_cancel_session_request(self) -> None: - response = {"type": "success"} - sock = FakeSocket.with_response(response) - - result = cancel_session(sock) - - length = struct.unpack("=I", bytes(sock.sent[:4]))[0] - sent_msg = json.loads(sock.sent[4 : 4 + length]) - assert sent_msg == {"type": "cancel_session"} - assert result == response diff --git a/tests/test_power.py b/tests/test_power.py deleted file mode 100644 index 6f90928..0000000 --- a/tests/test_power.py +++ /dev/null @@ -1,61 +0,0 @@ -# ABOUTME: Tests for power actions — reboot and shutdown via loginctl. -# ABOUTME: Uses mocking to avoid actually calling system commands. - -import subprocess -from unittest.mock import patch, call - -import pytest - -from moongreet.power import reboot, shutdown, POWER_TIMEOUT - - -class TestReboot: - """Tests for the reboot power action.""" - - @patch("moongreet.power.subprocess.run") - def test_calls_loginctl_reboot(self, mock_run) -> None: - reboot() - - mock_run.assert_called_once_with( - ["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moongreet.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl") - - with pytest.raises(subprocess.CalledProcessError): - reboot() - - @patch("moongreet.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - reboot() - - -class TestShutdown: - """Tests for the shutdown power action.""" - - @patch("moongreet.power.subprocess.run") - def test_calls_loginctl_poweroff(self, mock_run) -> None: - shutdown() - - mock_run.assert_called_once_with( - ["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moongreet.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl") - - with pytest.raises(subprocess.CalledProcessError): - shutdown() - - @patch("moongreet.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - shutdown() diff --git a/tests/test_sessions.py b/tests/test_sessions.py deleted file mode 100644 index 3e94858..0000000 --- a/tests/test_sessions.py +++ /dev/null @@ -1,104 +0,0 @@ -# ABOUTME: Tests for session detection — parsing .desktop files from wayland/xsessions dirs. -# ABOUTME: Uses temporary directories to simulate session file locations. - -from pathlib import Path - -import pytest - -from moongreet.sessions import Session, get_sessions - - -class TestGetSessions: - """Tests for discovering available sessions from .desktop files.""" - - def test_finds_wayland_session(self, tmp_path: Path) -> None: - wayland_dir = tmp_path / "wayland-sessions" - wayland_dir.mkdir() - desktop = wayland_dir / "hyprland.desktop" - desktop.write_text( - "[Desktop Entry]\n" - "Name=Hyprland\n" - "Exec=Hyprland\n" - "Type=Application\n" - ) - - sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) - - assert len(sessions) == 1 - assert sessions[0].name == "Hyprland" - assert sessions[0].exec_cmd == "Hyprland" - assert sessions[0].session_type == "wayland" - - def test_finds_xsession(self, tmp_path: Path) -> None: - x_dir = tmp_path / "xsessions" - x_dir.mkdir() - desktop = x_dir / "i3.desktop" - desktop.write_text( - "[Desktop Entry]\n" - "Name=i3\n" - "Exec=i3\n" - "Type=Application\n" - ) - - sessions = get_sessions(wayland_dirs=[], xsession_dirs=[x_dir]) - - assert len(sessions) == 1 - assert sessions[0].session_type == "x11" - - def test_finds_sessions_from_multiple_dirs(self, tmp_path: Path) -> None: - wayland_dir = tmp_path / "wayland-sessions" - wayland_dir.mkdir() - (wayland_dir / "sway.desktop").write_text( - "[Desktop Entry]\nName=Sway\nExec=sway\n" - ) - - x_dir = tmp_path / "xsessions" - x_dir.mkdir() - (x_dir / "openbox.desktop").write_text( - "[Desktop Entry]\nName=Openbox\nExec=openbox-session\n" - ) - - sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[x_dir]) - - names = {s.name for s in sessions} - assert names == {"Sway", "Openbox"} - - def test_returns_empty_for_no_sessions(self, tmp_path: Path) -> None: - empty = tmp_path / "empty" - - sessions = get_sessions(wayland_dirs=[empty], xsession_dirs=[empty]) - - assert sessions == [] - - def test_skips_files_without_name(self, tmp_path: Path) -> None: - wayland_dir = tmp_path / "wayland-sessions" - wayland_dir.mkdir() - (wayland_dir / "broken.desktop").write_text( - "[Desktop Entry]\nExec=something\n" - ) - - sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) - - assert sessions == [] - - def test_skips_files_without_exec(self, tmp_path: Path) -> None: - wayland_dir = tmp_path / "wayland-sessions" - wayland_dir.mkdir() - (wayland_dir / "noexec.desktop").write_text( - "[Desktop Entry]\nName=NoExec\n" - ) - - sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) - - assert sessions == [] - - def test_handles_exec_with_arguments(self, tmp_path: Path) -> None: - wayland_dir = tmp_path / "wayland-sessions" - wayland_dir.mkdir() - (wayland_dir / "sway.desktop").write_text( - "[Desktop Entry]\nName=Sway\nExec=sway --config /etc/sway/config\n" - ) - - sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) - - assert sessions[0].exec_cmd == "sway --config /etc/sway/config" diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 09bae44..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,215 +0,0 @@ -# ABOUTME: Tests for user detection — parsing /etc/passwd, avatar lookup, GTK theme reading. -# ABOUTME: Uses temporary files and mocking to avoid system dependencies. - -from pathlib import Path -from dataclasses import dataclass - -import pytest - -from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme - - -class TestGetUsers: - """Tests for parsing /etc/passwd to find login users.""" - - def test_returns_users_in_uid_range(self, tmp_path: Path) -> None: - passwd = tmp_path / "passwd" - passwd.write_text( - "root:x:0:0:root:/root:/bin/bash\n" - "nobody:x:65534:65534:Nobody:/:/usr/bin/nologin\n" - "dominik:x:1000:1000:Dominik:/home/dominik:/bin/zsh\n" - "testuser:x:1001:1001:Test User:/home/testuser:/bin/bash\n" - ) - - users = get_users(passwd_path=passwd) - - assert len(users) == 2 - assert users[0].username == "dominik" - assert users[0].uid == 1000 - assert users[0].gecos == "Dominik" - assert users[0].home == Path("/home/dominik") - assert users[1].username == "testuser" - - def test_excludes_nologin_shells(self, tmp_path: Path) -> None: - passwd = tmp_path / "passwd" - passwd.write_text( - "systemuser:x:1000:1000:System:/home/system:/usr/sbin/nologin\n" - "falseuser:x:1001:1001:False:/home/false:/bin/false\n" - "realuser:x:1002:1002:Real:/home/real:/bin/bash\n" - ) - - users = get_users(passwd_path=passwd) - - assert len(users) == 1 - assert users[0].username == "realuser" - - def test_returns_empty_for_no_matching_users(self, tmp_path: Path) -> None: - passwd = tmp_path / "passwd" - passwd.write_text("root:x:0:0:root:/root:/bin/bash\n") - - users = get_users(passwd_path=passwd) - - assert users == [] - - def test_handles_missing_gecos_field(self, tmp_path: Path) -> None: - passwd = tmp_path / "passwd" - passwd.write_text("user:x:1000:1000::/home/user:/bin/bash\n") - - users = get_users(passwd_path=passwd) - - assert len(users) == 1 - assert users[0].gecos == "" - assert users[0].display_name == "user" - - def test_skips_invalid_uid(self, tmp_path: Path) -> None: - """Corrupt /etc/passwd with non-numeric UID should not crash.""" - passwd = tmp_path / "passwd" - passwd.write_text( - "corrupt:x:NOTANUMBER:1000:Corrupt:/home/corrupt:/bin/bash\n" - "valid:x:1000:1000:Valid:/home/valid:/bin/bash\n" - ) - - users = get_users(passwd_path=passwd) - - assert len(users) == 1 - assert users[0].username == "valid" - - def test_skips_username_with_slash(self, tmp_path: Path) -> None: - """Usernames containing path separators should be rejected.""" - passwd = tmp_path / "passwd" - passwd.write_text( - "../evil:x:1000:1000:Evil:/home/evil:/bin/bash\n" - "normal:x:1001:1001:Normal:/home/normal:/bin/bash\n" - ) - - users = get_users(passwd_path=passwd) - - assert len(users) == 1 - assert users[0].username == "normal" - - -class TestGetAvatarPath: - """Tests for avatar file lookup.""" - - def test_finds_accountsservice_icon(self, tmp_path: Path) -> None: - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - avatar = icons_dir / "dominik" - avatar.write_bytes(b"PNG") - - result = get_avatar_path("dominik", accountsservice_dir=icons_dir) - - assert result == avatar - - def test_falls_back_to_dot_face(self, tmp_path: Path) -> None: - home = tmp_path / "home" / "dominik" - home.mkdir(parents=True) - face = home / ".face" - face.write_bytes(b"PNG") - empty_icons = tmp_path / "no_icons" - - result = get_avatar_path( - "dominik", accountsservice_dir=empty_icons, home_dir=home - ) - - assert result == face - - def test_ignores_symlinked_face(self, tmp_path: Path) -> None: - """~/.face as symlink should be ignored to prevent traversal.""" - home = tmp_path / "home" / "attacker" - home.mkdir(parents=True) - target = tmp_path / "secret.txt" - target.write_text("sensitive data") - face = home / ".face" - face.symlink_to(target) - empty_icons = tmp_path / "no_icons" - - result = get_avatar_path( - "attacker", accountsservice_dir=empty_icons, home_dir=home - ) - - assert result is None - - def test_returns_none_when_no_avatar(self, tmp_path: Path) -> None: - empty_icons = tmp_path / "no_icons" - home = tmp_path / "home" / "nobody" - - result = get_avatar_path( - "nobody", accountsservice_dir=empty_icons, home_dir=home - ) - - assert result is None - - -class TestGetUserGtkTheme: - """Tests for reading GTK theme from user's settings.ini.""" - - def test_reads_theme_from_settings(self, tmp_path: Path) -> None: - gtk_dir = tmp_path / ".config" / "gtk-4.0" - gtk_dir.mkdir(parents=True) - settings = gtk_dir / "settings.ini" - settings.write_text( - "[Settings]\n" - "gtk-theme-name=Adwaita-dark\n" - "gtk-icon-theme-name=Papirus\n" - ) - - result = get_user_gtk_theme(config_dir=gtk_dir) - - assert result == "Adwaita-dark" - - def test_returns_none_when_no_settings(self, tmp_path: Path) -> None: - gtk_dir = tmp_path / "nonexistent" - - result = get_user_gtk_theme(config_dir=gtk_dir) - - assert result is None - - def test_returns_none_when_no_theme_key(self, tmp_path: Path) -> None: - gtk_dir = tmp_path / ".config" / "gtk-4.0" - gtk_dir.mkdir(parents=True) - settings = gtk_dir / "settings.ini" - settings.write_text("[Settings]\ngtk-icon-theme-name=Papirus\n") - - result = get_user_gtk_theme(config_dir=gtk_dir) - - assert result is None - - def test_returns_none_for_corrupt_settings_ini(self, tmp_path: Path) -> None: - """settings.ini without section header should not crash.""" - gtk_dir = tmp_path / ".config" / "gtk-4.0" - gtk_dir.mkdir(parents=True) - settings = gtk_dir / "settings.ini" - settings.write_text("gtk-theme-name=Adwaita-dark\n") - - result = get_user_gtk_theme(config_dir=gtk_dir) - - assert result is None - - def test_passes_theme_with_special_characters(self, tmp_path: Path) -> None: - """Theme names with special characters are passed through to GTK.""" - gtk_dir = tmp_path / ".config" / "gtk-4.0" - gtk_dir.mkdir(parents=True) - settings = gtk_dir / "settings.ini" - settings.write_text( - "[Settings]\ngtk-theme-name=catppuccin-mocha-lavender-standard+default\n" - ) - - result = get_user_gtk_theme(config_dir=gtk_dir) - - assert result == "catppuccin-mocha-lavender-standard+default" - - def test_ignores_symlinked_accountsservice_icon(self, tmp_path: Path) -> None: - """AccountsService icon as symlink should be ignored to prevent traversal.""" - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - target = tmp_path / "secret.txt" - target.write_text("sensitive data") - icon = icons_dir / "attacker" - icon.symlink_to(target) - - result = get_avatar_path( - "attacker", accountsservice_dir=icons_dir - ) - - assert result is None diff --git a/uv.lock b/uv.lock deleted file mode 100644 index aeaae93..0000000 --- a/uv.lock +++ /dev/null @@ -1,45 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "moongreet" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "pygobject" }, -] - -[package.metadata] -requires-dist = [{ name = "pygobject", specifier = ">=3.46" }] - -[[package]] -name = "pycairo" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, - { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, - { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, - { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, - { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, - { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, - { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, -] - -[[package]] -name = "pygobject" -version = "3.56.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycairo" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }