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:
parent
d1c0b741fa
commit
0ee451de9e
4
config/moonlock-pam
Normal file
4
config/moonlock-pam
Normal 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
228
src/moonlock/lockscreen.py
Normal 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
101
src/moonlock/main.py
Normal 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
78
src/moonlock/style.css
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user