Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb11c551bd | |||
| 5fda0dce0c | |||
| 3f31387305 |
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# Maintainer: Dominik Kressler
|
# Maintainer: Dominik Kressler
|
||||||
|
|
||||||
pkgname=moonlock-git
|
pkgname=moonlock-git
|
||||||
pkgver=0.1.1.r0.g22f725e
|
pkgver=0.2.0.r0.g7cee4f4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
|
pkgdesc="A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "moonlock"
|
name = "moonlock"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
|
description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+12
-7
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import ctypes.util
|
import ctypes.util
|
||||||
from ctypes import POINTER, Structure, c_char_p, c_int, c_void_p, CFUNCTYPE, pointer, c_char
|
from ctypes import POINTER, Structure, c_char_p, c_int, c_void_p, CFUNCTYPE, pointer
|
||||||
|
|
||||||
# PAM return codes
|
# PAM return codes
|
||||||
PAM_SUCCESS = 0
|
PAM_SUCCESS = 0
|
||||||
@@ -81,8 +81,14 @@ def _get_libc() -> ctypes.CDLL:
|
|||||||
return _cached_libc
|
return _cached_libc
|
||||||
|
|
||||||
|
|
||||||
def _make_conv_func(password: str) -> PamConvFunc:
|
def _make_conv_func(password_bytes: bytearray) -> PamConvFunc:
|
||||||
"""Create a PAM conversation callback that provides the password."""
|
"""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(
|
def _conv(
|
||||||
num_msg: int,
|
num_msg: int,
|
||||||
@@ -91,7 +97,6 @@ def _make_conv_func(password: str) -> PamConvFunc:
|
|||||||
appdata_ptr: c_void_p,
|
appdata_ptr: c_void_p,
|
||||||
) -> int:
|
) -> int:
|
||||||
# PAM expects malloc'd memory — it will free() the responses and resp strings
|
# PAM expects malloc'd memory — it will free() the responses and resp strings
|
||||||
libc = _get_libc()
|
|
||||||
resp_array = libc.calloc(num_msg, ctypes.sizeof(PamResponse))
|
resp_array = libc.calloc(num_msg, ctypes.sizeof(PamResponse))
|
||||||
if not resp_array:
|
if not resp_array:
|
||||||
return PAM_AUTH_ERR
|
return PAM_AUTH_ERR
|
||||||
@@ -99,7 +104,7 @@ def _make_conv_func(password: str) -> PamConvFunc:
|
|||||||
resp_ptr = ctypes.cast(resp_array, POINTER(PamResponse))
|
resp_ptr = ctypes.cast(resp_array, POINTER(PamResponse))
|
||||||
for i in range(num_msg):
|
for i in range(num_msg):
|
||||||
# strdup allocates with malloc, which PAM can safely free()
|
# strdup allocates with malloc, which PAM can safely free()
|
||||||
resp_ptr[i].resp = libc.strdup(password.encode("utf-8"))
|
resp_ptr[i].resp = libc.strdup(bytes(password_bytes))
|
||||||
resp_ptr[i].resp_retcode = 0
|
resp_ptr[i].resp_retcode = 0
|
||||||
|
|
||||||
resp[0] = resp_ptr
|
resp[0] = resp_ptr
|
||||||
@@ -122,8 +127,8 @@ def authenticate(username: str, password: str) -> bool:
|
|||||||
# Use a mutable bytearray so we can wipe the password after use
|
# Use a mutable bytearray so we can wipe the password after use
|
||||||
password_bytes = bytearray(password.encode("utf-8"))
|
password_bytes = bytearray(password.encode("utf-8"))
|
||||||
|
|
||||||
# Set up conversation
|
# Set up conversation — pass bytearray, not the str
|
||||||
conv_func = _make_conv_func(password)
|
conv_func = _make_conv_func(password_bytes)
|
||||||
conv = PamConv(conv=conv_func, appdata_ptr=None)
|
conv = PamConv(conv=conv_func, appdata_ptr=None)
|
||||||
|
|
||||||
# PAM handle
|
# PAM handle
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# ABOUTME: fprintd D-Bus integration for fingerprint authentication.
|
# ABOUTME: fprintd D-Bus integration for fingerprint authentication.
|
||||||
# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop.
|
# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop.
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gio", "2.0")
|
gi.require_version("Gio", "2.0")
|
||||||
from gi.repository import Gio, GLib
|
from gi.repository import Gio, GLib
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FPRINTD_BUS_NAME = "net.reactivated.Fprint"
|
FPRINTD_BUS_NAME = "net.reactivated.Fprint"
|
||||||
FPRINTD_MANAGER_PATH = "/net/reactivated/Fprint/Manager"
|
FPRINTD_MANAGER_PATH = "/net/reactivated/Fprint/Manager"
|
||||||
FPRINTD_MANAGER_IFACE = "net.reactivated.Fprint.Manager"
|
FPRINTD_MANAGER_IFACE = "net.reactivated.Fprint.Manager"
|
||||||
FPRINTD_DEVICE_IFACE = "net.reactivated.Fprint.Device"
|
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-able statuses (finger not read properly, try again)
|
||||||
_RETRY_STATUSES = {
|
_RETRY_STATUSES = {
|
||||||
"verify-swipe-too-short",
|
"verify-swipe-too-short",
|
||||||
@@ -29,13 +35,18 @@ class FingerprintListener:
|
|||||||
self._device_path: str | None = None
|
self._device_path: str | None = None
|
||||||
self._signal_id: int | None = None
|
self._signal_id: int | None = None
|
||||||
self._running: bool = False
|
self._running: bool = False
|
||||||
|
self._failed_attempts: int = 0
|
||||||
self._on_success: Callable[[], None] | None = None
|
self._on_success: Callable[[], None] | None = None
|
||||||
self._on_failure: Callable[[], None] | None = None
|
self._on_failure: Callable[[], None] | None = None
|
||||||
|
|
||||||
self._init_device()
|
self._init_device()
|
||||||
|
|
||||||
def _init_device(self) -> None:
|
def _init_device(self) -> None:
|
||||||
"""Connect to fprintd and get the default device."""
|
"""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:
|
try:
|
||||||
manager = Gio.DBusProxy.new_for_bus_sync(
|
manager = Gio.DBusProxy.new_for_bus_sync(
|
||||||
Gio.BusType.SYSTEM,
|
Gio.BusType.SYSTEM,
|
||||||
@@ -84,16 +95,31 @@ class FingerprintListener:
|
|||||||
|
|
||||||
self._on_success = on_success
|
self._on_success = on_success
|
||||||
self._on_failure = on_failure
|
self._on_failure = on_failure
|
||||||
self._running = True
|
|
||||||
|
|
||||||
|
try:
|
||||||
self._device_proxy.Claim("(s)", username)
|
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
|
# Connect to the VerifyStatus signal
|
||||||
self._signal_id = self._device_proxy.connect(
|
self._signal_id = self._device_proxy.connect(
|
||||||
"g-signal", self._on_signal
|
"g-signal", self._on_signal
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
self._device_proxy.VerifyStart("(s)", "any")
|
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:
|
def stop(self) -> None:
|
||||||
"""Stop listening and release the device."""
|
"""Stop listening and release the device."""
|
||||||
@@ -128,6 +154,13 @@ class FingerprintListener:
|
|||||||
if signal_name != "VerifyStatus":
|
if signal_name != "VerifyStatus":
|
||||||
return
|
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]
|
status = parameters[0]
|
||||||
done = parameters[1]
|
done = parameters[1]
|
||||||
self._on_verify_status(status, done)
|
self._on_verify_status(status, done)
|
||||||
@@ -144,14 +177,25 @@ class FingerprintListener:
|
|||||||
|
|
||||||
if status in _RETRY_STATUSES:
|
if status in _RETRY_STATUSES:
|
||||||
# Retry — finger wasn't read properly
|
# Retry — finger wasn't read properly
|
||||||
if done:
|
if done and self._running and self._device_proxy:
|
||||||
|
try:
|
||||||
self._device_proxy.VerifyStart("(s)", "any")
|
self._device_proxy.VerifyStart("(s)", "any")
|
||||||
|
except GLib.Error as e:
|
||||||
|
logger.error("Failed to restart fingerprint verification: %s", e.message)
|
||||||
return
|
return
|
||||||
|
|
||||||
if status == "verify-no-match":
|
if status == "verify-no-match":
|
||||||
|
self._failed_attempts += 1
|
||||||
if self._on_failure:
|
if self._on_failure:
|
||||||
self._on_failure()
|
self._on_failure()
|
||||||
# Restart verification for another attempt
|
if self._failed_attempts >= _MAX_FP_ATTEMPTS:
|
||||||
if done:
|
logger.warning("Fingerprint max attempts (%d) reached, stopping listener", _MAX_FP_ATTEMPTS)
|
||||||
self._device_proxy.VerifyStart("(s)", "any")
|
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
|
return
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class Strings:
|
|||||||
reboot_failed: str
|
reboot_failed: str
|
||||||
shutdown_failed: str
|
shutdown_failed: str
|
||||||
|
|
||||||
|
# Power confirmation
|
||||||
|
reboot_confirm: str
|
||||||
|
shutdown_confirm: str
|
||||||
|
confirm_yes: str
|
||||||
|
confirm_no: str
|
||||||
|
|
||||||
# Templates (use .format())
|
# Templates (use .format())
|
||||||
faillock_attempts_remaining: str
|
faillock_attempts_remaining: str
|
||||||
faillock_locked: str
|
faillock_locked: str
|
||||||
@@ -46,6 +52,10 @@ _STRINGS_DE = Strings(
|
|||||||
wrong_password="Falsches Passwort",
|
wrong_password="Falsches Passwort",
|
||||||
reboot_failed="Neustart fehlgeschlagen",
|
reboot_failed="Neustart fehlgeschlagen",
|
||||||
shutdown_failed="Herunterfahren 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_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!",
|
||||||
faillock_locked="Konto ist möglicherweise gesperrt",
|
faillock_locked="Konto ist möglicherweise gesperrt",
|
||||||
)
|
)
|
||||||
@@ -62,6 +72,10 @@ _STRINGS_EN = Strings(
|
|||||||
wrong_password="Wrong password",
|
wrong_password="Wrong password",
|
||||||
reboot_failed="Reboot failed",
|
reboot_failed="Reboot failed",
|
||||||
shutdown_failed="Shutdown 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_attempts_remaining="{n} attempt(s) remaining before lockout!",
|
||||||
faillock_locked="Account may be locked",
|
faillock_locked="Account may be locked",
|
||||||
)
|
)
|
||||||
|
|||||||
+64
-10
@@ -6,8 +6,12 @@ gi.require_version("Gtk", "4.0")
|
|||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
|
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from moonlock.auth import authenticate
|
from moonlock.auth import authenticate
|
||||||
from moonlock.config import Config, load_config, resolve_background_path
|
from moonlock.config import Config, load_config, resolve_background_path
|
||||||
from moonlock.fingerprint import FingerprintListener
|
from moonlock.fingerprint import FingerprintListener
|
||||||
@@ -15,6 +19,8 @@ from moonlock.i18n import Strings, load_strings
|
|||||||
from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User
|
from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User
|
||||||
from moonlock import power
|
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
|
FAILLOCK_MAX_ATTEMPTS = 3
|
||||||
AVATAR_SIZE = 128
|
AVATAR_SIZE = 128
|
||||||
|
|
||||||
@@ -22,9 +28,10 @@ AVATAR_SIZE = 128
|
|||||||
class LockscreenWindow(Gtk.ApplicationWindow):
|
class LockscreenWindow(Gtk.ApplicationWindow):
|
||||||
"""Fullscreen lockscreen window with password and fingerprint auth."""
|
"""Fullscreen lockscreen window with password and fingerprint auth."""
|
||||||
|
|
||||||
def __init__(self, application: Gtk.Application, unlock_callback: callable | None = None,
|
def __init__(self, application: Gtk.Application, unlock_callback: Callable[[], None] | None = None,
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
fingerprint_listener: FingerprintListener | None = None) -> None:
|
fingerprint_listener: FingerprintListener | None = None,
|
||||||
|
wallpaper_path: Path | None = None) -> None:
|
||||||
super().__init__(application=application)
|
super().__init__(application=application)
|
||||||
self.add_css_class("lockscreen")
|
self.add_css_class("lockscreen")
|
||||||
|
|
||||||
@@ -33,6 +40,7 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
self._user = get_current_user()
|
self._user = get_current_user()
|
||||||
self._failed_attempts = 0
|
self._failed_attempts = 0
|
||||||
self._unlock_callback = unlock_callback
|
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)
|
# Fingerprint listener (shared across windows to avoid multiple device claims)
|
||||||
self._fp_listener = fingerprint_listener or FingerprintListener()
|
self._fp_listener = fingerprint_listener or FingerprintListener()
|
||||||
@@ -59,9 +67,8 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
overlay = Gtk.Overlay()
|
overlay = Gtk.Overlay()
|
||||||
self.set_child(overlay)
|
self.set_child(overlay)
|
||||||
|
|
||||||
# Background wallpaper
|
# Background wallpaper (path resolved once, shared across monitors)
|
||||||
wallpaper_path = resolve_background_path(self._config)
|
background = Gtk.Picture.new_for_filename(str(self._wallpaper_path))
|
||||||
background = Gtk.Picture.new_for_filename(str(wallpaper_path))
|
|
||||||
background.set_content_fit(Gtk.ContentFit.COVER)
|
background.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
background.set_hexpand(True)
|
background.set_hexpand(True)
|
||||||
background.set_vexpand(True)
|
background.set_vexpand(True)
|
||||||
@@ -211,9 +218,12 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
|
|
||||||
def _on_fingerprint_success(self) -> None:
|
def _on_fingerprint_success(self) -> None:
|
||||||
"""Called when fingerprint verification succeeds."""
|
"""Called when fingerprint verification succeeds."""
|
||||||
GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_success)
|
def _handle_success():
|
||||||
GLib.idle_add(self._fp_label.add_css_class, "success")
|
self._fp_label.set_text(self._strings.fingerprint_success)
|
||||||
GLib.idle_add(self._unlock)
|
self._fp_label.add_css_class("success")
|
||||||
|
self._unlock()
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
GLib.idle_add(_handle_success)
|
||||||
|
|
||||||
def _on_fingerprint_failure(self) -> None:
|
def _on_fingerprint_failure(self) -> None:
|
||||||
"""Called when fingerprint verification fails (no match)."""
|
"""Called when fingerprint verification fails (no match)."""
|
||||||
@@ -283,14 +293,58 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
if self._unlock_callback:
|
if self._unlock_callback:
|
||||||
self._unlock_callback()
|
self._unlock_callback()
|
||||||
|
|
||||||
def _on_power_action(self, action: callable) -> None:
|
def _on_power_action(self, action: Callable[[], None]) -> None:
|
||||||
"""Execute a power action (reboot/shutdown)."""
|
"""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 buttons for a power action."""
|
||||||
|
# Replace error label with confirmation prompt
|
||||||
|
self._error_label.set_text(message)
|
||||||
|
self._error_label.set_visible(True)
|
||||||
|
self._error_label.remove_css_class("error-label")
|
||||||
|
self._error_label.add_css_class("confirm-label")
|
||||||
|
|
||||||
|
# Add confirm buttons below the error label
|
||||||
|
self._confirm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self._confirm_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))
|
||||||
|
self._confirm_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())
|
||||||
|
self._confirm_box.append(no_btn)
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
action()
|
action()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("Power action failed")
|
||||||
error_msg = (
|
error_msg = (
|
||||||
self._strings.reboot_failed
|
self._strings.reboot_failed
|
||||||
if action == power.reboot
|
if action == power.reboot
|
||||||
else self._strings.shutdown_failed
|
else self._strings.shutdown_failed
|
||||||
)
|
)
|
||||||
self._show_error(error_msg)
|
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
|
||||||
|
self._error_label.set_visible(False)
|
||||||
|
self._error_label.remove_css_class("confirm-label")
|
||||||
|
self._error_label.add_css_class("error-label")
|
||||||
|
|||||||
+75
-6
@@ -1,15 +1,23 @@
|
|||||||
# ABOUTME: Entry point for Moonlock — sets up GTK Application and ext-session-lock-v1.
|
# ABOUTME: Entry point for Moonlock — sets up GTK Application and ext-session-lock-v1.
|
||||||
# ABOUTME: Handles CLI invocation, session locking, and multi-monitor support.
|
# ABOUTME: Handles CLI invocation, session locking, and multi-monitor support.
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# gtk4-layer-shell must be loaded before libwayland-client
|
# 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"
|
_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so"
|
||||||
_existing_preload = os.environ.get("LD_PRELOAD", "")
|
_existing_preload = os.environ.get("LD_PRELOAD", "")
|
||||||
if _LAYER_SHELL_LIB not in _existing_preload and os.path.exists(_LAYER_SHELL_LIB):
|
_is_testing = "pytest" in sys.modules or "unittest" in sys.modules
|
||||||
os.environ["LD_PRELOAD"] = f"{_existing_preload}:{_LAYER_SHELL_LIB}".lstrip(":")
|
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:])
|
os.execvp(sys.executable, [sys.executable, "-m", "moonlock.main"] + sys.argv[1:])
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
@@ -21,6 +29,8 @@ from moonlock.config import load_config
|
|||||||
from moonlock.fingerprint import FingerprintListener
|
from moonlock.fingerprint import FingerprintListener
|
||||||
from moonlock.lockscreen import LockscreenWindow
|
from moonlock.lockscreen import LockscreenWindow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ext-session-lock-v1 via gtk4-layer-shell
|
# ext-session-lock-v1 via gtk4-layer-shell
|
||||||
try:
|
try:
|
||||||
gi.require_version("Gtk4SessionLock", "1.0")
|
gi.require_version("Gtk4SessionLock", "1.0")
|
||||||
@@ -29,6 +39,33 @@ try:
|
|||||||
except (ValueError, ImportError):
|
except (ValueError, ImportError):
|
||||||
HAS_SESSION_LOCK = False
|
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):
|
class MoonlockApp(Gtk.Application):
|
||||||
"""GTK Application for the Moonlock lockscreen."""
|
"""GTK Application for the Moonlock lockscreen."""
|
||||||
@@ -51,26 +88,38 @@ class MoonlockApp(Gtk.Application):
|
|||||||
|
|
||||||
def _activate_with_session_lock(self) -> None:
|
def _activate_with_session_lock(self) -> None:
|
||||||
"""Lock the session using ext-session-lock-v1 protocol."""
|
"""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 = Gtk4SessionLock.Instance.new()
|
||||||
self._lock_instance.lock()
|
self._lock_instance.lock()
|
||||||
|
|
||||||
display = Gdk.Display.get_default()
|
display = Gdk.Display.get_default()
|
||||||
monitors = display.get_monitors()
|
monitors = display.get_monitors()
|
||||||
|
|
||||||
# Shared fingerprint listener across all windows (only one can claim the device)
|
|
||||||
fp_listener = FingerprintListener()
|
|
||||||
|
|
||||||
for i in range(monitors.get_n_items()):
|
for i in range(monitors.get_n_items()):
|
||||||
monitor = monitors.get_item(i)
|
monitor = monitors.get_item(i)
|
||||||
|
try:
|
||||||
window = LockscreenWindow(
|
window = LockscreenWindow(
|
||||||
application=self,
|
application=self,
|
||||||
unlock_callback=self._unlock,
|
unlock_callback=self._unlock,
|
||||||
config=self._config,
|
config=self._config,
|
||||||
fingerprint_listener=fp_listener,
|
fingerprint_listener=fp_listener,
|
||||||
|
wallpaper_path=wallpaper_path,
|
||||||
)
|
)
|
||||||
self._lock_instance.assign_window_to_monitor(window, monitor)
|
self._lock_instance.assign_window_to_monitor(window, monitor)
|
||||||
window.present()
|
window.present()
|
||||||
self._windows.append(window)
|
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:
|
def _activate_without_lock(self) -> None:
|
||||||
"""Fallback for development — no session lock, just a window."""
|
"""Fallback for development — no session lock, just a window."""
|
||||||
@@ -90,6 +139,7 @@ class MoonlockApp(Gtk.Application):
|
|||||||
|
|
||||||
def _load_css(self) -> None:
|
def _load_css(self) -> None:
|
||||||
"""Load the CSS stylesheet for the lockscreen."""
|
"""Load the CSS stylesheet for the lockscreen."""
|
||||||
|
try:
|
||||||
css_provider = Gtk.CssProvider()
|
css_provider = Gtk.CssProvider()
|
||||||
css_path = files("moonlock") / "style.css"
|
css_path = files("moonlock") / "style.css"
|
||||||
css_provider.load_from_path(str(css_path))
|
css_provider.load_from_path(str(css_path))
|
||||||
@@ -98,11 +148,30 @@ class MoonlockApp(Gtk.Application):
|
|||||||
css_provider,
|
css_provider,
|
||||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
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:
|
def main() -> None:
|
||||||
"""Run the Moonlock application."""
|
"""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()
|
app = MoonlockApp()
|
||||||
|
_install_excepthook()
|
||||||
app.run(sys.argv)
|
app.run(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class TestFingerprintListenerLifecycle:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
listener._signal_id = 42
|
listener._signal_id = 42
|
||||||
|
|
||||||
listener.stop()
|
listener.stop()
|
||||||
@@ -81,6 +82,7 @@ class TestFingerprintSignalHandling:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
on_success = MagicMock()
|
on_success = MagicMock()
|
||||||
on_failure = MagicMock()
|
on_failure = MagicMock()
|
||||||
listener._on_success = on_success
|
listener._on_success = on_success
|
||||||
@@ -94,6 +96,7 @@ class TestFingerprintSignalHandling:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
on_success = MagicMock()
|
on_success = MagicMock()
|
||||||
on_failure = MagicMock()
|
on_failure = MagicMock()
|
||||||
listener._on_success = on_success
|
listener._on_success = on_success
|
||||||
@@ -109,6 +112,7 @@ class TestFingerprintSignalHandling:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
on_success = MagicMock()
|
on_success = MagicMock()
|
||||||
on_failure = MagicMock()
|
on_failure = MagicMock()
|
||||||
listener._on_success = on_success
|
listener._on_success = on_success
|
||||||
@@ -124,6 +128,7 @@ class TestFingerprintSignalHandling:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
on_success = MagicMock()
|
on_success = MagicMock()
|
||||||
on_failure = MagicMock()
|
on_failure = MagicMock()
|
||||||
listener._on_success = on_success
|
listener._on_success = on_success
|
||||||
@@ -139,6 +144,7 @@ class TestFingerprintSignalHandling:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
on_success = MagicMock()
|
on_success = MagicMock()
|
||||||
on_failure = MagicMock()
|
on_failure = MagicMock()
|
||||||
listener._on_success = on_success
|
listener._on_success = on_success
|
||||||
@@ -150,3 +156,82 @@ class TestFingerprintSignalHandling:
|
|||||||
on_failure.assert_not_called()
|
on_failure.assert_not_called()
|
||||||
# Should NOT restart — verification still in progress
|
# Should NOT restart — verification still in progress
|
||||||
listener._device_proxy.VerifyStart.assert_not_called()
|
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
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class TestFingerprintAuthFlow:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
|
|
||||||
unlock_called = []
|
unlock_called = []
|
||||||
listener._on_success = lambda: unlock_called.append(True)
|
listener._on_success = lambda: unlock_called.append(True)
|
||||||
@@ -81,6 +82,7 @@ class TestFingerprintAuthFlow:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
listener._on_success = MagicMock()
|
listener._on_success = MagicMock()
|
||||||
listener._on_failure = MagicMock()
|
listener._on_failure = MagicMock()
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ class TestFingerprintAuthFlow:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
listener._on_success = MagicMock()
|
listener._on_success = MagicMock()
|
||||||
listener._on_failure = MagicMock()
|
listener._on_failure = MagicMock()
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ class TestFingerprintAuthFlow:
|
|||||||
listener = FingerprintListener.__new__(FingerprintListener)
|
listener = FingerprintListener.__new__(FingerprintListener)
|
||||||
listener._device_proxy = MagicMock()
|
listener._device_proxy = MagicMock()
|
||||||
listener._running = True
|
listener._running = True
|
||||||
|
listener._failed_attempts = 0
|
||||||
fp_unlock = []
|
fp_unlock = []
|
||||||
listener._on_success = lambda: fp_unlock.append(True)
|
listener._on_success = lambda: fp_unlock.append(True)
|
||||||
listener._on_failure = MagicMock()
|
listener._on_failure = MagicMock()
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# 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
|
||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.11"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moonlock"
|
name = "moonlock"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pygobject" },
|
{ name = "pygobject" },
|
||||||
|
|||||||
Reference in New Issue
Block a user