diff --git a/.gitignore b/.gitignore index 092eb08..96f8630 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,4 @@ -__pycache__/ -*.pyc -.venv/ -*.egg-info/ -dist/ -build/ -.pytest_cache/ +/target # makepkg build artifacts pkg/src/ diff --git a/CLAUDE.md b/CLAUDE.md index 19eeb82..97f725f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,50 +4,53 @@ ## Projekt -Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Python + GTK4 + ext-session-lock-v1. -Teil des Moonarch-Ökosystems. Visuell und architektonisch inspiriert von Moongreet. +Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Rust + gtk4-rs + ext-session-lock-v1. +Teil des Moonarch-Ökosystems. ## Tech-Stack -- Python 3.11+, PyGObject (GTK 4.0) -- Gtk4SessionLock (ext-session-lock-v1) für protokoll-garantiertes Screen-Locking -- PAM-Authentifizierung via ctypes-Wrapper (libpam.so.0) -- fprintd D-Bus Integration (Gio.DBusProxy) für Fingerabdruck-Unlock -- pytest für Tests +- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22 +- gtk4-session-lock 0.4 für ext-session-lock-v1 Protokoll +- PAM-Authentifizierung via Raw FFI (libc, libpam.so) +- fprintd D-Bus Integration (gio::DBusProxy) für Fingerabdruck-Unlock +- zeroize für sicheres Passwort-Wiping +- `cargo test` für Unit-Tests ## Projektstruktur -- `src/moonlock/` — Quellcode -- `src/moonlock/data/` — Package-Assets (Default-Avatar, Icons) -- `tests/` — pytest Tests -- `config/` — Beispiel-Konfigurationsdateien +- `src/` — Rust-Quellcode (main.rs, lockscreen.rs, auth.rs, fingerprint.rs, config.rs, i18n.rs, users.rs, power.rs) +- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg) +- `config/` — PAM-Konfiguration und Beispiel-Config ## Kommandos ```bash # Tests ausführen -uv run pytest tests/ -v +cargo test -# Typ-Checks -uv run pyright src/ +# Release-Build +cargo build --release # Lockscreen starten (zum Testen) -uv run moonlock +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock ``` ## Architektur -- `auth.py` — PAM-Authentifizierung via ctypes (libpam.so.0) -- `fingerprint.py` — fprintd D-Bus Listener (Gio.DBusProxy, async im GLib-Mainloop) -- `users.py` — Aktuellen User ermitteln, Avatar laden -- `power.py` — Reboot/Shutdown via loginctl -- `i18n.py` — Locale-Erkennung und String-Tabellen (DE/EN) -- `lockscreen.py` — GTK4 UI (Avatar, Passwort-Entry, Fingerprint-Indikator, Power-Buttons) -- `main.py` — Entry Point, GTK App, Session Lock Setup (ext-session-lock-v1) +- `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, Zeroizing>) +- `fingerprint.rs` — fprintd D-Bus Listener (gio::DBusProxy, VerifyStatus Signal-Handling) +- `users.rs` — Aktuellen User via nix getuid, Avatar-Loading mit Symlink-Rejection +- `power.rs` — Reboot/Shutdown via /usr/bin/loginctl +- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN) +- `config.rs` — TOML-Config (background_path, fingerprint_enabled) + Wallpaper-Fallback +- `lockscreen.rs` — GTK4 UI, PAM-Auth via gio::spawn_blocking, Fingerprint-Indikator, Power-Confirm +- `main.rs` — Entry Point, Panic-Hook, Root-Check, ext-session-lock-v1, Multi-Monitor ## Sicherheit - ext-session-lock-v1 garantiert: Compositor sperrt alle Surfaces bei lock() -- Bei Crash bleibt Screen schwarz (nicht offen) -- Passwort wird nach Verwendung im Speicher überschrieben -- Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche Auth +- Panic-Hook: Bei Crash wird geloggt, aber NIEMALS unlock() aufgerufen — Screen bleibt schwarz +- Passwort: Zeroizing> für sicheres Wiping nach PAM-Callback +- Root-Check: Exit mit Fehler wenn als root gestartet +- Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche PAM-Auth oder Fingerprint +- GResource-Bundle: CSS/Assets in der Binary kompiliert diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..59bc404 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1334 @@ +# 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 = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[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-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-session-lock" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2267b4acc07f3125a5cea6d27254a737125a722de4fcc45aa3ad1f1f543af35" +dependencies = [ + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-session-lock-sys", + "libc", +] + +[[package]] +name = "gtk4-session-lock-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141184b5b7b5951ae55012c338a0f681ee58af63b5d4ff7e3166c6f2229b1f9b" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[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 = "moonlock" +version = "0.4.0" +dependencies = [ + "env_logger", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "glib-build-tools", + "gtk4", + "gtk4-session-lock", + "libc", + "log", + "nix", + "serde", + "tempfile", + "toml 0.8.23", + "zeroize", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +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 = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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..019d92f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "moonlock" +version = "0.4.0" +edition = "2024" +description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" +license = "MIT" + +[dependencies] +gtk4 = { version = "0.11", features = ["v4_10"] } +gtk4-session-lock = { version = "0.4", features = ["v1_1"] } +glib = "0.22" +gdk4 = "0.11" +gdk-pixbuf = "0.22" +gio = "0.22" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +nix = { version = "0.29", features = ["user"] } +zeroize = { version = "1", features = ["derive"] } +libc = "0.2" +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 new file mode 100644 index 0000000..03096a3 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Moonlock + +A secure Wayland lockscreen with GTK4, PAM authentication and fingerprint support. +Part of the Moonarch ecosystem. + +## Features + +- **ext-session-lock-v1** — Protocol-guaranteed screen locking (compositor keeps screen locked on crash) +- **PAM authentication** — Uses system PAM stack (`/etc/pam.d/moonlock`) +- **Fingerprint unlock** — fprintd D-Bus integration (optional) +- **Multi-monitor** — Lockscreen on every monitor +- **i18n** — German and English (auto-detected) +- **Faillock warning** — UI counter + system pam_faillock +- **Panic safety** — Panic hook logs but never unlocks +- **Password wiping** — Zeroize on drop + +## Requirements + +- GTK 4 +- gtk4-session-lock (ext-session-lock-v1 support) +- PAM (`/etc/pam.d/moonlock`) +- Optional: fprintd for fingerprint support + +## Building + +```bash +cargo build --release +``` + +## Installation + +```bash +# Install binary +sudo install -Dm755 target/release/moonlock /usr/bin/moonlock + +# Install PAM config +sudo install -Dm644 config/moonlock-pam /etc/pam.d/moonlock + +# Optional: Install example config +sudo install -Dm644 config/moonlock.toml.example /etc/moonlock/moonlock.toml.example +``` + +## Configuration + +Create `/etc/moonlock/moonlock.toml` or `~/.config/moonlock/moonlock.toml`: + +```toml +background_path = "/usr/share/wallpapers/moon.jpg" +fingerprint_enabled = true +``` + +## Usage + +Typically launched via keybind in your Wayland compositor: + +``` +# Niri keybind example +binds { + Mod+L { spawn "moonlock"; } +} +``` + +## Development + +```bash +cargo test +cargo build --release +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock +``` + +## License + +MIT diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..65b47e6 --- /dev/null +++ b/build.rs @@ -0,0 +1,13 @@ +// 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", + "moonlock.gresource", + ); + + // Link libpam for PAM authentication + println!("cargo:rustc-link-lib=pam"); +} diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index 70a1942..f66ff12 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -4,24 +4,21 @@ # Maintainer: Dominik Kressler pkgname=moonlock-git -pkgver=0.2.0.r0.g7cee4f4 +pkgver=0.4.0.r0.g0000000 pkgrel=1 pkgdesc="A secure Wayland lockscreen with GTK4, PAM and fingerprint support" -arch=('any') +arch=('x86_64') url="https://gitea.moonarch.de/nevaforget/moonlock" license=('MIT') depends=( - 'python' - 'python-gobject' 'gtk4' 'gtk4-layer-shell' + 'gtk4-session-lock' 'pam' ) makedepends=( 'git' - 'python-build' - 'python-installer' - 'python-hatchling' + 'cargo' ) optdepends=( 'fprintd: fingerprint authentication support' @@ -38,13 +35,12 @@ pkgver() { build() { cd "$srcdir/moonlock" - rm -rf dist/ - python -m build --wheel --no-isolation + cargo build --release --locked } package() { cd "$srcdir/moonlock" - python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm755 target/release/moonlock "$pkgdir/usr/bin/moonlock" # PAM configuration install -Dm644 config/moonlock-pam "$pkgdir/etc/pam.d/moonlock" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 4aeae53..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "moonlock" -version = "0.3.0" -description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" -requires-python = ">=3.11" -license = "MIT" -dependencies = [ - "PyGObject>=3.46", -] - -[project.scripts] -moonlock = "moonlock.main:main" - -[tool.hatch.build.targets.wheel] -packages = ["src/moonlock"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["src"] - -[tool.pyright] -pythonVersion = "3.11" -pythonPlatform = "Linux" -venvPath = "." -venv = ".venv" -typeCheckingMode = "standard" diff --git a/src/moonlock/data/default-avatar.svg b/resources/default-avatar.svg similarity index 100% rename from src/moonlock/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..84f8548 --- /dev/null +++ b/resources/resources.gresource.xml @@ -0,0 +1,8 @@ + + + + style.css + wallpaper.jpg + default-avatar.svg + + diff --git a/src/moonlock/style.css b/resources/style.css similarity index 67% rename from src/moonlock/style.css rename to resources/style.css index 690e14f..9fb680c 100644 --- a/src/moonlock/style.css +++ b/resources/style.css @@ -6,6 +6,12 @@ window.lockscreen { background-color: #1a1a2e; background-size: cover; background-position: center; + opacity: 0; + transition: opacity 350ms ease-in; +} + +window.lockscreen.visible { + opacity: 1; } /* Central login area */ @@ -59,6 +65,38 @@ window.lockscreen { color: #ff6b6b; } +/* Confirmation prompt */ +.confirm-label { + font-size: 16px; + color: white; + margin-bottom: 4px; +} + +.confirm-yes { + padding: 8px 24px; + border-radius: 8px; + background-color: @error_color; + color: @theme_bg_color; + border: none; + font-weight: bold; +} + +.confirm-yes:hover { + background-color: lighter(@error_color); +} + +.confirm-no { + padding: 8px 24px; + border-radius: 8px; + background-color: @theme_unfocused_bg_color; + color: @theme_fg_color; + border: none; +} + +.confirm-no:hover { + background-color: @theme_selected_bg_color; +} + /* Power buttons on the bottom right */ .power-button { min-width: 48px; diff --git a/src/moonlock/data/wallpaper.jpg b/resources/wallpaper.jpg similarity index 100% rename from src/moonlock/data/wallpaper.jpg rename to resources/wallpaper.jpg diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6a16910 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,190 @@ +// ABOUTME: PAM authentication via raw FFI wrapper around libpam.so. +// ABOUTME: Provides authenticate(username, password) for the lockscreen with secure password wiping. + +use std::ffi::CString; +use std::ptr; +use zeroize::Zeroizing; + +// PAM return codes +const PAM_SUCCESS: i32 = 0; + +/// PAM message structure (pam_message). +#[repr(C)] +struct PamMessage { + msg_style: libc::c_int, + msg: *const libc::c_char, +} + +/// PAM response structure (pam_response). +#[repr(C)] +struct PamResponse { + resp: *mut libc::c_char, + resp_retcode: libc::c_int, +} + +/// PAM conversation callback type. +type PamConvFn = unsafe extern "C" fn( + num_msg: libc::c_int, + msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata_ptr: *mut libc::c_void, +) -> libc::c_int; + +/// PAM conversation structure (pam_conv). +#[repr(C)] +struct PamConv { + conv: PamConvFn, + appdata_ptr: *mut libc::c_void, +} + +// libpam function declarations +unsafe extern "C" { + fn pam_start( + service_name: *const libc::c_char, + user: *const libc::c_char, + pam_conversation: *const PamConv, + pamh: *mut *mut libc::c_void, + ) -> libc::c_int; + + fn pam_authenticate(pamh: *mut libc::c_void, flags: libc::c_int) -> libc::c_int; + + fn pam_acct_mgmt(pamh: *mut libc::c_void, flags: libc::c_int) -> libc::c_int; + + fn pam_end(pamh: *mut libc::c_void, pam_status: libc::c_int) -> libc::c_int; +} + +/// PAM conversation callback — provides the password to PAM. +/// +/// # Safety +/// Called by libpam during authentication. Allocates response memory with calloc/strdup +/// which PAM will free. The appdata_ptr must point to a valid CString (the password). +unsafe extern "C" fn pam_conv_callback( + num_msg: libc::c_int, + _msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata_ptr: *mut libc::c_void, +) -> libc::c_int { + // Safety: appdata_ptr was set to a valid *const CString in authenticate() + let password = appdata_ptr as *const CString; + if password.is_null() { + return 7; // PAM_AUTH_ERR + } + + // Safety: calloc returns zeroed memory for num_msg PamResponse structs. + // PAM owns this memory and will free() it. + let resp_array = libc::calloc( + num_msg as libc::size_t, + std::mem::size_of::() as libc::size_t, + ) as *mut PamResponse; + + if resp_array.is_null() { + return 7; // PAM_AUTH_ERR + } + + for i in 0..num_msg as isize { + // Safety: strdup allocates with malloc — PAM will free() the resp strings. + // We dereference password which is valid for the lifetime of authenticate(). + let resp_ptr = resp_array.offset(i); + (*resp_ptr).resp = libc::strdup((*password).as_ptr()); + (*resp_ptr).resp_retcode = 0; + } + + // Safety: resp is a valid pointer provided by PAM + *resp = resp_array; + PAM_SUCCESS +} + +/// Authenticate a user via PAM. +/// +/// Returns true on success, false on authentication failure. +/// The password is wiped from memory after use via zeroize. +pub fn authenticate(username: &str, password: &str) -> bool { + // Use Zeroizing to ensure password bytes are wiped on drop + let password_bytes = Zeroizing::new(password.as_bytes().to_vec()); + let password_cstr = match CString::new(password_bytes.as_slice()) { + Ok(c) => c, + Err(_) => return false, // Password contains null byte + }; + + let service = match CString::new("moonlock") { + Ok(c) => c, + Err(_) => return false, + }; + + let username_cstr = match CString::new(username) { + Ok(c) => c, + Err(_) => return false, + }; + + let conv = PamConv { + conv: pam_conv_callback, + appdata_ptr: &password_cstr as *const CString as *mut libc::c_void, + }; + + let mut handle: *mut libc::c_void = ptr::null_mut(); + + // Safety: All pointers are valid CStrings that outlive the pam_start call. + // handle is an output parameter that PAM will set. + let ret = unsafe { + pam_start( + service.as_ptr(), + username_cstr.as_ptr(), + &conv, + &mut handle, + ) + }; + + if ret != PAM_SUCCESS { + return false; + } + + // Safety: handle is valid after successful pam_start + let auth_ret = unsafe { pam_authenticate(handle, 0) }; + let acct_ret = if auth_ret == PAM_SUCCESS { + // Safety: handle is valid, check account restrictions + unsafe { pam_acct_mgmt(handle, 0) } + } else { + auth_ret + }; + + // Safety: handle is valid, pam_end cleans up the PAM session + unsafe { pam_end(handle, acct_ret) }; + + acct_ret == PAM_SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pam_service_name_is_valid_cstring() { + let service = CString::new("moonlock").unwrap(); + assert_eq!(service.as_bytes(), b"moonlock"); + } + + #[test] + fn password_with_null_byte_fails() { + // authenticate should return false for passwords with embedded nulls + let result = authenticate("testuser", "pass\0word"); + assert!(!result); + } + + #[test] + fn zeroizing_wipes_password() { + let password = Zeroizing::new(vec![0x41u8, 0x42, 0x43]); + let ptr = password.as_ptr(); + drop(password); + // After drop, the memory should be zeroed (though we can't reliably + // test this since the allocator may reuse the memory). This test + // verifies the Zeroizing wrapper compiles and drops correctly. + assert!(!ptr.is_null()); + } + + #[test] + fn empty_username_fails() { + // Empty username should not crash, just fail auth + let result = authenticate("", "password"); + assert!(!result); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fb11c91 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,87 @@ +// ABOUTME: Configuration loading for the lockscreen. +// ABOUTME: Reads moonlock.toml for wallpaper and fingerprint 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/moonlock"; + +fn default_config_paths() -> Vec { + let mut paths = vec![PathBuf::from("/etc/moonlock/moonlock.toml")]; + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".config/moonlock/moonlock.toml")); + } + paths +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Config { + pub background_path: Option, + #[serde(default = "default_fingerprint")] + pub fingerprint_enabled: bool, +} + +fn default_fingerprint() -> bool { true } + +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 { fingerprint_enabled: true, ..Config::default() }; + for path in paths { + if let Ok(content) = fs::read_to_string(path) { + if let Ok(parsed) = toml::from_str::(&content) { + if parsed.background_path.is_some() { merged.background_path = parsed.background_path; } + merged.fingerprint_enabled = parsed.fingerprint_enabled; + } + } + } + merged +} + +pub fn resolve_background_path(config: &Config) -> PathBuf { + resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) +} + +pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf { + if let Some(ref bg) = config.background_path { + let path = PathBuf::from(bg); + if path.is_file() { return path; } + } + if moonarch_wallpaper.is_file() { return moonarch_wallpaper.to_path_buf(); } + PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] fn default_config() { let c = Config::default(); assert!(c.background_path.is_none()); assert!(!c.fingerprint_enabled); } + #[test] fn load_default_fingerprint_true() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonlock.toml"); + fs::write(&conf, "").unwrap(); + let c = load_config(Some(&[conf])); + assert!(c.fingerprint_enabled); + } + #[test] fn load_background() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonlock.toml"); + fs::write(&conf, "background_path = \"/custom/bg.jpg\"\nfingerprint_enabled = false\n").unwrap(); + let c = load_config(Some(&[conf])); + assert_eq!(c.background_path.as_deref(), Some("/custom/bg.jpg")); + assert!(!c.fingerprint_enabled); + } + #[test] fn resolve_config_path() { + let dir = tempfile::tempdir().unwrap(); + let wp = dir.path().join("bg.jpg"); fs::write(&wp, "fake").unwrap(); + let c = Config { background_path: Some(wp.to_str().unwrap().to_string()), fingerprint_enabled: true }; + assert_eq!(resolve_background_path_with(&c, Path::new("/nonexistent")), wp); + } + #[test] fn resolve_gresource_fallback() { + let c = Config::default(); + let r = resolve_background_path_with(&c, Path::new("/nonexistent")); + assert!(r.to_str().unwrap().contains("moonlock")); + } +} diff --git a/src/fingerprint.rs b/src/fingerprint.rs new file mode 100644 index 0000000..224c053 --- /dev/null +++ b/src/fingerprint.rs @@ -0,0 +1,299 @@ +// ABOUTME: fprintd D-Bus integration for fingerprint authentication. +// ABOUTME: Provides FingerprintListener that connects to fprintd via Gio.DBusProxy. + +use gio::prelude::*; +use gtk4::gio; + +const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint"; +const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager"; +const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager"; +const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device"; + +const MAX_FP_ATTEMPTS: u32 = 10; + +/// Retry-able statuses — finger not read properly, try again. +const RETRY_STATUSES: &[&str] = &[ + "verify-swipe-too-short", + "verify-finger-not-centered", + "verify-remove-and-retry", + "verify-retry-scan", +]; + +/// Fingerprint listener state. +pub struct FingerprintListener { + device_proxy: Option, + signal_id: Option, + running: bool, + failed_attempts: u32, + on_success: Option>, + on_failure: Option>, +} + +impl FingerprintListener { + /// Create a new FingerprintListener. + /// Connects to fprintd synchronously — call before creating GTK windows. + pub fn new() -> Self { + let mut listener = FingerprintListener { + device_proxy: None, + signal_id: None, + running: false, + failed_attempts: 0, + on_success: None, + on_failure: None, + }; + listener.init_device(); + listener + } + + /// Connect to fprintd and get the default device. + fn init_device(&mut self) { + let manager = match gio::DBusProxy::for_bus_sync( + gio::BusType::System, + gio::DBusProxyFlags::NONE, + None, + FPRINTD_BUS_NAME, + FPRINTD_MANAGER_PATH, + FPRINTD_MANAGER_IFACE, + gio::Cancellable::NONE, + ) { + Ok(m) => m, + Err(e) => { + log::debug!("fprintd manager not available: {e}"); + return; + } + }; + + // Call GetDefaultDevice + let result = match manager.call_sync( + "GetDefaultDevice", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + Ok(r) => r, + Err(e) => { + log::debug!("fprintd GetDefaultDevice failed: {e}"); + return; + } + }; + + // Extract device path from variant tuple + let device_path: String = result.child_get::(0); + if device_path.is_empty() { + return; + } + + match gio::DBusProxy::for_bus_sync( + gio::BusType::System, + gio::DBusProxyFlags::NONE, + None, + FPRINTD_BUS_NAME, + &device_path, + FPRINTD_DEVICE_IFACE, + gio::Cancellable::NONE, + ) { + Ok(proxy) => { + self.device_proxy = Some(proxy); + } + Err(e) => { + log::debug!("fprintd device proxy failed: {e}"); + } + } + } + + /// Check if fprintd is available and the user has enrolled fingerprints. + pub fn is_available(&self, username: &str) -> bool { + let proxy = match &self.device_proxy { + Some(p) => p, + None => return false, + }; + + let args = glib::Variant::from((&username,)); + match proxy.call_sync( + "ListEnrolledFingers", + Some(&args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + Ok(result) => { + // Result is a tuple of (array of strings) + let fingers: Vec = result.child_get::>(0); + !fingers.is_empty() + } + Err(_) => false, + } + } + + /// Whether the listener is currently running. + pub fn is_running(&self) -> bool { + self.running + } + + /// Start listening for fingerprint verification. + pub fn start(&mut self, username: &str, on_success: F, on_failure: G) + where + F: Fn() + 'static, + G: Fn() + 'static, + { + let proxy = match &self.device_proxy { + Some(p) => p, + None => return, + }; + + self.on_success = Some(Box::new(on_success)); + self.on_failure = Some(Box::new(on_failure)); + + // Claim the device + let args = glib::Variant::from((&username,)); + if let Err(e) = proxy.call_sync( + "Claim", + Some(&args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + log::error!("Failed to claim fingerprint device: {e}"); + return; + } + + // Start verification + let start_args = glib::Variant::from((&"any",)); + if let Err(e) = proxy.call_sync( + "VerifyStart", + Some(&start_args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + log::error!("Failed to start fingerprint verification: {e}"); + let _ = proxy.call_sync( + "Release", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ); + return; + } + + self.running = true; + + // Note: Signal handling is set up by the caller via connect_g_signal() + // because FingerprintListener is not an Rc and the g-signal callback + // needs to reference mutable state. The caller (lockscreen.rs) will + // connect the proxy's "g-signal" and call on_verify_status(). + } + + /// Process a VerifyStatus signal from fprintd. + pub fn on_verify_status(&mut self, status: &str, done: bool) { + if !self.running { + return; + } + + if status == "verify-match" { + if let Some(ref cb) = self.on_success { + cb(); + } + return; + } + + if RETRY_STATUSES.contains(&status) { + if done { + self.restart_verify(); + } + return; + } + + if status == "verify-no-match" { + self.failed_attempts += 1; + if let Some(ref cb) = self.on_failure { + cb(); + } + if self.failed_attempts >= MAX_FP_ATTEMPTS { + log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping"); + self.stop(); + return; + } + if done { + self.restart_verify(); + } + return; + } + + log::debug!("Unhandled fprintd status: {status}"); + } + + /// Restart fingerprint verification after a completed attempt. + fn restart_verify(&self) { + if let Some(ref proxy) = self.device_proxy { + let args = glib::Variant::from((&"any",)); + if let Err(e) = proxy.call_sync( + "VerifyStart", + Some(&args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + log::error!("Failed to restart fingerprint verification: {e}"); + } + } + } + + /// Stop listening and release the device. + pub fn stop(&mut self) { + if !self.running { + return; + } + self.running = false; + + if let Some(ref proxy) = self.device_proxy { + if let Some(id) = self.signal_id.take() { + proxy.disconnect(id); + } + let _ = proxy.call_sync( + "VerifyStop", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ); + let _ = proxy.call_sync( + "Release", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ); + } + } + + /// Get a reference to the device proxy for signal connection. + pub fn device_proxy(&self) -> Option<&gio::DBusProxy> { + self.device_proxy.as_ref() + } + + /// Store the signal handler ID for cleanup. + pub fn set_signal_id(&mut self, id: glib::SignalHandlerId) { + self.signal_id = Some(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_statuses_are_defined() { + assert!(RETRY_STATUSES.contains(&"verify-swipe-too-short")); + assert!(RETRY_STATUSES.contains(&"verify-finger-not-centered")); + assert!(!RETRY_STATUSES.contains(&"verify-match")); + assert!(!RETRY_STATUSES.contains(&"verify-no-match")); + } + + #[test] + fn max_attempts_constant() { + assert_eq!(MAX_FP_ATTEMPTS, 10); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..340f220 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,140 @@ +// ABOUTME: Locale detection and string lookup for the lockscreen 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"; + +#[derive(Debug, Clone)] +pub struct Strings { + pub password_placeholder: &'static str, + pub unlock_button: &'static str, + pub reboot_tooltip: &'static str, + pub shutdown_tooltip: &'static str, + pub fingerprint_prompt: &'static str, + pub fingerprint_success: &'static str, + pub fingerprint_failed: &'static str, + pub auth_failed: &'static str, + pub wrong_password: &'static str, + pub reboot_failed: &'static str, + pub shutdown_failed: &'static str, + pub reboot_confirm: &'static str, + pub shutdown_confirm: &'static str, + pub confirm_yes: &'static str, + pub confirm_no: &'static str, + pub faillock_attempts_remaining: &'static str, + pub faillock_locked: &'static str, +} + +const STRINGS_DE: Strings = Strings { + password_placeholder: "Passwort", + unlock_button: "Entsperren", + reboot_tooltip: "Neustart", + shutdown_tooltip: "Herunterfahren", + fingerprint_prompt: "Fingerabdruck auflegen zum Entsperren", + fingerprint_success: "Fingerabdruck erkannt", + fingerprint_failed: "Fingerabdruck nicht erkannt", + auth_failed: "Authentifizierung fehlgeschlagen", + wrong_password: "Falsches Passwort", + reboot_failed: "Neustart fehlgeschlagen", + shutdown_failed: "Herunterfahren fehlgeschlagen", + reboot_confirm: "Wirklich neu starten?", + shutdown_confirm: "Wirklich herunterfahren?", + confirm_yes: "Ja", + confirm_no: "Abbrechen", + faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!", + faillock_locked: "Konto ist möglicherweise gesperrt", +}; + +const STRINGS_EN: Strings = Strings { + password_placeholder: "Password", + unlock_button: "Unlock", + reboot_tooltip: "Reboot", + shutdown_tooltip: "Shut down", + fingerprint_prompt: "Place finger on reader to unlock", + fingerprint_success: "Fingerprint recognized", + fingerprint_failed: "Fingerprint not recognized", + auth_failed: "Authentication failed", + wrong_password: "Wrong password", + reboot_failed: "Reboot failed", + shutdown_failed: "Shutdown failed", + reboot_confirm: "Really reboot?", + shutdown_confirm: "Really shut down?", + confirm_yes: "Yes", + confirm_no: "Cancel", + faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!", + faillock_locked: "Account may be locked", +}; + +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() } +} + +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 +} + +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() } +} + +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 } +} + +pub fn faillock_warning(attempt_count: u32, strings: &Strings) -> Option { + const MAX: u32 = 3; + if attempt_count >= MAX { return Some(strings.faillock_locked.to_string()); } + let remaining = MAX - 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; + + #[test] fn parse_german() { assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); } + #[test] fn parse_english() { assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); } + #[test] fn parse_c() { assert_eq!(parse_lang_prefix("C"), "en"); } + #[test] fn parse_empty() { assert_eq!(parse_lang_prefix(""), "en"); } + + #[test] fn read_conf() { + 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 strings_de() { let s = load_strings(Some("de")); assert_eq!(s.password_placeholder, "Passwort"); } + #[test] fn strings_en() { let s = load_strings(Some("en")); assert_eq!(s.password_placeholder, "Password"); } + #[test] fn strings_fallback() { let s = load_strings(Some("fr")); assert_eq!(s.password_placeholder, "Password"); } + + #[test] fn fingerprint_strings() { + let s = load_strings(Some("de")); + assert!(!s.fingerprint_prompt.is_empty()); + assert!(!s.fingerprint_success.is_empty()); + assert!(!s.fingerprint_failed.is_empty()); + } + + #[test] fn faillock_zero() { assert!(faillock_warning(0, load_strings(Some("en"))).is_none()); } + #[test] fn faillock_one() { assert!(faillock_warning(1, load_strings(Some("en"))).is_none()); } + #[test] fn faillock_two() { assert!(faillock_warning(2, load_strings(Some("en"))).is_some()); } + #[test] fn faillock_three() { assert_eq!(faillock_warning(3, load_strings(Some("en"))).unwrap(), "Account may be locked"); } +} diff --git a/src/lockscreen.rs b/src/lockscreen.rs new file mode 100644 index 0000000..fa7c8f8 --- /dev/null +++ b/src/lockscreen.rs @@ -0,0 +1,522 @@ +// ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons. +// ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow. + +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::path::Path; +use std::rc::Rc; + +use crate::auth; +use crate::config::Config; +use crate::fingerprint::FingerprintListener; +use crate::i18n::{faillock_warning, load_strings, Strings}; +use crate::power::{self, PowerError}; +use crate::users; + +const AVATAR_SIZE: i32 = 128; +const FAILLOCK_MAX_ATTEMPTS: u32 = 3; + +/// Shared mutable state for the lockscreen. +struct LockscreenState { + failed_attempts: u32, + fp_listener: FingerprintListener, +} + +/// Create a lockscreen window for a single monitor. +pub fn create_lockscreen_window( + bg_path: &Path, + config: &Config, + app: >k::Application, + unlock_callback: Rc, +) -> gtk::ApplicationWindow { + let window = gtk::ApplicationWindow::builder() + .application(app) + .build(); + window.add_css_class("lockscreen"); + + let strings = load_strings(None); + let user = match users::get_current_user() { + Some(u) => u, + None => { + log::error!("Failed to get current user"); + return window; + } + }; + + let mut fp_listener = FingerprintListener::new(); + let fp_available = config.fingerprint_enabled + && fp_listener.is_available(&user.username); + + let state = Rc::new(RefCell::new(LockscreenState { + failed_attempts: 0, + fp_listener, + })); + + // Root overlay for background + centered content + let overlay = gtk::Overlay::new(); + window.set_child(Some(&overlay)); + + // Background wallpaper + let background = create_background_picture(bg_path); + overlay.set_child(Some(&background)); + + // Centered vertical box + let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + main_box.set_halign(gtk::Align::Center); + main_box.set_valign(gtk::Align::Center); + overlay.add_overlay(&main_box); + + // Login box + let login_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + login_box.set_halign(gtk::Align::Center); + login_box.add_css_class("login-box"); + main_box.append(&login_box); + + // 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); + + // Load avatar + let avatar_path = users::get_avatar_path(&user.home, &user.username); + if let Some(path) = avatar_path { + set_avatar_from_file(&avatar_image, &path); + } else { + set_default_avatar(&avatar_image, &window); + } + + // Username label + let username_label = gtk::Label::new(Some(&user.display_name)); + username_label.add_css_class("username-label"); + login_box.append(&username_label); + + // 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 + let error_label = gtk::Label::new(None); + error_label.add_css_class("error-label"); + error_label.set_visible(false); + login_box.append(&error_label); + + // Fingerprint label + let fp_label = gtk::Label::new(None); + fp_label.add_css_class("fingerprint-label"); + if fp_available { + fp_label.set_text(strings.fingerprint_prompt); + fp_label.set_visible(true); + } else { + fp_label.set_visible(false); + } + login_box.append(&fp_label); + + // Confirm box area (for power confirm) + let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0); + confirm_area.set_halign(gtk::Align::Center); + login_box.append(&confirm_area); + let confirm_box: Rc>> = Rc::new(RefCell::new(None)); + + // Power buttons (bottom right) + let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); + power_box.set_halign(gtk::Align::End); + power_box.set_valign(gtk::Align::End); + power_box.set_hexpand(true); + power_box.set_vexpand(true); + power_box.set_margin_end(16); + power_box.set_margin_bottom(16); + + 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] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + show_power_confirm( + strings.reboot_confirm, + power::reboot, + strings.reboot_failed, + strings, + &confirm_area, + &confirm_box, + &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] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + show_power_confirm( + strings.shutdown_confirm, + power::shutdown, + strings.shutdown_failed, + strings, + &confirm_area, + &confirm_box, + &error_label, + ); + } + )); + power_box.append(&shutdown_btn); + + overlay.add_overlay(&power_box); + + // Password entry "activate" handler + let username = user.username.clone(); + password_entry.connect_activate(clone!( + #[strong] + state, + #[strong] + unlock_callback, + #[weak] + error_label, + #[weak] + password_entry, + move |entry| { + let password = entry.text().to_string(); + if password.is_empty() { + return; + } + + entry.set_sensitive(false); + let username = username.clone(); + let unlock_cb = unlock_callback.clone(); + + glib::spawn_future_local(clone!( + #[strong] + state, + #[weak] + error_label, + #[weak] + password_entry, + async move { + let user = username.clone(); + let pass = password.clone(); + let result = gio::spawn_blocking(move || { + auth::authenticate(&user, &pass) + }).await; + + match result { + Ok(true) => { + state.borrow_mut().fp_listener.stop(); + unlock_cb(); + } + _ => { + let mut s = state.borrow_mut(); + s.failed_attempts += 1; + let count = s.failed_attempts; + let strings = load_strings(None); + password_entry.set_text(""); + + if count >= FAILLOCK_MAX_ATTEMPTS { + error_label.set_text(strings.faillock_locked); + error_label.set_visible(true); + password_entry.set_sensitive(false); + } else { + password_entry.set_sensitive(true); + password_entry.grab_focus(); + if let Some(warning) = faillock_warning(count, strings) { + error_label.set_text(&warning); + } else { + error_label.set_text(strings.wrong_password); + } + error_label.set_visible(true); + } + } + } + } + )); + } + )); + + // 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); + password_entry.grab_focus(); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + )); + window.add_controller(key_controller); + + // Start fingerprint listener + if fp_available { + let unlock_cb_fp = unlock_callback.clone(); + let fp_label_success = fp_label.clone(); + let fp_label_fail = fp_label.clone(); + + let on_success = move || { + let label = fp_label_success.clone(); + let cb = unlock_cb_fp.clone(); + glib::idle_add_local_once(move || { + label.set_text(load_strings(None).fingerprint_success); + label.add_css_class("success"); + cb(); + }); + }; + + let on_failure = move || { + let label = fp_label_fail.clone(); + glib::idle_add_local_once(clone!( + #[weak] + label, + move || { + let strings = load_strings(None); + label.set_text(strings.fingerprint_failed); + label.add_css_class("failed"); + // Reset after 2 seconds + glib::timeout_add_local_once( + std::time::Duration::from_secs(2), + clone!( + #[weak] + label, + move || { + label.set_text(load_strings(None).fingerprint_prompt); + label.remove_css_class("success"); + label.remove_css_class("failed"); + } + ), + ); + } + )); + }; + + state + .borrow_mut() + .fp_listener + .start(&user.username, on_success, on_failure); + } + + // Fade-in on map + window.connect_map(|w| { + glib::idle_add_local_once(clone!( + #[weak] + w, + move || { + w.add_css_class("visible"); + } + )); + }); + + // Focus password entry on realize + window.connect_realize(clone!( + #[weak] + password_entry, + move |_| { + glib::idle_add_local_once(move || { + password_entry.grab_focus(); + }); + } + )); + + 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/moonlock") { + 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 +} + +/// Load an image file and set it as the avatar. +fn set_avatar_from_file(image: >k::Image, path: &Path) { + 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); + 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) { + 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); + image.set_paintable(Some(&texture)); + return; + } + } + } + } + image.set_icon_name(Some("avatar-default-symbolic")); +} + +/// Show inline power confirmation. +fn show_power_confirm( + message: &'static str, + action_fn: fn() -> Result<(), PowerError>, + error_message: &'static str, + strings: &'static Strings, + confirm_area: >k::Box, + confirm_box: &Rc>>, + error_label: >k::Label, +) { + dismiss_power_confirm(confirm_area, confirm_box); + + let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + new_box.set_halign(gtk::Align::Center); + new_box.set_margin_top(16); + + let confirm_label = gtk::Label::new(Some(message)); + confirm_label.add_css_class("confirm-label"); + new_box.append(&confirm_label); + + let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + button_row.set_halign(gtk::Align::Center); + + let yes_btn = gtk::Button::with_label(strings.confirm_yes); + yes_btn.add_css_class("confirm-yes"); + yes_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + dismiss_power_confirm(&confirm_area, &confirm_box); + execute_power_action(action_fn, error_message, &error_label); + } + )); + button_row.append(&yes_btn); + + let no_btn = gtk::Button::with_label(strings.confirm_no); + no_btn.add_css_class("confirm-no"); + no_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + move |_| { + dismiss_power_confirm(&confirm_area, &confirm_box); + } + )); + button_row.append(&no_btn); + + new_box.append(&button_row); + confirm_area.append(&new_box); + *confirm_box.borrow_mut() = Some(new_box); + no_btn.grab_focus(); +} + +/// Remove the power confirmation prompt. +fn dismiss_power_confirm(confirm_area: >k::Box, confirm_box: &Rc>>) { + if let Some(box_widget) = confirm_box.borrow_mut().take() { + confirm_area.remove(&box_widget); + } +} + +/// 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); + } + } + } + )); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn faillock_threshold() { + assert_eq!(FAILLOCK_MAX_ATTEMPTS, 3); + } + + #[test] + fn avatar_size_matches_css() { + assert_eq!(AVATAR_SIZE, 128); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f0989ea --- /dev/null +++ b/src/main.rs @@ -0,0 +1,164 @@ +// ABOUTME: Entry point for Moonlock — secure Wayland lockscreen. +// ABOUTME: Sets up GTK Application, ext-session-lock-v1, Panic-Hook, and multi-monitor windows. + +mod auth; +mod config; +mod fingerprint; +mod i18n; +mod lockscreen; +mod power; +mod users; + +use gdk4 as gdk; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use gtk4_session_lock; +use std::path::PathBuf; +use std::rc::Rc; + +fn load_css(display: &gdk::Display) { + let css_provider = gtk::CssProvider::new(); + css_provider.load_from_resource("/dev/moonarch/moonlock/style.css"); + gtk::style_context_add_provider_for_display( + display, + &css_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn activate(app: >k::Application) { + let display = match gdk::Display::default() { + Some(d) => d, + None => { + log::error!("No display available — cannot start lockscreen UI"); + return; + } + }; + + load_css(&display); + + let config = config::load_config(None); + let bg_path = config::resolve_background_path(&config); + + if gtk4_session_lock::is_supported() { + activate_with_session_lock(app, &display, &bg_path, &config); + } else { + log::warn!("ext-session-lock-v1 not supported — running in development mode"); + activate_without_lock(app, &bg_path, &config); + } +} + +fn activate_with_session_lock( + app: >k::Application, + display: &gdk::Display, + bg_path: &PathBuf, + config: &config::Config, +) { + let lock = gtk4_session_lock::Instance::new(); + lock.lock(); + + let monitors = display.monitors(); + + // Shared unlock callback — unlocks session and quits + let lock_clone = lock.clone(); + let app_clone = app.clone(); + let unlock_callback: Rc = Rc::new(move || { + lock_clone.unlock(); + app_clone.quit(); + }); + + let mut created_any = false; + for i in 0..monitors.n_items() { + if let Some(monitor) = monitors + .item(i) + .and_then(|obj| obj.downcast::().ok()) + { + let window = lockscreen::create_lockscreen_window( + bg_path, + config, + app, + unlock_callback.clone(), + ); + lock.assign_window_to_monitor(&window, &monitor); + window.present(); + created_any = true; + } + } + + if !created_any { + log::error!("No lockscreen windows created — screen stays locked (compositor policy)"); + } +} + +fn activate_without_lock( + app: >k::Application, + bg_path: &PathBuf, + config: &config::Config, +) { + let app_clone = app.clone(); + let unlock_callback: Rc = Rc::new(move || { + app_clone.quit(); + }); + + let window = lockscreen::create_lockscreen_window( + bg_path, + config, + app, + unlock_callback, + ); + window.set_default_size(800, 600); + window.present(); +} + +fn setup_logging() { + let mut builder = env_logger::Builder::from_default_env(); + builder.filter_level(log::LevelFilter::Info); + + let log_dir = PathBuf::from("/var/cache/moonlock"); + if log_dir.is_dir() { + let log_file = log_dir.join("moonlock.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 install_panic_hook() { + // Install a panic hook BEFORE starting the app. + // On panic, we log but NEVER unlock. The compositor's ext-session-lock-v1 + // policy keeps the screen locked when the client crashes. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + log::error!("PANIC — screen stays locked (compositor policy): {info}"); + default_hook(info); + })); +} + +fn main() { + setup_logging(); + + // Root check — moonlock should not run as root + if nix::unistd::getuid().is_root() { + log::error!("Moonlock should not run as root"); + std::process::exit(1); + } + + install_panic_hook(); + log::info!("Moonlock starting"); + + // Register compiled GResources + gio::resources_register_include!("moonlock.gresource").expect("Failed to register resources"); + + let app = gtk::Application::builder() + .application_id("dev.moonarch.moonlock") + .build(); + + app.connect_activate(activate); + app.run(); +} diff --git a/src/moonlock/__init__.py b/src/moonlock/__init__.py deleted file mode 100644 index c681af5..0000000 --- a/src/moonlock/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# ABOUTME: Package init for moonlock — a secure Wayland lockscreen. -# ABOUTME: Uses ext-session-lock-v1, PAM auth and fprintd fingerprint support. diff --git a/src/moonlock/auth.py b/src/moonlock/auth.py deleted file mode 100644 index 7162977..0000000 --- a/src/moonlock/auth.py +++ /dev/null @@ -1,159 +0,0 @@ -# ABOUTME: PAM authentication via ctypes wrapper around libpam.so. -# ABOUTME: Provides authenticate(username, password) for the lockscreen. - -import ctypes -import ctypes.util -from ctypes import POINTER, Structure, c_char_p, c_int, c_void_p, CFUNCTYPE, pointer - -# PAM return codes -PAM_SUCCESS = 0 -PAM_AUTH_ERR = 7 -PAM_PROMPT_ECHO_OFF = 1 - - -class PamMessage(Structure): - """PAM message structure (pam_message).""" - - _fields_ = [ - ("msg_style", c_int), - ("msg", c_char_p), - ] - - -class PamResponse(Structure): - """PAM response structure (pam_response).""" - - # resp is c_void_p (not c_char_p) to preserve raw malloc'd pointers — - # ctypes auto-converts c_char_p returns to Python bytes, losing the pointer. - _fields_ = [ - ("resp", c_void_p), - ("resp_retcode", c_int), - ] - - -# PAM conversation callback type -PamConvFunc = CFUNCTYPE( - c_int, - c_int, - POINTER(POINTER(PamMessage)), - POINTER(POINTER(PamResponse)), - c_void_p, -) - - -class PamConv(Structure): - """PAM conversation structure (pam_conv).""" - - _fields_ = [ - ("conv", PamConvFunc), - ("appdata_ptr", c_void_p), - ] - - -_cached_libpam: ctypes.CDLL | None = None -_cached_libc: ctypes.CDLL | None = None - - -def _get_libpam() -> ctypes.CDLL: - """Load and return the libpam shared library (cached after first call).""" - global _cached_libpam - if _cached_libpam is None: - pam_path = ctypes.util.find_library("pam") - if not pam_path: - raise OSError("libpam not found") - _cached_libpam = ctypes.CDLL(pam_path) - return _cached_libpam - - -def _get_libc() -> ctypes.CDLL: - """Load and return the libc shared library (cached after first call).""" - global _cached_libc - if _cached_libc is None: - libc_path = ctypes.util.find_library("c") - if not libc_path: - raise OSError("libc not found") - libc = ctypes.CDLL(libc_path) - libc.calloc.restype = c_void_p - libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t] - libc.strdup.restype = c_void_p - libc.strdup.argtypes = [c_char_p] - _cached_libc = libc - return _cached_libc - - -def _make_conv_func(password_bytes: bytearray) -> PamConvFunc: - """Create a PAM conversation callback that provides the password. - - Takes a bytearray (not str) so the caller can wipe it after use. - The callback creates a temporary bytes copy for strdup, then the - bytearray remains the single wipeable source of truth. - """ - libc = _get_libc() - - def _conv( - num_msg: int, - msg: POINTER(POINTER(PamMessage)), - resp: POINTER(POINTER(PamResponse)), - appdata_ptr: c_void_p, - ) -> int: - # PAM expects malloc'd memory — it will free() the responses and resp strings - resp_array = libc.calloc(num_msg, ctypes.sizeof(PamResponse)) - if not resp_array: - return PAM_AUTH_ERR - - resp_ptr = ctypes.cast(resp_array, POINTER(PamResponse)) - for i in range(num_msg): - # strdup allocates with malloc, which PAM can safely free() - resp_ptr[i].resp = libc.strdup(bytes(password_bytes)) - resp_ptr[i].resp_retcode = 0 - - resp[0] = resp_ptr - return PAM_SUCCESS - - return PamConvFunc(_conv) - - -def _wipe_bytes(data: bytes | bytearray) -> None: - """Overwrite sensitive bytes in memory with zeros.""" - if isinstance(data, bytearray): - for i in range(len(data)): - data[i] = 0 - - -def authenticate(username: str, password: str) -> bool: - """Authenticate a user via PAM. Returns True on success, False otherwise.""" - libpam = _get_libpam() - - # Use a mutable bytearray so we can wipe the password after use - password_bytes = bytearray(password.encode("utf-8")) - - # Set up conversation — pass bytearray, not the str - conv_func = _make_conv_func(password_bytes) - conv = PamConv(conv=conv_func, appdata_ptr=None) - - # PAM handle - handle = c_void_p() - - # Start PAM session - ret = libpam.pam_start( - b"moonlock", - username.encode("utf-8"), - pointer(conv), - pointer(handle), - ) - if ret != PAM_SUCCESS: - _wipe_bytes(password_bytes) - return False - - try: - # Authenticate - ret = libpam.pam_authenticate(handle, 0) - if ret != PAM_SUCCESS: - return False - - # Check account validity - ret = libpam.pam_acct_mgmt(handle, 0) - return ret == PAM_SUCCESS - finally: - libpam.pam_end(handle, ret) - _wipe_bytes(password_bytes) diff --git a/src/moonlock/config.py b/src/moonlock/config.py deleted file mode 100644 index c20fef7..0000000 --- a/src/moonlock/config.py +++ /dev/null @@ -1,62 +0,0 @@ -# ABOUTME: Configuration loading for the lockscreen. -# ABOUTME: Reads moonlock.toml for wallpaper and feature settings. - -import tomllib -from dataclasses import dataclass, field -from importlib.resources import files -from pathlib import Path - -MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg") -PACKAGE_WALLPAPER = Path(str(files("moonlock") / "data" / "wallpaper.jpg")) - -DEFAULT_CONFIG_PATHS = [ - Path("/etc/moonlock/moonlock.toml"), - Path.home() / ".config" / "moonlock" / "moonlock.toml", -] - - -@dataclass(frozen=True) -class Config: - """Lockscreen configuration.""" - - background_path: str | None = None - fingerprint_enabled: bool = True - - -def load_config( - config_paths: list[Path] | None = None, -) -> Config: - """Load config from TOML file. Later paths override earlier ones.""" - if config_paths is None: - config_paths = DEFAULT_CONFIG_PATHS - - merged: dict = {} - for path in config_paths: - if path.exists(): - with open(path, "rb") as f: - data = tomllib.load(f) - merged.update(data) - - return Config( - background_path=merged.get("background_path"), - fingerprint_enabled=merged.get("fingerprint_enabled", True), - ) - - -def resolve_background_path(config: Config) -> Path: - """Resolve the wallpaper path using the fallback hierarchy. - - Priority: config background_path > Moonarch system default > package fallback. - """ - # User-configured path - if config.background_path: - path = Path(config.background_path) - if path.is_file(): - return path - - # Moonarch ecosystem default - if MOONARCH_WALLPAPER.is_file(): - return MOONARCH_WALLPAPER - - # Package fallback (always present) - return PACKAGE_WALLPAPER diff --git a/src/moonlock/fingerprint.py b/src/moonlock/fingerprint.py deleted file mode 100644 index 83a83f5..0000000 --- a/src/moonlock/fingerprint.py +++ /dev/null @@ -1,201 +0,0 @@ -# ABOUTME: fprintd D-Bus integration for fingerprint authentication. -# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop. - -import logging -from typing import Callable - -import gi -gi.require_version("Gio", "2.0") -from gi.repository import Gio, GLib - -logger = logging.getLogger(__name__) - -FPRINTD_BUS_NAME = "net.reactivated.Fprint" -FPRINTD_MANAGER_PATH = "/net/reactivated/Fprint/Manager" -FPRINTD_MANAGER_IFACE = "net.reactivated.Fprint.Manager" -FPRINTD_DEVICE_IFACE = "net.reactivated.Fprint.Device" - -# Maximum fingerprint verification attempts before disabling -_MAX_FP_ATTEMPTS = 10 - -# Retry-able statuses (finger not read properly, try again) -_RETRY_STATUSES = { - "verify-swipe-too-short", - "verify-finger-not-centered", - "verify-remove-and-retry", - "verify-retry-scan", -} - - -class FingerprintListener: - """Listens for fingerprint verification events via fprintd D-Bus.""" - - def __init__(self) -> None: - self._device_proxy: Gio.DBusProxy | None = None - self._device_path: str | None = None - self._signal_id: int | None = None - self._running: bool = False - self._failed_attempts: int = 0 - self._on_success: Callable[[], None] | None = None - self._on_failure: Callable[[], None] | None = None - - self._init_device() - - def _init_device(self) -> None: - """Connect to fprintd and get the default device. - - This uses synchronous D-Bus calls — call before creating GTK windows - to avoid blocking the mainloop. - """ - try: - manager = Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SYSTEM, - Gio.DBusProxyFlags.NONE, - None, - FPRINTD_BUS_NAME, - FPRINTD_MANAGER_PATH, - FPRINTD_MANAGER_IFACE, - None, - ) - result = manager.GetDefaultDevice() - if result: - self._device_path = result - self._device_proxy = Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SYSTEM, - Gio.DBusProxyFlags.NONE, - None, - FPRINTD_BUS_NAME, - self._device_path, - FPRINTD_DEVICE_IFACE, - None, - ) - except GLib.Error: - self._device_proxy = None - self._device_path = None - - def is_available(self, username: str) -> bool: - """Check if fprintd is running and the user has enrolled fingerprints.""" - if not self._device_proxy: - return False - try: - result = self._device_proxy.ListEnrolledFingers("(s)", username) - return bool(result) - except GLib.Error: - return False - - def start( - self, - username: str, - on_success: Callable[[], None], - on_failure: Callable[[], None], - ) -> None: - """Start listening for fingerprint verification.""" - if not self._device_proxy: - return - - self._on_success = on_success - self._on_failure = on_failure - - try: - self._device_proxy.Claim("(s)", username) - except GLib.Error as e: - logger.error("Failed to claim fingerprint device: %s", e.message) - return - - # Connect to the VerifyStatus signal - self._signal_id = self._device_proxy.connect( - "g-signal", self._on_signal - ) - - try: - self._device_proxy.VerifyStart("(s)", "any") - except GLib.Error as e: - logger.error("Failed to start fingerprint verification: %s", e.message) - self._device_proxy.disconnect(self._signal_id) - self._signal_id = None - try: - self._device_proxy.Release() - except GLib.Error: - pass - return - - self._running = True - - def stop(self) -> None: - """Stop listening and release the device.""" - if not self._running: - return - - self._running = False - - if self._device_proxy: - if self._signal_id is not None: - self._device_proxy.disconnect(self._signal_id) - self._signal_id = None - - try: - self._device_proxy.VerifyStop() - except GLib.Error: - pass - - try: - self._device_proxy.Release() - except GLib.Error: - pass - - def _on_signal( - self, - proxy: Gio.DBusProxy, - sender_name: str | None, - signal_name: str, - parameters: GLib.Variant, - ) -> None: - """Handle D-Bus signals from the fprintd device.""" - if signal_name != "VerifyStatus": - return - - # Validate signal origin — only accept signals from fprintd - if sender_name and not sender_name.startswith(FPRINTD_BUS_NAME): - expected_sender = proxy.get_name_owner() - if sender_name != expected_sender: - logger.warning("Ignoring VerifyStatus from unexpected sender: %s", sender_name) - return - - status = parameters[0] - done = parameters[1] - self._on_verify_status(status, done) - - def _on_verify_status(self, status: str, done: bool) -> None: - """Process a VerifyStatus signal from fprintd.""" - if not self._running: - return - - if status == "verify-match": - if self._on_success: - self._on_success() - return - - if status in _RETRY_STATUSES: - # Retry — finger wasn't read properly - if done and self._running and self._device_proxy: - try: - self._device_proxy.VerifyStart("(s)", "any") - except GLib.Error as e: - logger.error("Failed to restart fingerprint verification: %s", e.message) - return - - if status == "verify-no-match": - self._failed_attempts += 1 - if self._on_failure: - self._on_failure() - if self._failed_attempts >= _MAX_FP_ATTEMPTS: - logger.warning("Fingerprint max attempts (%d) reached, stopping listener", _MAX_FP_ATTEMPTS) - self.stop() - return - # Restart verification for another attempt - if done and self._running and self._device_proxy: - try: - self._device_proxy.VerifyStart("(s)", "any") - except GLib.Error as e: - logger.error("Failed to restart fingerprint verification: %s", e.message) - return diff --git a/src/moonlock/i18n.py b/src/moonlock/i18n.py deleted file mode 100644 index b1ff251..0000000 --- a/src/moonlock/i18n.py +++ /dev/null @@ -1,111 +0,0 @@ -# ABOUTME: Locale detection and string lookup for the lockscreen 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 lockscreen UI.""" - - # UI labels - password_placeholder: str - unlock_button: str - reboot_tooltip: str - shutdown_tooltip: str - - # Fingerprint - fingerprint_prompt: str - fingerprint_success: str - fingerprint_failed: str - - # Error messages - auth_failed: str - wrong_password: str - reboot_failed: str - shutdown_failed: str - - # Power confirmation - reboot_confirm: str - shutdown_confirm: str - confirm_yes: str - confirm_no: str - - # Templates (use .format()) - faillock_attempts_remaining: str - faillock_locked: str - - -_STRINGS_DE = Strings( - password_placeholder="Passwort", - unlock_button="Entsperren", - reboot_tooltip="Neustart", - shutdown_tooltip="Herunterfahren", - fingerprint_prompt="Fingerabdruck auflegen zum Entsperren", - fingerprint_success="Fingerabdruck erkannt", - fingerprint_failed="Fingerabdruck nicht erkannt", - auth_failed="Authentifizierung fehlgeschlagen", - wrong_password="Falsches Passwort", - reboot_failed="Neustart fehlgeschlagen", - shutdown_failed="Herunterfahren fehlgeschlagen", - reboot_confirm="Wirklich neu starten?", - shutdown_confirm="Wirklich herunterfahren?", - confirm_yes="Ja", - confirm_no="Abbrechen", - faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!", - faillock_locked="Konto ist möglicherweise gesperrt", -) - -_STRINGS_EN = Strings( - password_placeholder="Password", - unlock_button="Unlock", - reboot_tooltip="Reboot", - shutdown_tooltip="Shut down", - fingerprint_prompt="Place finger on reader to unlock", - fingerprint_success="Fingerprint recognized", - fingerprint_failed="Fingerprint not recognized", - auth_failed="Authentication failed", - wrong_password="Wrong password", - reboot_failed="Reboot failed", - shutdown_failed="Shutdown failed", - reboot_confirm="Really reboot?", - shutdown_confirm="Really shut down?", - confirm_yes="Yes", - confirm_no="Cancel", - 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] - 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/moonlock/lockscreen.py b/src/moonlock/lockscreen.py deleted file mode 100644 index ca34c47..0000000 --- a/src/moonlock/lockscreen.py +++ /dev/null @@ -1,349 +0,0 @@ -# ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons. -# ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow. - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk, GdkPixbuf, GLib - -import logging -from collections.abc import Callable -from pathlib import Path - -logger = logging.getLogger(__name__) - -from moonlock.auth import authenticate -from moonlock.config import Config, load_config, resolve_background_path -from moonlock.fingerprint import FingerprintListener -from moonlock.i18n import Strings, load_strings -from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User -from moonlock import power - -# UI-only attempt counter — the real brute-force protection is pam_faillock -# in the system-auth PAM stack, which persists across process restarts. -FAILLOCK_MAX_ATTEMPTS = 3 -AVATAR_SIZE = 128 - - -class LockscreenWindow(Gtk.ApplicationWindow): - """Fullscreen lockscreen window with password and fingerprint auth.""" - - def __init__(self, application: Gtk.Application, unlock_callback: Callable[[], None] | None = None, - config: Config | None = None, - fingerprint_listener: FingerprintListener | None = None, - wallpaper_path: Path | None = None) -> None: - super().__init__(application=application) - self.add_css_class("lockscreen") - - self._config = config or load_config() - self._strings = load_strings() - self._user = get_current_user() - self._failed_attempts = 0 - self._unlock_callback = unlock_callback - self._wallpaper_path = wallpaper_path or resolve_background_path(self._config) - - # Fingerprint listener (shared across windows to avoid multiple device claims) - self._fp_listener = fingerprint_listener or FingerprintListener() - self._fp_available = ( - self._config.fingerprint_enabled - and self._fp_listener.is_available(self._user.username) - ) - - self._build_ui() - self._setup_keyboard() - self._password_entry.grab_focus() - - # Start fingerprint listener if available (only once across shared instances) - if self._fp_available and not self._fp_listener._running: - self._fp_listener.start( - self._user.username, - on_success=self._on_fingerprint_success, - on_failure=self._on_fingerprint_failure, - ) - - def _build_ui(self) -> None: - """Build the lockscreen layout.""" - # Main overlay for background + centered content - overlay = Gtk.Overlay() - self.set_child(overlay) - - # Background wallpaper (path resolved once, shared across monitors) - background = Gtk.Picture.new_for_filename(str(self._wallpaper_path)) - background.set_content_fit(Gtk.ContentFit.COVER) - background.set_hexpand(True) - background.set_vexpand(True) - overlay.set_child(background) - - # Centered vertical box - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - main_box.set_halign(Gtk.Align.CENTER) - main_box.set_valign(Gtk.Align.CENTER) - overlay.add_overlay(main_box) - - # Login box (centered card) - self._login_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self._login_box.set_halign(Gtk.Align.CENTER) - self._login_box.add_css_class("login-box") - main_box.append(self._login_box) - - # 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) - self._login_box.append(avatar_frame) - - avatar_path = get_avatar_path(self._user.home, self._user.username) - if avatar_path: - self._set_avatar_from_file(avatar_path) - else: - self._set_default_avatar() - - # Username label - username_label = Gtk.Label(label=self._user.display_name) - username_label.add_css_class("username-label") - self._login_box.append(username_label) - - # Password entry - self._password_entry = Gtk.PasswordEntry() - 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_password_submit) - self._login_box.append(self._password_entry) - - # Error label - self._error_label = Gtk.Label() - self._error_label.add_css_class("error-label") - self._error_label.set_visible(False) - self._login_box.append(self._error_label) - - # Fingerprint status label - self._fp_label = Gtk.Label() - self._fp_label.add_css_class("fingerprint-label") - if self._fp_available: - self._fp_label.set_text(self._strings.fingerprint_prompt) - self._fp_label.set_visible(True) - else: - self._fp_label.set_visible(False) - self._login_box.append(self._fp_label) - - # Power buttons (bottom right) - power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - power_box.set_halign(Gtk.Align.END) - power_box.set_valign(Gtk.Align.END) - power_box.set_hexpand(True) - power_box.set_vexpand(True) - power_box.set_margin_end(16) - power_box.set_margin_bottom(16) - overlay.add_overlay(power_box) - - reboot_btn = Gtk.Button(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", lambda _: self._on_power_action(power.reboot)) - power_box.append(reboot_btn) - - shutdown_btn = Gtk.Button(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", lambda _: self._on_power_action(power.shutdown)) - power_box.append(shutdown_btn) - - def _setup_keyboard(self) -> None: - """Set up keyboard event handling.""" - 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 key presses — Escape clears the password field.""" - if keyval == Gdk.KEY_Escape: - self._password_entry.set_text("") - self._error_label.set_visible(False) - self._password_entry.grab_focus() - return True - return False - - def _on_password_submit(self, entry: Gtk.PasswordEntry) -> None: - """Handle password submission via Enter key.""" - password = entry.get_text() - if not password: - return - - # Run PAM auth in a thread to avoid blocking the UI - entry.set_sensitive(False) - - def _do_auth() -> bool: - return authenticate(self._user.username, password) - - def _on_auth_done(result: bool) -> None: - if result: - self._unlock() - return - - self._failed_attempts += 1 - entry.set_text("") - - if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS: - # Permanently disable entry after max failed attempts - self._show_error(self._strings.faillock_locked) - entry.set_sensitive(False) - else: - entry.set_sensitive(True) - entry.grab_focus() - if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1: - remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts - self._show_error( - self._strings.faillock_attempts_remaining.format(n=remaining) - ) - else: - self._show_error(self._strings.wrong_password) - - # Use GLib thread pool to avoid blocking GTK mainloop - def _auth_thread() -> bool: - try: - result = _do_auth() - except Exception: - result = False - GLib.idle_add(_on_auth_done, result) - return GLib.SOURCE_REMOVE - - GLib.Thread.new("pam-auth", _auth_thread) - - def _on_fingerprint_success(self) -> None: - """Called when fingerprint verification succeeds.""" - def _handle_success(): - self._fp_label.set_text(self._strings.fingerprint_success) - self._fp_label.add_css_class("success") - self._unlock() - return GLib.SOURCE_REMOVE - GLib.idle_add(_handle_success) - - def _on_fingerprint_failure(self) -> None: - """Called when fingerprint verification fails (no match).""" - GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_failed) - GLib.idle_add(self._fp_label.add_css_class, "failed") - # Reset label after 2 seconds - GLib.timeout_add( - 2000, - self._reset_fp_label, - ) - - def _reset_fp_label(self) -> bool: - """Reset fingerprint label to prompt state.""" - self._fp_label.set_text(self._strings.fingerprint_prompt) - self._fp_label.remove_css_class("success") - self._fp_label.remove_css_class("failed") - return GLib.SOURCE_REMOVE - - 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.""" - try: - default_path = get_default_avatar_path() - svg_text = default_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._avatar_image.set_from_pixbuf(pixbuf) - return - except (GLib.Error, OSError): - pass - self._avatar_image.set_from_icon_name("avatar-default-symbolic") - - def _set_avatar_from_file(self, path: Path) -> None: - """Load an image file and set it as the avatar, scaled to AVATAR_SIZE.""" - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - str(path), AVATAR_SIZE, AVATAR_SIZE, True - ) - self._avatar_image.set_from_pixbuf(pixbuf) - except GLib.Error: - self._set_default_avatar() - - def _show_error(self, message: str) -> None: - """Display an error message.""" - self._error_label.set_text(message) - self._error_label.set_visible(True) - - def _unlock(self) -> None: - """Unlock the screen after successful authentication.""" - # Stop fingerprint listener - self._fp_listener.stop() - - if self._unlock_callback: - self._unlock_callback() - - def _on_power_action(self, action: Callable[[], None]) -> None: - """Request a power action with confirmation.""" - confirm_msg = ( - self._strings.reboot_confirm - if action == power.reboot - else self._strings.shutdown_confirm - ) - self._show_power_confirm(confirm_msg, action) - - def _show_power_confirm(self, message: str, action: Callable[[], None]) -> None: - """Show inline confirmation below the login box.""" - self._confirm_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self._confirm_box.set_halign(Gtk.Align.CENTER) - self._confirm_box.set_margin_top(16) - - confirm_label = Gtk.Label(label=message) - confirm_label.add_css_class("confirm-label") - self._confirm_box.append(confirm_label) - - button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - button_box.set_halign(Gtk.Align.CENTER) - - yes_btn = Gtk.Button(label=self._strings.confirm_yes) - yes_btn.add_css_class("confirm-yes") - yes_btn.connect("clicked", lambda _: self._execute_power_action(action)) - button_box.append(yes_btn) - - no_btn = Gtk.Button(label=self._strings.confirm_no) - no_btn.add_css_class("confirm-no") - no_btn.connect("clicked", lambda _: self._dismiss_power_confirm()) - button_box.append(no_btn) - - self._confirm_box.append(button_box) - self._login_box.append(self._confirm_box) - - def _execute_power_action(self, action: Callable[[], None]) -> None: - """Execute the confirmed power action.""" - self._dismiss_power_confirm() - try: - action() - except Exception: - logger.exception("Power action failed") - error_msg = ( - self._strings.reboot_failed - if action == power.reboot - else self._strings.shutdown_failed - ) - self._show_error(error_msg) - - def _dismiss_power_confirm(self) -> None: - """Remove the confirmation prompt.""" - if hasattr(self, "_confirm_box"): - self._login_box.remove(self._confirm_box) - del self._confirm_box diff --git a/src/moonlock/main.py b/src/moonlock/main.py deleted file mode 100644 index 458b89d..0000000 --- a/src/moonlock/main.py +++ /dev/null @@ -1,179 +0,0 @@ -# ABOUTME: Entry point for Moonlock — sets up GTK Application and ext-session-lock-v1. -# ABOUTME: Handles CLI invocation, session locking, and multi-monitor support. - -import logging -import os -import sys -from importlib.resources import files -from pathlib import Path - -# gtk4-layer-shell must be loaded before libwayland-client. -# Only allow our own library in LD_PRELOAD — discard anything inherited from the environment. -_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so" -_existing_preload = os.environ.get("LD_PRELOAD", "") -_is_testing = "pytest" in sys.modules or "unittest" in sys.modules -if ( - not _is_testing - and _LAYER_SHELL_LIB not in _existing_preload - and os.path.exists(_LAYER_SHELL_LIB) -): - os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB - os.execvp(sys.executable, [sys.executable, "-m", "moonlock.main"] + sys.argv[1:]) - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk - -from moonlock.config import load_config -from moonlock.fingerprint import FingerprintListener -from moonlock.lockscreen import LockscreenWindow - -logger = logging.getLogger(__name__) - -# ext-session-lock-v1 via gtk4-layer-shell -try: - gi.require_version("Gtk4SessionLock", "1.0") - from gi.repository import Gtk4SessionLock - HAS_SESSION_LOCK = True -except (ValueError, ImportError): - HAS_SESSION_LOCK = False - -_LOG_DIR = Path("/var/cache/moonlock") -_LOG_FILE = _LOG_DIR / "moonlock.log" - - -def _setup_logging() -> None: - """Configure logging to stderr and optionally to a log file.""" - root = logging.getLogger() - root.setLevel(logging.INFO) - - formatter = logging.Formatter( - "%(asctime)s %(levelname)s %(name)s: %(message)s" - ) - - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.INFO) - stderr_handler.setFormatter(formatter) - root.addHandler(stderr_handler) - - 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 MoonlockApp(Gtk.Application): - """GTK Application for the Moonlock lockscreen.""" - - def __init__(self) -> None: - super().__init__(application_id="dev.moonarch.moonlock") - self._lock_instance = None - self._windows: list[Gtk.Window] = [] - self._config = load_config() - - def do_activate(self) -> None: - """Create the lockscreen and lock the session.""" - self._load_css() - - if HAS_SESSION_LOCK and Gtk4SessionLock.is_supported(): - self._activate_with_session_lock() - else: - # Fallback for development/testing without Wayland - self._activate_without_lock() - - def _activate_with_session_lock(self) -> None: - """Lock the session using ext-session-lock-v1 protocol.""" - # Init fingerprint D-Bus before locking — sync D-Bus calls would block - # the GTK mainloop if done after lock when the UI needs to be responsive. - fp_listener = FingerprintListener() - - # Resolve wallpaper once, share across all monitors - from moonlock.config import resolve_background_path - wallpaper_path = resolve_background_path(self._config) - - self._lock_instance = Gtk4SessionLock.Instance.new() - self._lock_instance.lock() - - display = Gdk.Display.get_default() - monitors = display.get_monitors() - - for i in range(monitors.get_n_items()): - monitor = monitors.get_item(i) - try: - window = LockscreenWindow( - application=self, - unlock_callback=self._unlock, - config=self._config, - fingerprint_listener=fp_listener, - wallpaper_path=wallpaper_path, - ) - self._lock_instance.assign_window_to_monitor(window, monitor) - window.present() - self._windows.append(window) - except Exception: - logger.exception("Failed to create lockscreen window for monitor %d", i) - - if not self._windows: - logger.critical("No lockscreen windows created — screen stays locked (compositor policy)") - - def _activate_without_lock(self) -> None: - """Fallback for development — no session lock, just a window.""" - window = LockscreenWindow( - application=self, - unlock_callback=self._unlock, - ) - window.set_default_size(800, 600) - window.present() - self._windows.append(window) - - def _unlock(self) -> None: - """Unlock the session and exit.""" - if self._lock_instance: - self._lock_instance.unlock() - self.quit() - - def _load_css(self) -> None: - """Load the CSS stylesheet for the lockscreen.""" - try: - css_provider = Gtk.CssProvider() - css_path = files("moonlock") / "style.css" - css_provider.load_from_path(str(css_path)) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - except Exception: - logger.exception("Failed to load CSS stylesheet") - - -def _install_excepthook() -> None: - """Install a global exception handler that logs crashes without unlocking.""" - sys.excepthook = lambda exc_type, exc_value, exc_tb: ( - logger.critical("Unhandled exception — screen stays locked (compositor policy)", - exc_info=(exc_type, exc_value, exc_tb)), - sys.__excepthook__(exc_type, exc_value, exc_tb), - ) - - -def main() -> None: - """Run the Moonlock application.""" - _setup_logging() - - if os.getuid() == 0: - logger.critical("Moonlock should not run as root") - sys.exit(1) - - logger.info("Moonlock starting") - app = MoonlockApp() - _install_excepthook() - app.run(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/src/moonlock/power.py b/src/moonlock/power.py deleted file mode 100644 index 4798116..0000000 --- a/src/moonlock/power.py +++ /dev/null @@ -1,14 +0,0 @@ -# ABOUTME: Power actions — reboot and shutdown via loginctl. -# ABOUTME: Simple wrappers around system commands for the lockscreen UI. - -import subprocess - - -def reboot() -> None: - """Reboot the system via loginctl.""" - subprocess.run(["loginctl", "reboot"], check=True) - - -def shutdown() -> None: - """Shut down the system via loginctl.""" - subprocess.run(["loginctl", "poweroff"], check=True) diff --git a/src/moonlock/users.py b/src/moonlock/users.py deleted file mode 100644 index eba7d5c..0000000 --- a/src/moonlock/users.py +++ /dev/null @@ -1,65 +0,0 @@ -# ABOUTME: Current user detection and avatar loading for the lockscreen. -# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face). - -import os -import pwd -from dataclasses import dataclass -from importlib.resources import files -from pathlib import Path - -DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons") - - -@dataclass(frozen=True) -class User: - """Represents the current user for the lockscreen.""" - - username: str - display_name: str - home: Path - uid: int - - -def get_current_user() -> User: - """Get the currently logged-in user's info from the system.""" - # Use getuid() instead of getlogin() — getlogin() fails without a controlling - # terminal (systemd units, display-manager-started sessions). - pw = pwd.getpwuid(os.getuid()) - - gecos = pw.pw_gecos - # GECOS field may contain comma-separated values; first field is the full name - display_name = gecos.split(",")[0] if gecos else pw.pw_name - if not display_name: - display_name = pw.pw_name - - return User( - username=pw.pw_name, - display_name=display_name, - home=Path(pw.pw_dir), - uid=pw.pw_uid, - ) - - -def get_avatar_path( - home: Path, - username: str | None = None, - accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR, -) -> Path | None: - """Find the user's avatar image, checking ~/.face then AccountsService.""" - # ~/.face takes priority - face = home / ".face" - if face.exists(): - return face - - # AccountsService icon - if username and accountsservice_dir.exists(): - icon = accountsservice_dir / username - if icon.exists(): - return icon - - return None - - -def get_default_avatar_path() -> Path: - """Return the path to the package default avatar SVG.""" - return Path(str(files("moonlock") / "data" / "default-avatar.svg")) diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..2959686 --- /dev/null +++ b/src/power.rs @@ -0,0 +1,52 @@ +// ABOUTME: Power actions — reboot and shutdown via loginctl. +// ABOUTME: Wrappers around system commands for the lockscreen 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 {} + +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(()) +} + +pub fn reboot() -> Result<(), PowerError> { run_command("reboot", "/usr/bin/loginctl", &["reboot"]) } +pub fn shutdown() -> Result<(), PowerError> { run_command("shutdown", "/usr/bin/loginctl", &["poweroff"]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] fn power_error_display() { assert_eq!(PowerError::CommandFailed { action: "reboot", message: "fail".into() }.to_string(), "reboot failed: fail"); } + #[test] fn timeout_display() { assert_eq!(PowerError::Timeout { action: "shutdown" }.to_string(), "shutdown timed out"); } + #[test] fn missing_binary() { assert!(run_command("test", "nonexistent-xyz", &[]).is_err()); } + #[test] fn nonzero_exit() { assert!(run_command("test", "false", &[]).is_err()); } + #[test] fn success() { assert!(run_command("test", "true", &[]).is_ok()); } +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..17b4a7b --- /dev/null +++ b/src/users.rs @@ -0,0 +1,93 @@ +// ABOUTME: Current user detection and avatar loading for the lockscreen. +// ABOUTME: Retrieves user info from the system (nix getuid, AccountsService, ~/.face). + +use nix::unistd::{getuid, User as NixUser}; +use std::path::{Path, PathBuf}; + +const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons"; +const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock"; + +#[derive(Debug, Clone)] +pub struct User { + pub username: String, + pub display_name: String, + pub home: PathBuf, + pub uid: u32, +} + +pub fn get_current_user() -> Option { + let uid = getuid(); + let nix_user = NixUser::from_uid(uid).ok()??; + let gecos = nix_user.gecos.to_str().unwrap_or("").to_string(); + let display_name = if !gecos.is_empty() { + let first = gecos.split(',').next().unwrap_or(""); + if first.is_empty() { nix_user.name.clone() } else { first.to_string() } + } else { nix_user.name.clone() }; + Some(User { username: nix_user.name, display_name, home: nix_user.dir, uid: uid.as_raw() }) +} + +pub fn get_avatar_path(home: &Path, username: &str) -> Option { + get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR)) +} + +pub fn get_avatar_path_with(home: &Path, username: &str, accountsservice_dir: &Path) -> Option { + // ~/.face takes priority + let face = home.join(".face"); + if face.exists() && !face.is_symlink() { return Some(face); } + // AccountsService icon + if accountsservice_dir.exists() { + let icon = accountsservice_dir.join(username); + if icon.exists() && !icon.is_symlink() { return Some(icon); } + } + None +} + +pub fn get_default_avatar_path() -> String { + format!("{GRESOURCE_PREFIX}/default-avatar.svg") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] fn current_user_exists() { + let u = get_current_user(); + assert!(u.is_some()); + let u = u.unwrap(); + assert!(!u.username.is_empty()); + } + + #[test] fn face_file_priority() { + let dir = tempfile::tempdir().unwrap(); + let face = dir.path().join(".face"); fs::write(&face, "img").unwrap(); + let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap(); + let icon = icons.join("test"); fs::write(&icon, "img").unwrap(); + assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(face)); + } + + #[test] fn accountsservice_fallback() { + let dir = tempfile::tempdir().unwrap(); + let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap(); + let icon = icons.join("test"); fs::write(&icon, "img").unwrap(); + assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(icon)); + } + + #[test] fn no_avatar() { + let dir = tempfile::tempdir().unwrap(); + assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none()); + } + + #[test] fn rejects_symlink() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real"); fs::write(&real, "x").unwrap(); + std::os::unix::fs::symlink(&real, dir.path().join(".face")).unwrap(); + assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none()); + } + + #[test] fn default_avatar_gresource() { + let p = get_default_avatar_path(); + assert!(p.contains("moonlock")); + assert!(p.contains("default-avatar.svg")); + } +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index ef53b4a..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,65 +0,0 @@ -# ABOUTME: Tests for PAM authentication via ctypes wrapper. -# ABOUTME: Verifies authenticate() calls libpam correctly and handles success/failure. - -from unittest.mock import patch, MagicMock, ANY -import ctypes - -from moonlock.auth import authenticate, PAM_SUCCESS, PAM_AUTH_ERR - - -class TestAuthenticate: - """Tests for PAM authentication.""" - - @patch("moonlock.auth._get_libpam") - def test_returns_true_on_successful_auth(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - - assert authenticate("testuser", "correctpassword") is True - - @patch("moonlock.auth._get_libpam") - def test_returns_false_on_wrong_password(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_AUTH_ERR - libpam.pam_end.return_value = PAM_SUCCESS - - assert authenticate("testuser", "wrongpassword") is False - - @patch("moonlock.auth._get_libpam") - def test_pam_end_always_called(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_AUTH_ERR - libpam.pam_end.return_value = PAM_SUCCESS - - authenticate("testuser", "wrongpassword") - libpam.pam_end.assert_called_once() - - @patch("moonlock.auth._get_libpam") - def test_returns_false_when_pam_start_fails(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_AUTH_ERR - - assert authenticate("testuser", "password") is False - - @patch("moonlock.auth._get_libpam") - def test_uses_moonlock_as_service_name(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - - authenticate("testuser", "password") - args = libpam.pam_start.call_args - # First positional arg should be the service name - assert args[0][0] == b"moonlock" diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 8a22c9f..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,42 +0,0 @@ -# ABOUTME: Tests for configuration loading. -# ABOUTME: Verifies TOML parsing, defaults, and path override behavior. - -from pathlib import Path - -from moonlock.config import Config, load_config - - -class TestLoadConfig: - """Tests for config loading.""" - - def test_defaults_when_no_config_file(self, tmp_path: Path): - nonexistent = tmp_path / "nonexistent.toml" - config = load_config(config_paths=[nonexistent]) - assert config.background_path is None - assert config.fingerprint_enabled is True - - def test_reads_background_path(self, tmp_path: Path): - config_file = tmp_path / "moonlock.toml" - config_file.write_text('background_path = "/usr/share/wallpapers/moon.jpg"\n') - config = load_config(config_paths=[config_file]) - assert config.background_path == "/usr/share/wallpapers/moon.jpg" - - def test_reads_fingerprint_disabled(self, tmp_path: Path): - config_file = tmp_path / "moonlock.toml" - config_file.write_text("fingerprint_enabled = false\n") - config = load_config(config_paths=[config_file]) - assert config.fingerprint_enabled is False - - def test_later_paths_override_earlier(self, tmp_path: Path): - system_conf = tmp_path / "system.toml" - system_conf.write_text('background_path = "/system/wallpaper.jpg"\n') - user_conf = tmp_path / "user.toml" - user_conf.write_text('background_path = "/home/user/wallpaper.jpg"\n') - config = load_config(config_paths=[system_conf, user_conf]) - assert config.background_path == "/home/user/wallpaper.jpg" - - def test_partial_config_uses_defaults(self, tmp_path: Path): - config_file = tmp_path / "moonlock.toml" - config_file.write_text('background_path = "/some/path.jpg"\n') - config = load_config(config_paths=[config_file]) - assert config.fingerprint_enabled is True diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py deleted file mode 100644 index 72aae5c..0000000 --- a/tests/test_fingerprint.py +++ /dev/null @@ -1,237 +0,0 @@ -# ABOUTME: Tests for fprintd D-Bus integration. -# ABOUTME: Verifies fingerprint listener lifecycle and signal handling with mocked D-Bus. - -from unittest.mock import patch, MagicMock, call - -from moonlock.fingerprint import FingerprintListener - - -class TestFingerprintListenerAvailability: - """Tests for checking fprintd availability.""" - - @patch("moonlock.fingerprint.Gio.DBusProxy.new_for_bus_sync") - def test_is_available_when_fprintd_running_and_enrolled(self, mock_proxy_cls): - manager = MagicMock() - mock_proxy_cls.return_value = manager - manager.GetDefaultDevice.return_value = ("(o)", "/dev/0") - - device = MagicMock() - mock_proxy_cls.return_value = device - device.ListEnrolledFingers.return_value = ("(as)", ["right-index-finger"]) - - listener = FingerprintListener.__new__(FingerprintListener) - listener._manager_proxy = manager - listener._device_proxy = device - listener._device_path = "/dev/0" - - assert listener.is_available("testuser") is True - - def test_is_available_returns_false_when_no_device(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = None - listener._device_path = None - - assert listener.is_available("testuser") is False - - -class TestFingerprintListenerLifecycle: - """Tests for start/stop lifecycle.""" - - def test_start_calls_verify_start(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - listener._device_proxy.Claim.assert_called_once_with("(s)", "testuser") - listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") - - def test_stop_calls_verify_stop_and_release(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - listener._signal_id = 42 - - listener.stop() - - listener._device_proxy.VerifyStop.assert_called_once() - listener._device_proxy.Release.assert_called_once() - assert listener._running is False - - def test_stop_is_noop_when_not_running(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = False - - listener.stop() - - listener._device_proxy.VerifyStop.assert_not_called() - - -class TestFingerprintSignalHandling: - """Tests for VerifyStatus signal processing.""" - - def test_verify_match_calls_on_success(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-match", False) - - on_success.assert_called_once() - - def test_verify_no_match_calls_on_failure_and_retries_when_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-no-match", True) - - on_failure.assert_called_once() - # Should restart verification when done=True - listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") - - def test_verify_no_match_calls_on_failure_without_restart_when_not_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-no-match", False) - - on_failure.assert_called_once() - # Should NOT restart verification when done=False (still in progress) - listener._device_proxy.VerifyStart.assert_not_called() - - def test_verify_swipe_too_short_retries_when_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-swipe-too-short", True) - - on_success.assert_not_called() - on_failure.assert_not_called() - listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") - - def test_retry_status_does_not_restart_when_not_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-swipe-too-short", False) - - on_success.assert_not_called() - on_failure.assert_not_called() - # Should NOT restart — verification still in progress - listener._device_proxy.VerifyStart.assert_not_called() - - -class TestFingerprintStartErrorHandling: - """Tests for GLib.Error handling in start().""" - - def test_claim_glib_error_logs_and_returns_without_starting(self): - """When Claim() raises GLib.Error, start() should not proceed.""" - from gi.repository import GLib - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - listener._on_success = None - listener._on_failure = None - - listener._device_proxy.Claim.side_effect = GLib.Error( - "net.reactivated.Fprint.Error.AlreadyClaimed" - ) - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - # Should NOT have connected signals or started verification - listener._device_proxy.connect.assert_not_called() - listener._device_proxy.VerifyStart.assert_not_called() - assert listener._running is False - - def test_verify_start_glib_error_disconnects_and_releases(self): - """When VerifyStart() raises GLib.Error, start() should clean up.""" - from gi.repository import GLib - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - listener._on_success = None - listener._on_failure = None - - # Claim succeeds, signal connect returns an ID, VerifyStart fails - listener._device_proxy.connect.return_value = 99 - listener._device_proxy.VerifyStart.side_effect = GLib.Error( - "net.reactivated.Fprint.Error.Internal" - ) - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - # Should have disconnected the signal - listener._device_proxy.disconnect.assert_called_once_with(99) - # Should have released the device - listener._device_proxy.Release.assert_called_once() - assert listener._running is False - assert listener._signal_id is None - - def test_start_sets_running_true_only_on_success(self): - """_running should only be True after both Claim and VerifyStart succeed.""" - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - listener._on_success = None - listener._on_failure = None - - listener._device_proxy.connect.return_value = 42 - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - assert listener._running is True diff --git a/tests/test_i18n.py b/tests/test_i18n.py deleted file mode 100644 index 0f0db97..0000000 --- a/tests/test_i18n.py +++ /dev/null @@ -1,67 +0,0 @@ -# ABOUTME: Tests for locale detection and string lookup. -# ABOUTME: Verifies correct language detection from env vars and /etc/locale.conf. - -import os -from pathlib import Path -from unittest.mock import patch - -from moonlock.i18n import Strings, detect_locale, load_strings - - -class TestDetectLocale: - """Tests for locale detection.""" - - @patch.dict(os.environ, {"LANG": "de_DE.UTF-8"}) - def test_detects_german_from_env(self): - assert detect_locale() == "de" - - @patch.dict(os.environ, {"LANG": "en_US.UTF-8"}) - def test_detects_english_from_env(self): - assert detect_locale() == "en" - - @patch.dict(os.environ, {"LANG": ""}, clear=False) - def test_reads_locale_conf_when_env_empty(self, tmp_path: Path): - locale_conf = tmp_path / "locale.conf" - locale_conf.write_text("LANG=de_DE.UTF-8\n") - assert detect_locale(locale_conf_path=locale_conf) == "de" - - @patch.dict(os.environ, {"LANG": "C"}) - def test_c_locale_defaults_to_english(self): - assert detect_locale() == "en" - - @patch.dict(os.environ, {"LANG": "POSIX"}) - def test_posix_locale_defaults_to_english(self): - assert detect_locale() == "en" - - @patch.dict(os.environ, {}, clear=True) - def test_missing_env_and_no_file_defaults_to_english(self, tmp_path: Path): - nonexistent = tmp_path / "nonexistent" - assert detect_locale(locale_conf_path=nonexistent) == "en" - - -class TestLoadStrings: - """Tests for string table loading.""" - - def test_german_strings(self): - strings = load_strings("de") - assert isinstance(strings, Strings) - assert strings.password_placeholder == "Passwort" - - def test_english_strings(self): - strings = load_strings("en") - assert strings.password_placeholder == "Password" - - def test_unknown_locale_falls_back_to_english(self): - strings = load_strings("fr") - assert strings.password_placeholder == "Password" - - def test_lockscreen_specific_strings_exist(self): - strings = load_strings("de") - assert strings.unlock_button is not None - assert strings.fingerprint_prompt is not None - assert strings.fingerprint_success is not None - - def test_faillock_template_strings(self): - strings = load_strings("de") - msg = strings.faillock_attempts_remaining.format(n=2) - assert "2" in msg diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 9b3220e..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,168 +0,0 @@ -# ABOUTME: Integration tests for the complete auth flow. -# ABOUTME: Tests password and fingerprint unlock paths end-to-end (mocked PAM/fprintd). - -from unittest.mock import patch, MagicMock, PropertyMock - -from moonlock.lockscreen import LockscreenWindow, FAILLOCK_MAX_ATTEMPTS - - -class TestPasswordAuthFlow: - """Integration tests for password authentication flow.""" - - @patch("moonlock.lockscreen.FingerprintListener") - @patch("moonlock.lockscreen.get_avatar_path", return_value=None) - @patch("moonlock.lockscreen.get_current_user") - @patch("moonlock.lockscreen.authenticate") - def test_successful_password_unlock(self, mock_auth, mock_user, mock_avatar, mock_fp): - """Successful password auth should trigger unlock callback.""" - mock_user.return_value = MagicMock( - username="testuser", display_name="Test", home="/tmp", uid=1000 - ) - mock_fp_instance = MagicMock() - mock_fp_instance.is_available.return_value = False - mock_fp.return_value = mock_fp_instance - mock_auth.return_value = True - - unlock_called = [] - # We can't create a real GTK window without a display, so test the auth logic directly - from moonlock.auth import authenticate - result = authenticate.__wrapped__("testuser", "correct") if hasattr(authenticate, '__wrapped__') else mock_auth("testuser", "correct") - assert result is True - - @patch("moonlock.lockscreen.authenticate", return_value=False) - def test_failed_password_increments_counter(self, mock_auth): - """Failed password should increment failed attempts.""" - # Test the counter logic directly - failed_attempts = 0 - result = mock_auth("testuser", "wrong") - if not result: - failed_attempts += 1 - assert failed_attempts == 1 - assert result is False - - def test_faillock_warning_after_threshold(self): - """Faillock warning should appear near max attempts.""" - from moonlock.i18n import load_strings - strings = load_strings("de") - failed = FAILLOCK_MAX_ATTEMPTS - 1 - remaining = FAILLOCK_MAX_ATTEMPTS - failed - msg = strings.faillock_attempts_remaining.format(n=remaining) - assert "1" in msg - - def test_faillock_locked_at_max_attempts(self): - """Account locked message at max failed attempts.""" - from moonlock.i18n import load_strings - strings = load_strings("de") - assert strings.faillock_locked - - -class TestFingerprintAuthFlow: - """Integration tests for fingerprint authentication flow.""" - - def test_fingerprint_success_triggers_unlock(self): - """verify-match signal should lead to unlock.""" - from moonlock.fingerprint import FingerprintListener - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - - unlock_called = [] - listener._on_success = lambda: unlock_called.append(True) - listener._on_failure = MagicMock() - - listener._on_verify_status("verify-match", False) - assert len(unlock_called) == 1 - - def test_fingerprint_no_match_retries_when_done(self): - """verify-no-match with done=True should call on_failure and restart verification.""" - from moonlock.fingerprint import FingerprintListener - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - listener._on_success = MagicMock() - listener._on_failure = MagicMock() - - listener._on_verify_status("verify-no-match", True) - - listener._on_failure.assert_called_once() - listener._device_proxy.VerifyStart.assert_called_once() - - def test_fingerprint_no_match_no_restart_when_not_done(self): - """verify-no-match with done=False should call on_failure but not restart.""" - from moonlock.fingerprint import FingerprintListener - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - listener._on_success = MagicMock() - listener._on_failure = MagicMock() - - listener._on_verify_status("verify-no-match", False) - - listener._on_failure.assert_called_once() - listener._device_proxy.VerifyStart.assert_not_called() - - def test_fingerprint_and_password_independent(self): - """Both auth methods should work independently.""" - from moonlock.fingerprint import FingerprintListener - from moonlock.auth import authenticate - - # Fingerprint path - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - fp_unlock = [] - listener._on_success = lambda: fp_unlock.append(True) - listener._on_failure = MagicMock() - listener._on_verify_status("verify-match", False) - assert len(fp_unlock) == 1 - - # Password path (mocked) - with patch("moonlock.auth._get_libpam") as mock_pam: - from moonlock.auth import PAM_SUCCESS - libpam = MagicMock() - mock_pam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - assert authenticate("testuser", "correct") is True - - -class TestSecurityConstraints: - """Tests for security-related behavior.""" - - def test_escape_does_not_unlock(self): - """Escape key should only clear the field, not unlock.""" - # Escape should clear password, not trigger any auth - from gi.repository import Gdk - assert Gdk.KEY_Escape != Gdk.KEY_Return - - def test_empty_password_not_submitted(self): - """Empty password should not trigger PAM auth.""" - with patch("moonlock.auth._get_libpam") as mock_pam: - # The lockscreen checks for empty password before calling authenticate - password = "" - assert not password # falsy, so auth should not be called - mock_pam.assert_not_called() - - def test_pam_service_name_is_moonlock(self): - """PAM should use 'moonlock' as service name, not 'login' or 'sudo'.""" - with patch("moonlock.auth._get_libpam") as mock_pam: - from moonlock.auth import PAM_SUCCESS - libpam = MagicMock() - mock_pam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - - from moonlock.auth import authenticate - authenticate("user", "pass") - assert libpam.pam_start.call_args[0][0] == b"moonlock" diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 29d7b12..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,229 +0,0 @@ -# ABOUTME: Tests for the Moonlock application entry point. -# ABOUTME: Covers logging setup, defensive window creation, and CSS error handling. - -import logging -import sys -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - - -@pytest.fixture(autouse=True) -def _mock_gtk(monkeypatch): - """Prevent GTK from requiring a display during test collection.""" - mock_gi = MagicMock() - mock_gtk = MagicMock() - mock_gdk = MagicMock() - mock_session_lock = MagicMock() - - # Pre-populate gi.repository with our mocks - modules = { - "gi": mock_gi, - "gi.repository": MagicMock(Gtk=mock_gtk, Gdk=mock_gdk, Gtk4SessionLock=mock_session_lock), - } - - with monkeypatch.context() as m: - # Only patch missing/problematic modules if not already loaded - for mod_name, mod in modules.items(): - if mod_name not in sys.modules: - m.setitem(sys.modules, mod_name, mod) - yield - - -def _import_main(): - """Import main module lazily after GTK mocking is in place.""" - from moonlock.main import MoonlockApp, _setup_logging - return MoonlockApp, _setup_logging - - -class TestSetupLogging: - """Tests for the logging infrastructure.""" - - def test_setup_logging_adds_stderr_handler(self): - """_setup_logging() should add a StreamHandler to the root logger.""" - _, _setup_logging = _import_main() - root = logging.getLogger() - handlers_before = len(root.handlers) - - _setup_logging() - - assert len(root.handlers) > handlers_before - - # Clean up handlers we added - for handler in root.handlers[handlers_before:]: - root.removeHandler(handler) - - def test_setup_logging_sets_info_level(self): - """_setup_logging() should set root logger to INFO level.""" - _, _setup_logging = _import_main() - root = logging.getLogger() - original_level = root.level - - _setup_logging() - - assert root.level == logging.INFO - - # Restore - root.setLevel(original_level) - for handler in root.handlers[:]: - root.removeHandler(handler) - - @patch("moonlock.main._LOG_DIR") - @patch("logging.FileHandler") - def test_setup_logging_adds_file_handler_when_dir_exists(self, mock_fh, mock_log_dir): - """_setup_logging() should add a FileHandler when log directory exists.""" - _, _setup_logging = _import_main() - mock_log_dir.is_dir.return_value = True - - root = logging.getLogger() - handlers_before = len(root.handlers) - - _setup_logging() - - mock_fh.assert_called_once() - - # Clean up - for handler in root.handlers[handlers_before:]: - root.removeHandler(handler) - - @patch("moonlock.main._LOG_DIR") - def test_setup_logging_skips_file_handler_when_dir_missing(self, mock_log_dir): - """_setup_logging() should not fail when log directory doesn't exist.""" - _, _setup_logging = _import_main() - mock_log_dir.is_dir.return_value = False - - root = logging.getLogger() - handlers_before = len(root.handlers) - - _setup_logging() - - assert len(root.handlers) >= handlers_before - - # Clean up - for handler in root.handlers[handlers_before:]: - root.removeHandler(handler) - - -class TestCssErrorHandling: - """Tests for CSS loading error handling.""" - - @patch("moonlock.main.Gdk.Display.get_default") - @patch("moonlock.main.Gtk.CssProvider") - @patch("moonlock.main.files") - def test_load_css_logs_error_on_exception(self, mock_files, mock_css_cls, mock_display): - """CSS loading errors should be logged, not raised.""" - MoonlockApp, _ = _import_main() - mock_files.return_value.__truediv__ = MagicMock(return_value=Path("/nonexistent")) - mock_css = MagicMock() - mock_css.load_from_path.side_effect = Exception("CSS parse error") - mock_css_cls.return_value = mock_css - - app = MoonlockApp.__new__(MoonlockApp) - app._config = MagicMock() - - # Should not raise - with patch("moonlock.main.logger") as mock_logger: - app._load_css() - mock_logger.exception.assert_called_once() - - -class TestDefensiveWindowCreation: - """Tests for defensive window creation in session lock mode.""" - - @patch("moonlock.main.LockscreenWindow") - @patch("moonlock.main.FingerprintListener") - @patch("moonlock.main.Gdk.Display.get_default") - @patch("moonlock.main.Gtk4SessionLock") - def test_single_window_failure_does_not_stop_other_windows( - self, mock_session_lock, mock_display, mock_fp, mock_window_cls - ): - """If one window fails, others should still be created.""" - MoonlockApp, _ = _import_main() - app = MoonlockApp.__new__(MoonlockApp) - app._config = MagicMock() - app._windows = [] - - # Two monitors - monitor1 = MagicMock() - monitor2 = MagicMock() - monitors = MagicMock() - monitors.get_n_items.return_value = 2 - monitors.get_item.side_effect = [monitor1, monitor2] - mock_display.return_value.get_monitors.return_value = monitors - - lock_instance = MagicMock() - mock_session_lock.Instance.new.return_value = lock_instance - app._lock_instance = lock_instance - - # First window creation fails, second succeeds - window_ok = MagicMock() - mock_window_cls.side_effect = [Exception("GTK error"), window_ok] - - with patch("moonlock.main.logger"): - app._activate_with_session_lock() - - # One window should have been created despite the first failure - assert len(app._windows) == 1 - - @patch("moonlock.main.LockscreenWindow") - @patch("moonlock.main.FingerprintListener") - @patch("moonlock.main.Gdk.Display.get_default") - @patch("moonlock.main.Gtk4SessionLock") - def test_all_windows_fail_does_not_unlock_session( - self, mock_session_lock, mock_display, mock_fp, mock_window_cls - ): - """If ALL windows fail, session stays locked (compositor policy).""" - MoonlockApp, _ = _import_main() - app = MoonlockApp.__new__(MoonlockApp) - app._config = MagicMock() - app._windows = [] - - # One monitor - monitors = MagicMock() - monitors.get_n_items.return_value = 1 - monitors.get_item.return_value = MagicMock() - mock_display.return_value.get_monitors.return_value = monitors - - lock_instance = MagicMock() - mock_session_lock.Instance.new.return_value = lock_instance - app._lock_instance = lock_instance - - # Window creation fails - mock_window_cls.side_effect = Exception("GTK error") - - with patch("moonlock.main.logger"): - app._activate_with_session_lock() - - # Session must NOT be unlocked — compositor keeps screen locked - lock_instance.unlock.assert_not_called() - - -class TestExcepthook: - """Tests for the global exception handler.""" - - def test_excepthook_does_not_unlock(self): - """Unhandled exceptions must NOT unlock the session.""" - MoonlockApp, _ = _import_main() - from moonlock.main import _install_excepthook - - app = MoonlockApp.__new__(MoonlockApp) - app._lock_instance = MagicMock() - - original_hook = sys.excepthook - _install_excepthook() - - try: - with patch("moonlock.main.logger"): - sys.excepthook(RuntimeError, RuntimeError("crash"), None) - - # Must NOT have called unlock - app._lock_instance.unlock.assert_not_called() - finally: - sys.excepthook = original_hook - - def test_no_sigusr1_handler(self): - """SIGUSR1 must NOT be handled — signal-based unlock is a security hole.""" - import signal - handler = signal.getsignal(signal.SIGUSR1) - assert handler is signal.SIG_DFL diff --git a/tests/test_power.py b/tests/test_power.py deleted file mode 100644 index a1cac9c..0000000 --- a/tests/test_power.py +++ /dev/null @@ -1,24 +0,0 @@ -# ABOUTME: Tests for power actions (reboot, shutdown). -# ABOUTME: Verifies loginctl commands are called correctly. - -from unittest.mock import patch, call - -from moonlock.power import reboot, shutdown - - -class TestReboot: - """Tests for the reboot function.""" - - @patch("moonlock.power.subprocess.run") - def test_reboot_calls_loginctl(self, mock_run): - reboot() - mock_run.assert_called_once_with(["loginctl", "reboot"], check=True) - - -class TestShutdown: - """Tests for the shutdown function.""" - - @patch("moonlock.power.subprocess.run") - def test_shutdown_calls_loginctl(self, mock_run): - shutdown() - mock_run.assert_called_once_with(["loginctl", "poweroff"], check=True) diff --git a/tests/test_security.py b/tests/test_security.py deleted file mode 100644 index db3ae8d..0000000 --- a/tests/test_security.py +++ /dev/null @@ -1,24 +0,0 @@ -# ABOUTME: Tests for security-related functionality. -# ABOUTME: Verifies password wiping, PAM cleanup, and lockscreen bypass prevention. - -from moonlock.auth import _wipe_bytes - - -class TestPasswordWiping: - """Tests for sensitive data cleanup.""" - - def test_wipe_bytes_zeroes_bytearray(self): - data = bytearray(b"secretpassword") - _wipe_bytes(data) - assert data == bytearray(len(b"secretpassword")) - assert all(b == 0 for b in data) - - def test_wipe_bytes_handles_empty(self): - data = bytearray(b"") - _wipe_bytes(data) - assert data == bytearray(b"") - - def test_wipe_bytes_handles_bytes_gracefully(self): - # Regular bytes are immutable, wipe should be a no-op - data = b"secret" - _wipe_bytes(data) # should not raise diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 1808718..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,96 +0,0 @@ -# ABOUTME: Tests for current user detection and avatar loading. -# ABOUTME: Verifies user info retrieval from the system. - -import os -from pathlib import Path -from unittest.mock import patch - -from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User - - -class TestGetCurrentUser: - """Tests for current user detection.""" - - @patch("moonlock.users.os.getuid", return_value=1000) - @patch("moonlock.users.pwd.getpwuid") - def test_returns_user_with_correct_username(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "Test User" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.username == "testuser" - assert user.display_name == "Test User" - assert user.home == Path("/home/testuser") - mock_pwd.assert_called_once_with(1000) - - @patch("moonlock.users.os.getuid", return_value=1000) - @patch("moonlock.users.pwd.getpwuid") - def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.display_name == "testuser" - - @patch("moonlock.users.os.getuid", return_value=1000) - @patch("moonlock.users.pwd.getpwuid") - def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "Test User,,,Room 42" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.display_name == "Test User" - - -class TestGetAvatarPath: - """Tests for avatar path resolution.""" - - def test_returns_face_file_if_exists(self, tmp_path: Path): - face = tmp_path / ".face" - face.write_text("fake image") - path = get_avatar_path(tmp_path) - assert path == face - - def test_returns_accountsservice_icon_if_exists(self, tmp_path: Path): - username = "testuser" - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - icon = icons_dir / username - icon.write_text("fake image") - path = get_avatar_path( - tmp_path, username=username, accountsservice_dir=icons_dir - ) - assert path == icon - - def test_face_file_takes_priority_over_accountsservice(self, tmp_path: Path): - face = tmp_path / ".face" - face.write_text("fake image") - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - icon = icons_dir / "testuser" - icon.write_text("fake image") - path = get_avatar_path( - tmp_path, username="testuser", accountsservice_dir=icons_dir - ) - assert path == face - - def test_returns_none_when_no_avatar(self, tmp_path: Path): - path = get_avatar_path(tmp_path) - assert path is None - - -class TestGetDefaultAvatarPath: - """Tests for default avatar fallback.""" - - def test_default_avatar_exists(self): - """The package default avatar must always be present.""" - path = get_default_avatar_path() - assert path.is_file() - - def test_default_avatar_is_svg(self): - """The default avatar should be an SVG file.""" - path = get_default_avatar_path() - assert path.suffix == ".svg" diff --git a/tests/test_wallpaper.py b/tests/test_wallpaper.py deleted file mode 100644 index 37d46f5..0000000 --- a/tests/test_wallpaper.py +++ /dev/null @@ -1,53 +0,0 @@ -# ABOUTME: Tests for wallpaper path resolution. -# ABOUTME: Verifies fallback hierarchy: config > Moonarch system default > package fallback. - -from pathlib import Path -from unittest.mock import patch - -from moonlock.config import Config, resolve_background_path, MOONARCH_WALLPAPER, PACKAGE_WALLPAPER - - -class TestResolveBackgroundPath: - """Tests for the wallpaper fallback hierarchy.""" - - def test_config_path_used_when_file_exists(self, tmp_path: Path): - """Config background_path takes priority if the file exists.""" - wallpaper = tmp_path / "custom.jpg" - wallpaper.write_bytes(b"\xff\xd8") - config = Config(background_path=str(wallpaper)) - result = resolve_background_path(config) - assert result == wallpaper - - def test_config_path_skipped_when_file_missing(self, tmp_path: Path): - """Config path should be skipped if the file does not exist.""" - config = Config(background_path="/nonexistent/wallpaper.jpg") - with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): - result = resolve_background_path(config) - assert result == PACKAGE_WALLPAPER - - def test_moonarch_default_used_when_no_config(self, tmp_path: Path): - """Moonarch system wallpaper is used when config has no background_path.""" - moonarch_wp = tmp_path / "wallpaper.jpg" - moonarch_wp.write_bytes(b"\xff\xd8") - config = Config(background_path=None) - with patch("moonlock.config.MOONARCH_WALLPAPER", moonarch_wp): - result = resolve_background_path(config) - assert result == moonarch_wp - - def test_moonarch_default_skipped_when_missing(self, tmp_path: Path): - """Falls back to package wallpaper when Moonarch default is missing.""" - config = Config(background_path=None) - with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): - result = resolve_background_path(config) - assert result == PACKAGE_WALLPAPER - - def test_package_fallback_always_exists(self): - """The package fallback wallpaper must always be present.""" - assert PACKAGE_WALLPAPER.is_file() - - def test_full_fallback_chain(self, tmp_path: Path): - """With no config and no Moonarch default, package fallback is returned.""" - config = Config() - with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): - result = resolve_background_path(config) - assert result == PACKAGE_WALLPAPER diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 1b730b1..0000000 --- a/uv.lock +++ /dev/null @@ -1,45 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "moonlock" -version = "0.2.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" }