- Replace os.getlogin() with pwd.getpwuid(os.getuid()) to prevent crashes in systemd/display-manager sessions without a controlling tty - Cache libpam and libc at module level instead of calling find_library() on every auth attempt (spawned ldconfig subprocess each time) - Disable password entry permanently after FAILLOCK_MAX_ATTEMPTS instead of just showing a warning while allowing unlimited retries - Fix LD_PRELOAD logic to append gtk4-layer-shell instead of skipping when LD_PRELOAD is already set (caused silent session lock fallback) - Ensure password entry keeps focus after errors and escape
296 lines
11 KiB
Python
296 lines
11 KiB
Python
# ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons.
|
|
# ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow.
|
|
|
|
import gi
|
|
gi.require_version("Gtk", "4.0")
|
|
gi.require_version("Gdk", "4.0")
|
|
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
|
|
|
|
from pathlib import Path
|
|
|
|
from moonlock.auth import authenticate
|
|
from moonlock.config import Config, load_config, resolve_background_path
|
|
from moonlock.fingerprint import FingerprintListener
|
|
from moonlock.i18n import Strings, load_strings
|
|
from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User
|
|
from moonlock import power
|
|
|
|
FAILLOCK_MAX_ATTEMPTS = 3
|
|
AVATAR_SIZE = 128
|
|
|
|
|
|
class LockscreenWindow(Gtk.ApplicationWindow):
|
|
"""Fullscreen lockscreen window with password and fingerprint auth."""
|
|
|
|
def __init__(self, application: Gtk.Application, unlock_callback: callable | None = None,
|
|
config: Config | None = None) -> None:
|
|
super().__init__(application=application)
|
|
self.add_css_class("lockscreen")
|
|
|
|
self._config = config or load_config()
|
|
self._strings = load_strings()
|
|
self._user = get_current_user()
|
|
self._failed_attempts = 0
|
|
self._unlock_callback = unlock_callback
|
|
|
|
# Fingerprint listener
|
|
self._fp_listener = FingerprintListener()
|
|
self._fp_available = (
|
|
self._config.fingerprint_enabled
|
|
and self._fp_listener.is_available(self._user.username)
|
|
)
|
|
|
|
self._build_ui()
|
|
self._setup_keyboard()
|
|
self._password_entry.grab_focus()
|
|
|
|
# Start fingerprint listener if available
|
|
if self._fp_available:
|
|
self._fp_listener.start(
|
|
self._user.username,
|
|
on_success=self._on_fingerprint_success,
|
|
on_failure=self._on_fingerprint_failure,
|
|
)
|
|
|
|
def _build_ui(self) -> None:
|
|
"""Build the lockscreen layout."""
|
|
# Main overlay for background + centered content
|
|
overlay = Gtk.Overlay()
|
|
self.set_child(overlay)
|
|
|
|
# Background wallpaper
|
|
wallpaper_path = resolve_background_path(self._config)
|
|
background = Gtk.Picture.new_for_filename(str(wallpaper_path))
|
|
background.set_content_fit(Gtk.ContentFit.COVER)
|
|
background.set_hexpand(True)
|
|
background.set_vexpand(True)
|
|
overlay.set_child(background)
|
|
|
|
# Centered vertical box
|
|
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
main_box.set_halign(Gtk.Align.CENTER)
|
|
main_box.set_valign(Gtk.Align.CENTER)
|
|
overlay.add_overlay(main_box)
|
|
|
|
# Login box (centered card)
|
|
self._login_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self._login_box.set_halign(Gtk.Align.CENTER)
|
|
self._login_box.add_css_class("login-box")
|
|
main_box.append(self._login_box)
|
|
|
|
# Avatar — wrapped in a clipping frame for round shape
|
|
avatar_frame = Gtk.Box()
|
|
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE)
|
|
avatar_frame.set_halign(Gtk.Align.CENTER)
|
|
avatar_frame.set_overflow(Gtk.Overflow.HIDDEN)
|
|
avatar_frame.add_css_class("avatar")
|
|
self._avatar_image = Gtk.Image()
|
|
self._avatar_image.set_pixel_size(AVATAR_SIZE)
|
|
avatar_frame.append(self._avatar_image)
|
|
self._login_box.append(avatar_frame)
|
|
|
|
avatar_path = get_avatar_path(self._user.home, self._user.username)
|
|
if avatar_path:
|
|
self._set_avatar_from_file(avatar_path)
|
|
else:
|
|
self._set_default_avatar()
|
|
|
|
# Username label
|
|
username_label = Gtk.Label(label=self._user.display_name)
|
|
username_label.add_css_class("username-label")
|
|
self._login_box.append(username_label)
|
|
|
|
# Password entry
|
|
self._password_entry = Gtk.PasswordEntry()
|
|
self._password_entry.set_property("placeholder-text", self._strings.password_placeholder)
|
|
self._password_entry.set_property("show-peek-icon", True)
|
|
self._password_entry.add_css_class("password-entry")
|
|
self._password_entry.connect("activate", self._on_password_submit)
|
|
self._login_box.append(self._password_entry)
|
|
|
|
# Error label
|
|
self._error_label = Gtk.Label()
|
|
self._error_label.add_css_class("error-label")
|
|
self._error_label.set_visible(False)
|
|
self._login_box.append(self._error_label)
|
|
|
|
# Fingerprint status label
|
|
self._fp_label = Gtk.Label()
|
|
self._fp_label.add_css_class("fingerprint-label")
|
|
if self._fp_available:
|
|
self._fp_label.set_text(self._strings.fingerprint_prompt)
|
|
self._fp_label.set_visible(True)
|
|
else:
|
|
self._fp_label.set_visible(False)
|
|
self._login_box.append(self._fp_label)
|
|
|
|
# Power buttons (bottom right)
|
|
power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
|
power_box.set_halign(Gtk.Align.END)
|
|
power_box.set_valign(Gtk.Align.END)
|
|
power_box.set_hexpand(True)
|
|
power_box.set_vexpand(True)
|
|
power_box.set_margin_end(16)
|
|
power_box.set_margin_bottom(16)
|
|
overlay.add_overlay(power_box)
|
|
|
|
reboot_btn = Gtk.Button(icon_name="system-reboot-symbolic")
|
|
reboot_btn.add_css_class("power-button")
|
|
reboot_btn.set_tooltip_text(self._strings.reboot_tooltip)
|
|
reboot_btn.connect("clicked", lambda _: self._on_power_action(power.reboot))
|
|
power_box.append(reboot_btn)
|
|
|
|
shutdown_btn = Gtk.Button(icon_name="system-shutdown-symbolic")
|
|
shutdown_btn.add_css_class("power-button")
|
|
shutdown_btn.set_tooltip_text(self._strings.shutdown_tooltip)
|
|
shutdown_btn.connect("clicked", lambda _: self._on_power_action(power.shutdown))
|
|
power_box.append(shutdown_btn)
|
|
|
|
def _setup_keyboard(self) -> None:
|
|
"""Set up keyboard event handling."""
|
|
controller = Gtk.EventControllerKey()
|
|
controller.connect("key-pressed", self._on_key_pressed)
|
|
self.add_controller(controller)
|
|
|
|
def _on_key_pressed(self, controller: Gtk.EventControllerKey, keyval: int,
|
|
keycode: int, state: Gdk.ModifierType) -> bool:
|
|
"""Handle key presses — Escape clears the password field."""
|
|
if keyval == Gdk.KEY_Escape:
|
|
self._password_entry.set_text("")
|
|
self._error_label.set_visible(False)
|
|
self._password_entry.grab_focus()
|
|
return True
|
|
return False
|
|
|
|
def _on_password_submit(self, entry: Gtk.PasswordEntry) -> None:
|
|
"""Handle password submission via Enter key."""
|
|
password = entry.get_text()
|
|
if not password:
|
|
return
|
|
|
|
# Run PAM auth in a thread to avoid blocking the UI
|
|
entry.set_sensitive(False)
|
|
|
|
def _do_auth() -> bool:
|
|
return authenticate(self._user.username, password)
|
|
|
|
def _on_auth_done(result: bool) -> None:
|
|
if result:
|
|
self._unlock()
|
|
return
|
|
|
|
self._failed_attempts += 1
|
|
entry.set_text("")
|
|
|
|
if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS:
|
|
# Permanently disable entry after max failed attempts
|
|
self._show_error(self._strings.faillock_locked)
|
|
entry.set_sensitive(False)
|
|
else:
|
|
entry.set_sensitive(True)
|
|
entry.grab_focus()
|
|
if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1:
|
|
remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts
|
|
self._show_error(
|
|
self._strings.faillock_attempts_remaining.format(n=remaining)
|
|
)
|
|
else:
|
|
self._show_error(self._strings.wrong_password)
|
|
|
|
# Use GLib thread pool to avoid blocking GTK mainloop
|
|
def _auth_thread() -> bool:
|
|
try:
|
|
result = _do_auth()
|
|
except Exception:
|
|
result = False
|
|
GLib.idle_add(_on_auth_done, result)
|
|
return GLib.SOURCE_REMOVE
|
|
|
|
GLib.Thread.new("pam-auth", _auth_thread)
|
|
|
|
def _on_fingerprint_success(self) -> None:
|
|
"""Called when fingerprint verification succeeds."""
|
|
GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_success)
|
|
GLib.idle_add(self._fp_label.add_css_class, "success")
|
|
GLib.idle_add(self._unlock)
|
|
|
|
def _on_fingerprint_failure(self) -> None:
|
|
"""Called when fingerprint verification fails (no match)."""
|
|
GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_failed)
|
|
GLib.idle_add(self._fp_label.add_css_class, "failed")
|
|
# Reset label after 2 seconds
|
|
GLib.timeout_add(
|
|
2000,
|
|
self._reset_fp_label,
|
|
)
|
|
|
|
def _reset_fp_label(self) -> bool:
|
|
"""Reset fingerprint label to prompt state."""
|
|
self._fp_label.set_text(self._strings.fingerprint_prompt)
|
|
self._fp_label.remove_css_class("success")
|
|
self._fp_label.remove_css_class("failed")
|
|
return GLib.SOURCE_REMOVE
|
|
|
|
def _get_foreground_color(self) -> str:
|
|
"""Get the current GTK theme foreground color as a hex string."""
|
|
rgba = self.get_color()
|
|
r = int(rgba.red * 255)
|
|
g = int(rgba.green * 255)
|
|
b = int(rgba.blue * 255)
|
|
return f"#{r:02x}{g:02x}{b:02x}"
|
|
|
|
def _set_default_avatar(self) -> None:
|
|
"""Load the default avatar SVG, tinted with the GTK foreground color."""
|
|
try:
|
|
default_path = get_default_avatar_path()
|
|
svg_text = default_path.read_text()
|
|
fg_color = self._get_foreground_color()
|
|
svg_text = svg_text.replace("#PLACEHOLDER", fg_color)
|
|
svg_bytes = svg_text.encode("utf-8")
|
|
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
|
|
loader.set_size(AVATAR_SIZE, AVATAR_SIZE)
|
|
loader.write(svg_bytes)
|
|
loader.close()
|
|
pixbuf = loader.get_pixbuf()
|
|
if pixbuf:
|
|
self._avatar_image.set_from_pixbuf(pixbuf)
|
|
return
|
|
except (GLib.Error, OSError):
|
|
pass
|
|
self._avatar_image.set_from_icon_name("avatar-default-symbolic")
|
|
|
|
def _set_avatar_from_file(self, path: Path) -> None:
|
|
"""Load an image file and set it as the avatar, scaled to AVATAR_SIZE."""
|
|
try:
|
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
|
str(path), AVATAR_SIZE, AVATAR_SIZE, True
|
|
)
|
|
self._avatar_image.set_from_pixbuf(pixbuf)
|
|
except GLib.Error:
|
|
self._set_default_avatar()
|
|
|
|
def _show_error(self, message: str) -> None:
|
|
"""Display an error message."""
|
|
self._error_label.set_text(message)
|
|
self._error_label.set_visible(True)
|
|
|
|
def _unlock(self) -> None:
|
|
"""Unlock the screen after successful authentication."""
|
|
# Stop fingerprint listener
|
|
self._fp_listener.stop()
|
|
|
|
if self._unlock_callback:
|
|
self._unlock_callback()
|
|
|
|
def _on_power_action(self, action: callable) -> None:
|
|
"""Execute a power action (reboot/shutdown)."""
|
|
try:
|
|
action()
|
|
except Exception:
|
|
error_msg = (
|
|
self._strings.reboot_failed
|
|
if action == power.reboot
|
|
else self._strings.shutdown_failed
|
|
)
|
|
self._show_error(error_msg)
|