From d1c0b741faf53edb7e5bc96c3ea040315488761c Mon Sep 17 00:00:00 2001 From: nevaforget Date: Thu, 26 Mar 2026 12:28:17 +0100 Subject: [PATCH] Initial project setup with core modules Moonlock lockscreen scaffolding: PAM auth (ctypes), fprintd D-Bus listener, i18n (DE/EN), user detection, power actions. 33 tests passing. --- .gitignore | 7 + CLAUDE.md | 53 ++++++ pyproject.toml | 30 ++++ src/moonlock/__init__.py | 2 + src/moonlock/auth.py | 111 +++++++++++++ src/moonlock/data/default-avatar.svg | 1 + .../moongreet-default-avatar-symbolic.svg | 1 + src/moonlock/fingerprint.py | 155 ++++++++++++++++++ src/moonlock/i18n.py | 97 +++++++++++ src/moonlock/power.py | 14 ++ src/moonlock/users.py | 58 +++++++ tests/__init__.py | 0 tests/test_auth.py | 65 ++++++++ tests/test_fingerprint.py | 121 ++++++++++++++ tests/test_i18n.py | 67 ++++++++ tests/test_power.py | 24 +++ tests/test_users.py | 81 +++++++++ uv.lock | 45 +++++ 18 files changed, 932 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 pyproject.toml create mode 100644 src/moonlock/__init__.py create mode 100644 src/moonlock/auth.py create mode 100644 src/moonlock/data/default-avatar.svg create mode 100644 src/moonlock/data/icons/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg create mode 100644 src/moonlock/fingerprint.py create mode 100644 src/moonlock/i18n.py create mode 100644 src/moonlock/power.py create mode 100644 src/moonlock/users.py create mode 100644 tests/__init__.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_fingerprint.py create mode 100644 tests/test_i18n.py create mode 100644 tests/test_power.py create mode 100644 tests/test_users.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e079ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..19eeb82 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# Moonlock + +**Name**: Nyx (Göttin der Nacht — passend zum Lockscreen, der den Bildschirm verdunkelt) + +## 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. + +## 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 + +## Projektstruktur + +- `src/moonlock/` — Quellcode +- `src/moonlock/data/` — Package-Assets (Default-Avatar, Icons) +- `tests/` — pytest Tests +- `config/` — Beispiel-Konfigurationsdateien + +## Kommandos + +```bash +# Tests ausführen +uv run pytest tests/ -v + +# Typ-Checks +uv run pyright src/ + +# Lockscreen starten (zum Testen) +uv run 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) + +## 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5618245 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "moonlock" +version = "0.1.0" +description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" +requires-python = ">=3.11" +license = "MIT" +dependencies = [ + "PyGObject>=3.46", +] + +[project.scripts] +moonlock = "moonlock.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/moonlock"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.pyright] +pythonVersion = "3.11" +pythonPlatform = "Linux" +venvPath = "." +venv = ".venv" +typeCheckingMode = "standard" diff --git a/src/moonlock/__init__.py b/src/moonlock/__init__.py new file mode 100644 index 0000000..c681af5 --- /dev/null +++ b/src/moonlock/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Package init for moonlock — a secure Wayland lockscreen. +# ABOUTME: Uses ext-session-lock-v1, PAM auth and fprintd fingerprint support. diff --git a/src/moonlock/auth.py b/src/moonlock/auth.py new file mode 100644 index 0000000..65e2aaf --- /dev/null +++ b/src/moonlock/auth.py @@ -0,0 +1,111 @@ +# 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).""" + + _fields_ = [ + ("resp", c_char_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), + ] + + +def _get_libpam() -> ctypes.CDLL: + """Load and return the libpam shared library.""" + pam_path = ctypes.util.find_library("pam") + if not pam_path: + raise OSError("libpam not found") + return ctypes.CDLL(pam_path) + + +def _make_conv_func(password: str) -> PamConvFunc: + """Create a PAM conversation callback that provides the password.""" + + def _conv( + num_msg: int, + msg: POINTER(POINTER(PamMessage)), + resp: POINTER(POINTER(PamResponse)), + appdata_ptr: c_void_p, + ) -> int: + # Allocate response array + response = (PamResponse * num_msg)() + for i in range(num_msg): + response[i].resp = password.encode("utf-8") + response[i].resp_retcode = 0 + # PAM expects malloc'd memory; ctypes handles this via the array + resp[0] = ctypes.cast(response, POINTER(PamResponse)) + return PAM_SUCCESS + + return PamConvFunc(_conv) + + +def authenticate(username: str, password: str) -> bool: + """Authenticate a user via PAM. Returns True on success, False otherwise.""" + libpam = _get_libpam() + + # Set up conversation + conv_func = _make_conv_func(password) + 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: + 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) diff --git a/src/moonlock/data/default-avatar.svg b/src/moonlock/data/default-avatar.svg new file mode 100644 index 0000000..e3da366 --- /dev/null +++ b/src/moonlock/data/default-avatar.svg @@ -0,0 +1 @@ + diff --git a/src/moonlock/data/icons/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg b/src/moonlock/data/icons/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg new file mode 100644 index 0000000..9db9ddc --- /dev/null +++ b/src/moonlock/data/icons/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg @@ -0,0 +1 @@ + diff --git a/src/moonlock/fingerprint.py b/src/moonlock/fingerprint.py new file mode 100644 index 0000000..5d4ea3e --- /dev/null +++ b/src/moonlock/fingerprint.py @@ -0,0 +1,155 @@ +# ABOUTME: fprintd D-Bus integration for fingerprint authentication. +# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop. + +from typing import Callable + +import gi +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + +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" + +# 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._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.""" + 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 + self._running = True + + self._device_proxy.Claim("(s)", username) + + # Connect to the VerifyStatus signal + self._signal_id = self._device_proxy.connect( + "g-signal", self._on_signal + ) + + self._device_proxy.VerifyStart("(s)", "any") + + 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 + + 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 silently — finger wasn't read properly + self._device_proxy.VerifyStart("(s)", "any") + return + + if status == "verify-no-match": + if self._on_failure: + self._on_failure() + # Restart verification for another attempt + self._device_proxy.VerifyStart("(s)", "any") + return diff --git a/src/moonlock/i18n.py b/src/moonlock/i18n.py new file mode 100644 index 0000000..989c219 --- /dev/null +++ b/src/moonlock/i18n.py @@ -0,0 +1,97 @@ +# 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 + + # 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", + 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", + faillock_attempts_remaining="{n} attempt(s) remaining before lockout!", + faillock_locked="Account may be locked", +) + +_LOCALE_MAP: dict[str, Strings] = { + "de": _STRINGS_DE, + "en": _STRINGS_EN, +} + + +def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str: + """Determine the system language from LANG env var or /etc/locale.conf.""" + lang = os.environ.get("LANG") + + if not lang and locale_conf_path.exists(): + for line in locale_conf_path.read_text().splitlines(): + if line.startswith("LANG="): + lang = line.split("=", 1)[1].strip() + break + + if not lang or lang in ("C", "POSIX"): + return "en" + + # Extract language prefix: "de_DE.UTF-8" → "de" + lang = lang.split("_")[0].split(".")[0] + return lang + + +def load_strings(locale: str | None = None) -> Strings: + """Return the string table for the given locale, defaulting to English.""" + if locale is None: + locale = detect_locale() + return _LOCALE_MAP.get(locale, _STRINGS_EN) diff --git a/src/moonlock/power.py b/src/moonlock/power.py new file mode 100644 index 0000000..4798116 --- /dev/null +++ b/src/moonlock/power.py @@ -0,0 +1,14 @@ +# ABOUTME: Power actions — reboot and shutdown via loginctl. +# ABOUTME: Simple wrappers around system commands for the lockscreen UI. + +import subprocess + + +def reboot() -> None: + """Reboot the system via loginctl.""" + subprocess.run(["loginctl", "reboot"], check=True) + + +def shutdown() -> None: + """Shut down the system via loginctl.""" + subprocess.run(["loginctl", "poweroff"], check=True) diff --git a/src/moonlock/users.py b/src/moonlock/users.py new file mode 100644 index 0000000..2bad10e --- /dev/null +++ b/src/moonlock/users.py @@ -0,0 +1,58 @@ +# 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 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.""" + username = os.getlogin() + pw = pwd.getpwnam(username) + + 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 username + if not display_name: + display_name = username + + 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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..ef53b4a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,65 @@ +# ABOUTME: Tests for PAM authentication via ctypes wrapper. +# ABOUTME: Verifies authenticate() calls libpam correctly and handles success/failure. + +from unittest.mock import patch, MagicMock, ANY +import ctypes + +from moonlock.auth import authenticate, PAM_SUCCESS, PAM_AUTH_ERR + + +class TestAuthenticate: + """Tests for PAM authentication.""" + + @patch("moonlock.auth._get_libpam") + def test_returns_true_on_successful_auth(self, mock_get_libpam): + libpam = MagicMock() + mock_get_libpam.return_value = libpam + libpam.pam_start.return_value = PAM_SUCCESS + libpam.pam_authenticate.return_value = PAM_SUCCESS + libpam.pam_acct_mgmt.return_value = PAM_SUCCESS + libpam.pam_end.return_value = PAM_SUCCESS + + assert authenticate("testuser", "correctpassword") is True + + @patch("moonlock.auth._get_libpam") + def test_returns_false_on_wrong_password(self, mock_get_libpam): + libpam = MagicMock() + mock_get_libpam.return_value = libpam + libpam.pam_start.return_value = PAM_SUCCESS + libpam.pam_authenticate.return_value = PAM_AUTH_ERR + libpam.pam_end.return_value = PAM_SUCCESS + + assert authenticate("testuser", "wrongpassword") is False + + @patch("moonlock.auth._get_libpam") + def test_pam_end_always_called(self, mock_get_libpam): + libpam = MagicMock() + mock_get_libpam.return_value = libpam + libpam.pam_start.return_value = PAM_SUCCESS + libpam.pam_authenticate.return_value = PAM_AUTH_ERR + libpam.pam_end.return_value = PAM_SUCCESS + + authenticate("testuser", "wrongpassword") + libpam.pam_end.assert_called_once() + + @patch("moonlock.auth._get_libpam") + def test_returns_false_when_pam_start_fails(self, mock_get_libpam): + libpam = MagicMock() + mock_get_libpam.return_value = libpam + libpam.pam_start.return_value = PAM_AUTH_ERR + + assert authenticate("testuser", "password") is False + + @patch("moonlock.auth._get_libpam") + def test_uses_moonlock_as_service_name(self, mock_get_libpam): + libpam = MagicMock() + mock_get_libpam.return_value = libpam + libpam.pam_start.return_value = PAM_SUCCESS + libpam.pam_authenticate.return_value = PAM_SUCCESS + libpam.pam_acct_mgmt.return_value = PAM_SUCCESS + libpam.pam_end.return_value = PAM_SUCCESS + + authenticate("testuser", "password") + args = libpam.pam_start.call_args + # First positional arg should be the service name + assert args[0][0] == b"moonlock" diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py new file mode 100644 index 0000000..9a76d0d --- /dev/null +++ b/tests/test_fingerprint.py @@ -0,0 +1,121 @@ +# 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._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 + 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(self): + listener = FingerprintListener.__new__(FingerprintListener) + listener._device_proxy = MagicMock() + listener._running = True + 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 restart verification for retry + listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") + + def test_verify_swipe_too_short_retries_without_failure(self): + listener = FingerprintListener.__new__(FingerprintListener) + listener._device_proxy = MagicMock() + listener._running = True + 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") diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..0f0db97 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,67 @@ +# ABOUTME: Tests for locale detection and string lookup. +# ABOUTME: Verifies correct language detection from env vars and /etc/locale.conf. + +import os +from pathlib import Path +from unittest.mock import patch + +from moonlock.i18n import Strings, detect_locale, load_strings + + +class TestDetectLocale: + """Tests for locale detection.""" + + @patch.dict(os.environ, {"LANG": "de_DE.UTF-8"}) + def test_detects_german_from_env(self): + assert detect_locale() == "de" + + @patch.dict(os.environ, {"LANG": "en_US.UTF-8"}) + def test_detects_english_from_env(self): + assert detect_locale() == "en" + + @patch.dict(os.environ, {"LANG": ""}, clear=False) + def test_reads_locale_conf_when_env_empty(self, tmp_path: Path): + locale_conf = tmp_path / "locale.conf" + locale_conf.write_text("LANG=de_DE.UTF-8\n") + assert detect_locale(locale_conf_path=locale_conf) == "de" + + @patch.dict(os.environ, {"LANG": "C"}) + def test_c_locale_defaults_to_english(self): + assert detect_locale() == "en" + + @patch.dict(os.environ, {"LANG": "POSIX"}) + def test_posix_locale_defaults_to_english(self): + assert detect_locale() == "en" + + @patch.dict(os.environ, {}, clear=True) + def test_missing_env_and_no_file_defaults_to_english(self, tmp_path: Path): + nonexistent = tmp_path / "nonexistent" + assert detect_locale(locale_conf_path=nonexistent) == "en" + + +class TestLoadStrings: + """Tests for string table loading.""" + + def test_german_strings(self): + strings = load_strings("de") + assert isinstance(strings, Strings) + assert strings.password_placeholder == "Passwort" + + def test_english_strings(self): + strings = load_strings("en") + assert strings.password_placeholder == "Password" + + def test_unknown_locale_falls_back_to_english(self): + strings = load_strings("fr") + assert strings.password_placeholder == "Password" + + def test_lockscreen_specific_strings_exist(self): + strings = load_strings("de") + assert strings.unlock_button is not None + assert strings.fingerprint_prompt is not None + assert strings.fingerprint_success is not None + + def test_faillock_template_strings(self): + strings = load_strings("de") + msg = strings.faillock_attempts_remaining.format(n=2) + assert "2" in msg diff --git a/tests/test_power.py b/tests/test_power.py new file mode 100644 index 0000000..a1cac9c --- /dev/null +++ b/tests/test_power.py @@ -0,0 +1,24 @@ +# ABOUTME: Tests for power actions (reboot, shutdown). +# ABOUTME: Verifies loginctl commands are called correctly. + +from unittest.mock import patch, call + +from moonlock.power import reboot, shutdown + + +class TestReboot: + """Tests for the reboot function.""" + + @patch("moonlock.power.subprocess.run") + def test_reboot_calls_loginctl(self, mock_run): + reboot() + mock_run.assert_called_once_with(["loginctl", "reboot"], check=True) + + +class TestShutdown: + """Tests for the shutdown function.""" + + @patch("moonlock.power.subprocess.run") + def test_shutdown_calls_loginctl(self, mock_run): + shutdown() + mock_run.assert_called_once_with(["loginctl", "poweroff"], check=True) diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..3890638 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,81 @@ +# 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, User + + +class TestGetCurrentUser: + """Tests for current user detection.""" + + @patch("moonlock.users.os.getlogin", return_value="testuser") + @patch("moonlock.users.pwd.getpwnam") + def test_returns_user_with_correct_username(self, mock_pwd, mock_login): + 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") + + @patch("moonlock.users.os.getlogin", return_value="testuser") + @patch("moonlock.users.pwd.getpwnam") + def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_login): + 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.getlogin", return_value="testuser") + @patch("moonlock.users.pwd.getpwnam") + def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_login): + 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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4104f8a --- /dev/null +++ b/uv.lock @@ -0,0 +1,45 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "moonlock" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pygobject" }, +] + +[package.metadata] +requires-dist = [{ name = "pygobject", specifier = ">=3.46" }] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, + { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, + { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, + { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, +] + +[[package]] +name = "pygobject" +version = "3.56.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }