From 0ee451de9e824fec56e9638e8446cf8af292565a Mon Sep 17 00:00:00 2001 From: nevaforget Date: Thu, 26 Mar 2026 12:29:38 +0100 Subject: [PATCH] Add GTK4 lockscreen UI and ext-session-lock-v1 integration Lockscreen window with avatar, password entry, fingerprint indicator, power buttons. Session locking via Gtk4SessionLock (ext-session-lock-v1) with multi-monitor support and dev fallback mode. --- config/moonlock-pam | 4 + src/moonlock/lockscreen.py | 228 +++++++++++++++++++++++++++++++++++++ src/moonlock/main.py | 101 ++++++++++++++++ src/moonlock/style.css | 78 +++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 config/moonlock-pam create mode 100644 src/moonlock/lockscreen.py create mode 100644 src/moonlock/main.py create mode 100644 src/moonlock/style.css diff --git a/config/moonlock-pam b/config/moonlock-pam new file mode 100644 index 0000000..ab053c5 --- /dev/null +++ b/config/moonlock-pam @@ -0,0 +1,4 @@ +#%PAM-1.0 +auth include system-auth +account include system-auth +session include system-auth diff --git a/src/moonlock/lockscreen.py b/src/moonlock/lockscreen.py new file mode 100644 index 0000000..6b34372 --- /dev/null +++ b/src/moonlock/lockscreen.py @@ -0,0 +1,228 @@ +# 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, GLib + +from moonlock.auth import authenticate +from moonlock.fingerprint import FingerprintListener +from moonlock.i18n import Strings, load_strings +from moonlock.users import get_current_user, get_avatar_path, User +from moonlock import power + +FAILLOCK_MAX_ATTEMPTS = 3 + + +class LockscreenWindow(Gtk.ApplicationWindow): + """Fullscreen lockscreen window with password and fingerprint auth.""" + + def __init__(self, application: Gtk.Application, unlock_callback: callable | None = None) -> None: + super().__init__(application=application) + self.add_css_class("lockscreen") + + 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._fp_listener.is_available(self._user.username) + + self._build_ui() + self._setup_keyboard() + + # 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 + background = Gtk.Box() + 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 + avatar_path = get_avatar_path(self._user.home, self._user.username) + if avatar_path: + avatar = Gtk.Picture.new_for_filename(str(avatar_path)) + avatar.set_content_fit(Gtk.ContentFit.COVER) + else: + avatar = Gtk.Box() + avatar.set_halign(Gtk.Align.CENTER) + avatar.add_css_class("avatar") + self._login_box.append(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_placeholder_text(self._strings.password_placeholder) + self._password_entry.set_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) + 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: + entry.set_sensitive(True) + if result: + self._unlock() + else: + self._failed_attempts += 1 + self._show_error(self._strings.wrong_password) + if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS: + self._show_error(self._strings.faillock_locked) + elif 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) + ) + entry.set_text("") + + # Use GLib thread pool to avoid blocking GTK mainloop + def _auth_thread() -> bool: + result = _do_auth() + 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 _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) diff --git a/src/moonlock/main.py b/src/moonlock/main.py new file mode 100644 index 0000000..0a04f4a --- /dev/null +++ b/src/moonlock/main.py @@ -0,0 +1,101 @@ +# ABOUTME: Entry point for Moonlock — sets up GTK Application and ext-session-lock-v1. +# ABOUTME: Handles CLI invocation, session locking, and multi-monitor support. + +import sys +from importlib.resources import files + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gtk, Gdk + +from moonlock.lockscreen import LockscreenWindow + +# ext-session-lock-v1 via gtk4-layer-shell +try: + gi.require_version("Gtk4SessionLock", "1.0") + from gi.repository import Gtk4SessionLock + HAS_SESSION_LOCK = True +except (ValueError, ImportError): + HAS_SESSION_LOCK = False + + +class MoonlockApp(Gtk.Application): + """GTK Application for the Moonlock lockscreen.""" + + def __init__(self) -> None: + super().__init__(application_id="dev.moonarch.moonlock") + self._lock_instance = None + self._windows: list[Gtk.Window] = [] + + def do_activate(self) -> None: + """Create the lockscreen and lock the session.""" + self._load_css() + + if HAS_SESSION_LOCK and Gtk4SessionLock.is_supported(): + self._activate_with_session_lock() + else: + # Fallback for development/testing without Wayland + self._activate_without_lock() + + def _activate_with_session_lock(self) -> None: + """Lock the session using ext-session-lock-v1 protocol.""" + self._lock_instance = Gtk4SessionLock.Instance.new() + self._lock_instance.lock() + + display = Gdk.Display.get_default() + monitors = display.get_monitors() + + for i in range(monitors.get_n_items()): + monitor = monitors.get_item(i) + # Primary monitor gets the full UI + if i == 0: + window = LockscreenWindow( + application=self, + unlock_callback=self._unlock, + ) + else: + # Secondary monitors get a blank lockscreen + window = Gtk.ApplicationWindow(application=self) + window.add_css_class("lockscreen") + + self._lock_instance.assign_window_to_monitor(window, monitor) + window.present() + self._windows.append(window) + + def _activate_without_lock(self) -> None: + """Fallback for development — no session lock, just a window.""" + window = LockscreenWindow( + application=self, + unlock_callback=self._unlock, + ) + window.set_default_size(800, 600) + window.present() + self._windows.append(window) + + def _unlock(self) -> None: + """Unlock the session and exit.""" + if self._lock_instance: + self._lock_instance.unlock() + self.quit() + + def _load_css(self) -> None: + """Load the CSS stylesheet for the lockscreen.""" + css_provider = Gtk.CssProvider() + css_path = files("moonlock") / "style.css" + css_provider.load_from_path(str(css_path)) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + +def main() -> None: + """Run the Moonlock application.""" + app = MoonlockApp() + app.run(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/src/moonlock/style.css b/src/moonlock/style.css new file mode 100644 index 0000000..a351cc6 --- /dev/null +++ b/src/moonlock/style.css @@ -0,0 +1,78 @@ +/* ABOUTME: GTK4 CSS stylesheet for the Moonlock lockscreen. */ +/* ABOUTME: Dark theme styling matching the Moonarch ecosystem. */ + +/* Main window background */ +window.lockscreen { + background-color: #1a1a2e; + background-size: cover; + background-position: center; +} + +/* Central login area */ +.login-box { + padding: 40px; + border-radius: 12px; + background-color: alpha(@theme_bg_color, 0.7); +} + +/* Round avatar image */ +.avatar { + border-radius: 50%; + min-width: 128px; + min-height: 128px; + max-width: 128px; + max-height: 128px; + background-color: @theme_selected_bg_color; + border: 3px solid alpha(white, 0.3); +} + +/* Username label */ +.username-label { + font-size: 24px; + font-weight: bold; + color: white; + margin-top: 12px; + margin-bottom: 40px; +} + +/* Password entry field */ +.password-entry { + min-width: 280px; +} + +/* Error message label */ +.error-label { + color: #ff6b6b; + font-size: 14px; +} + +/* Fingerprint status indicator */ +.fingerprint-label { + color: alpha(white, 0.6); + font-size: 13px; + margin-top: 8px; +} + +.fingerprint-label.success { + color: #51cf66; +} + +.fingerprint-label.failed { + color: #ff6b6b; +} + +/* Power buttons on the bottom right */ +.power-button { + min-width: 48px; + min-height: 48px; + padding: 0px; + border-radius: 24px; + background-color: alpha(white, 0.1); + color: white; + border: none; + margin: 4px; +} + +.power-button:hover { + background-color: alpha(white, 0.25); +}