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.
This commit is contained in:
commit
d1c0b741fa
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
53
CLAUDE.md
Normal file
53
CLAUDE.md
Normal file
@ -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
|
||||||
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
@ -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"
|
||||||
2
src/moonlock/__init__.py
Normal file
2
src/moonlock/__init__.py
Normal file
@ -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.
|
||||||
111
src/moonlock/auth.py
Normal file
111
src/moonlock/auth.py
Normal file
@ -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)
|
||||||
1
src/moonlock/data/default-avatar.svg
Normal file
1
src/moonlock/data/default-avatar.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#PLACEHOLDER" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#222222" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
155
src/moonlock/fingerprint.py
Normal file
155
src/moonlock/fingerprint.py
Normal file
@ -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
|
||||||
97
src/moonlock/i18n.py
Normal file
97
src/moonlock/i18n.py
Normal file
@ -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)
|
||||||
14
src/moonlock/power.py
Normal file
14
src/moonlock/power.py
Normal file
@ -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)
|
||||||
58
src/moonlock/users.py
Normal file
58
src/moonlock/users.py
Normal file
@ -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
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
65
tests/test_auth.py
Normal file
65
tests/test_auth.py
Normal file
@ -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"
|
||||||
121
tests/test_fingerprint.py
Normal file
121
tests/test_fingerprint.py
Normal file
@ -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")
|
||||||
67
tests/test_i18n.py
Normal file
67
tests/test_i18n.py
Normal file
@ -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
|
||||||
24
tests/test_power.py
Normal file
24
tests/test_power.py
Normal file
@ -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)
|
||||||
81
tests/test_users.py
Normal file
81
tests/test_users.py
Normal file
@ -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
|
||||||
45
uv.lock
generated
Normal file
45
uv.lock
generated
Normal file
@ -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" }
|
||||||
Loading…
x
Reference in New Issue
Block a user