Compare commits

..

3 Commits

Author SHA1 Message Date
4968b7ee2d Merge rust-rewrite: moonlock v0.4.0 Rust rewrite 2026-03-27 23:18:03 +01:00
60e63a6857 Fix Rust 2024 unsafe block warnings in PAM callback
Wrap raw pointer operations in explicit unsafe blocks inside
the unsafe extern "C" conv callback, as required by Rust 2024
edition. Remove unused mut binding.
2026-03-27 23:13:23 +01:00
817a9547ad Rewrite moonlock from Python to Rust (v0.4.0)
Complete rewrite of the Wayland lockscreen from Python/PyGObject to
Rust/gtk4-rs for memory safety in security-critical PAM code and
consistency with the moonset/moongreet Rust ecosystem.

Modules: main, lockscreen, auth (PAM FFI), fingerprint (fprintd D-Bus),
config, i18n, users, power. 37 unit tests.

Security: PAM conversation callback with Zeroizing password, panic hook
that never unlocks, root check, ext-session-lock-v1 compositor policy,
absolute loginctl path, avatar symlink rejection.
2026-03-27 23:09:54 +01:00
41 changed files with 3077 additions and 2264 deletions

8
.gitignore vendored
View File

@ -1,10 +1,4 @@
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
/target
# makepkg build artifacts
pkg/src/

View File

@ -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<Vec<u8>>)
- `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<Vec<u8>> 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

1334
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
Cargo.toml Normal file
View File

@ -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"

73
README.md Normal file
View File

@ -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

13
build.rs Normal file
View File

@ -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");
}

View File

@ -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"

View File

@ -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"

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/dev/moonarch/moonlock">
<file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>

View File

@ -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;

View File

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 366 KiB

192
src/auth.rs Normal file
View File

@ -0,0 +1,192 @@
// 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 {
unsafe {
// 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::<PamResponse>() 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);
}
}

87
src/config.rs Normal file
View File

@ -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<PathBuf> {
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<String>,
#[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::<Config>(&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"));
}
}

299
src/fingerprint.rs Normal file
View File

@ -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<gio::DBusProxy>,
signal_id: Option<glib::SignalHandlerId>,
running: bool,
failed_attempts: u32,
on_success: Option<Box<dyn Fn() + 'static>>,
on_failure: Option<Box<dyn Fn() + 'static>>,
}
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::<String>(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<String> = result.child_get::<Vec<String>>(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<F, G>(&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);
}
}

140
src/i18n.rs Normal file
View File

@ -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<String> {
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<String> {
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"); }
}

522
src/lockscreen.rs Normal file
View File

@ -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: &gtk::Application,
unlock_callback: Rc<dyn Fn()>,
) -> 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 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<RefCell<Option<gtk::Box>>> = 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: &gtk::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: &gtk::Image, window: &gtk::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: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::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: &gtk::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
if let Some(box_widget) = confirm_box.borrow_mut().take() {
confirm_area.remove(&box_widget);
}
}
/// Execute a power action in a background thread.
fn execute_power_action(
action_fn: fn() -> Result<(), PowerError>,
error_message: &'static str,
error_label: &gtk::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);
}
}

164
src/main.rs Normal file
View File

@ -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: &gtk::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: &gtk::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<dyn Fn()> = 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::<gdk::Monitor>().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: &gtk::Application,
bg_path: &PathBuf,
config: &config::Config,
) {
let app_clone = app.clone();
let unlock_callback: Rc<dyn Fn()> = 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();
}

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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"))

52
src/power.rs Normal file
View File

@ -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()); }
}

93
src/users.rs Normal file
View File

@ -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<User> {
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<PathBuf> {
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<PathBuf> {
// ~/.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"));
}
}

View File

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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

45
uv.lock generated
View File

@ -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" }