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.
This commit is contained in:
nevaforget 2026-03-26 12:29:38 +01:00
parent d1c0b741fa
commit 0ee451de9e
4 changed files with 411 additions and 0 deletions

4
config/moonlock-pam Normal file
View File

@ -0,0 +1,4 @@
#%PAM-1.0
auth include system-auth
account include system-auth
session include system-auth

228
src/moonlock/lockscreen.py Normal file
View File

@ -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)

101
src/moonlock/main.py Normal file
View File

@ -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()

78
src/moonlock/style.css Normal file
View File

@ -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);
}