feat: initial Moongreet greeter implementation
greetd-Greeter für Wayland mit Python + GTK4 + gtk4-layer-shell. Enthält IPC-Protokoll, User/Session-Erkennung, Power-Actions, komplettes UI-Layout und 36 Tests (Unit + Integration).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4.
|
||||
# ABOUTME: Part of the Moonarch ecosystem.
|
||||
@@ -0,0 +1,376 @@
|
||||
# ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter.
|
||||
# ABOUTME: Handles user selection, session choice, password entry, and power actions.
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, Gio, GdkPixbuf
|
||||
|
||||
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
|
||||
from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme
|
||||
from moongreet.sessions import Session, get_sessions
|
||||
from moongreet.power import reboot, shutdown
|
||||
|
||||
LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
|
||||
DEFAULT_AVATAR_PATH = Path(__file__).parent.parent.parent / "data" / "default-avatar.svg"
|
||||
|
||||
|
||||
class GreeterWindow(Gtk.ApplicationWindow):
|
||||
"""The main greeter window with login UI."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.add_css_class("greeter")
|
||||
self.set_default_size(1920, 1080)
|
||||
|
||||
self._users = get_users()
|
||||
self._sessions = get_sessions()
|
||||
self._selected_user: User | None = None
|
||||
self._greetd_sock: socket.socket | None = None
|
||||
|
||||
self._build_ui()
|
||||
self._select_initial_user()
|
||||
self._setup_keyboard_navigation()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build the complete greeter UI layout."""
|
||||
# Root overlay for layering
|
||||
overlay = Gtk.Overlay()
|
||||
self.set_child(overlay)
|
||||
|
||||
# Background fills the whole window
|
||||
background = Gtk.Box()
|
||||
background.set_hexpand(True)
|
||||
background.set_vexpand(True)
|
||||
overlay.set_child(background)
|
||||
|
||||
# Main layout: 3 rows (top spacer, center login, bottom bar)
|
||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
main_box.set_hexpand(True)
|
||||
main_box.set_vexpand(True)
|
||||
overlay.add_overlay(main_box)
|
||||
|
||||
# Top spacer
|
||||
top_spacer = Gtk.Box()
|
||||
top_spacer.set_vexpand(True)
|
||||
main_box.append(top_spacer)
|
||||
|
||||
# Center: login box
|
||||
center_box = self._build_login_box()
|
||||
center_box.set_halign(Gtk.Align.CENTER)
|
||||
main_box.append(center_box)
|
||||
|
||||
# Bottom spacer
|
||||
bottom_spacer = Gtk.Box()
|
||||
bottom_spacer.set_vexpand(True)
|
||||
main_box.append(bottom_spacer)
|
||||
|
||||
# Bottom bar overlay (user list left, power buttons right)
|
||||
bottom_bar = self._build_bottom_bar()
|
||||
bottom_bar.set_valign(Gtk.Align.END)
|
||||
overlay.add_overlay(bottom_bar)
|
||||
|
||||
def _build_login_box(self) -> Gtk.Box:
|
||||
"""Build the central login area with avatar, name, session, password."""
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
box.add_css_class("login-box")
|
||||
box.set_halign(Gtk.Align.CENTER)
|
||||
box.set_valign(Gtk.Align.CENTER)
|
||||
box.set_spacing(4)
|
||||
|
||||
# Avatar — wrapped in a fixed-size frame to constrain the Picture
|
||||
avatar_frame = Gtk.Box()
|
||||
avatar_frame.set_size_request(96, 96)
|
||||
avatar_frame.set_halign(Gtk.Align.CENTER)
|
||||
avatar_frame.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
avatar_frame.add_css_class("avatar")
|
||||
self._avatar_image = Gtk.Picture()
|
||||
self._avatar_image.set_size_request(96, 96)
|
||||
self._avatar_image.set_content_fit(Gtk.ContentFit.COVER)
|
||||
self._avatar_image.set_hexpand(False)
|
||||
self._avatar_image.set_vexpand(False)
|
||||
avatar_frame.append(self._avatar_image)
|
||||
box.append(avatar_frame)
|
||||
|
||||
# Username label
|
||||
self._username_label = Gtk.Label(label="")
|
||||
self._username_label.add_css_class("username-label")
|
||||
box.append(self._username_label)
|
||||
|
||||
# Session dropdown
|
||||
self._session_dropdown = Gtk.DropDown()
|
||||
self._session_dropdown.add_css_class("session-dropdown")
|
||||
self._session_dropdown.set_halign(Gtk.Align.CENTER)
|
||||
if self._sessions:
|
||||
session_names = [s.name for s in self._sessions]
|
||||
string_list = Gtk.StringList.new(session_names)
|
||||
self._session_dropdown.set_model(string_list)
|
||||
box.append(self._session_dropdown)
|
||||
|
||||
# Password entry
|
||||
self._password_entry = Gtk.PasswordEntry()
|
||||
self._password_entry.set_property("placeholder-text", "Passwort")
|
||||
self._password_entry.set_property("show-peek-icon", True)
|
||||
self._password_entry.add_css_class("password-entry")
|
||||
self._password_entry.set_halign(Gtk.Align.CENTER)
|
||||
self._password_entry.connect("activate", self._on_login_activate)
|
||||
box.append(self._password_entry)
|
||||
|
||||
# Error label (hidden by default)
|
||||
self._error_label = Gtk.Label(label="")
|
||||
self._error_label.add_css_class("error-label")
|
||||
self._error_label.set_visible(False)
|
||||
box.append(self._error_label)
|
||||
|
||||
return box
|
||||
|
||||
def _build_bottom_bar(self) -> Gtk.Box:
|
||||
"""Build the bottom bar with user list (left) and power buttons (right)."""
|
||||
bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
bar.set_hexpand(True)
|
||||
bar.set_margin_start(16)
|
||||
bar.set_margin_end(16)
|
||||
bar.set_margin_bottom(16)
|
||||
|
||||
# User list (left)
|
||||
user_list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
user_list_box.add_css_class("user-list")
|
||||
user_list_box.set_halign(Gtk.Align.START)
|
||||
user_list_box.set_valign(Gtk.Align.END)
|
||||
|
||||
for user in self._users:
|
||||
btn = Gtk.Button(label=user.display_name)
|
||||
btn.add_css_class("user-list-item")
|
||||
btn.connect("clicked", self._on_user_clicked, user)
|
||||
user_list_box.append(btn)
|
||||
|
||||
bar.append(user_list_box)
|
||||
|
||||
# Spacer
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
bar.append(spacer)
|
||||
|
||||
# Power buttons (right)
|
||||
power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
power_box.set_halign(Gtk.Align.END)
|
||||
power_box.set_valign(Gtk.Align.END)
|
||||
power_box.set_spacing(8)
|
||||
|
||||
reboot_btn = Gtk.Button()
|
||||
reboot_btn.set_icon_name("system-reboot-symbolic")
|
||||
reboot_btn.add_css_class("power-button")
|
||||
reboot_btn.set_tooltip_text("Neustart")
|
||||
reboot_btn.connect("clicked", self._on_reboot_clicked)
|
||||
power_box.append(reboot_btn)
|
||||
|
||||
shutdown_btn = Gtk.Button()
|
||||
shutdown_btn.set_icon_name("system-shutdown-symbolic")
|
||||
shutdown_btn.add_css_class("power-button")
|
||||
shutdown_btn.set_tooltip_text("Herunterfahren")
|
||||
shutdown_btn.connect("clicked", self._on_shutdown_clicked)
|
||||
power_box.append(shutdown_btn)
|
||||
|
||||
bar.append(power_box)
|
||||
|
||||
return bar
|
||||
|
||||
def _select_initial_user(self) -> None:
|
||||
"""Select the last user or the first available user."""
|
||||
if not self._users:
|
||||
return
|
||||
|
||||
# Try to load last user
|
||||
last_username = self._load_last_user()
|
||||
target_user = None
|
||||
|
||||
if last_username:
|
||||
for user in self._users:
|
||||
if user.username == last_username:
|
||||
target_user = user
|
||||
break
|
||||
|
||||
if target_user is None:
|
||||
target_user = self._users[0]
|
||||
|
||||
self._switch_to_user(target_user)
|
||||
|
||||
def _switch_to_user(self, user: User) -> None:
|
||||
"""Update the UI to show the selected user."""
|
||||
self._selected_user = user
|
||||
self._username_label.set_text(user.display_name)
|
||||
self._password_entry.set_text("")
|
||||
self._error_label.set_visible(False)
|
||||
|
||||
# Update avatar
|
||||
avatar_path = get_avatar_path(
|
||||
user.username, home_dir=user.home
|
||||
)
|
||||
if avatar_path and avatar_path.exists():
|
||||
self._avatar_image.set_filename(str(avatar_path))
|
||||
elif DEFAULT_AVATAR_PATH.exists():
|
||||
self._avatar_image.set_filename(str(DEFAULT_AVATAR_PATH))
|
||||
|
||||
# Apply user's GTK theme if available
|
||||
self._apply_user_theme(user)
|
||||
|
||||
# Focus password entry
|
||||
self._password_entry.grab_focus()
|
||||
|
||||
def _apply_user_theme(self, user: User) -> None:
|
||||
"""Load the user's preferred GTK theme from their settings.ini."""
|
||||
gtk_config_dir = user.home / ".config" / "gtk-4.0"
|
||||
theme_name = get_user_gtk_theme(config_dir=gtk_config_dir)
|
||||
|
||||
settings = Gtk.Settings.get_default()
|
||||
if settings is None:
|
||||
return
|
||||
|
||||
if theme_name:
|
||||
settings.set_property("gtk-theme-name", theme_name)
|
||||
else:
|
||||
settings.reset_property("gtk-theme-name")
|
||||
|
||||
def _setup_keyboard_navigation(self) -> None:
|
||||
"""Set up keyboard shortcuts."""
|
||||
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 global key presses."""
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
self._password_entry.set_text("")
|
||||
self._error_label.set_visible(False)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_user_clicked(self, button: Gtk.Button, user: User) -> None:
|
||||
"""Handle user selection from the user list."""
|
||||
self._cancel_pending_session()
|
||||
self._switch_to_user(user)
|
||||
|
||||
def _on_login_activate(self, entry: Gtk.PasswordEntry) -> None:
|
||||
"""Handle Enter key in the password field — attempt login."""
|
||||
if not self._selected_user:
|
||||
return
|
||||
|
||||
password = entry.get_text()
|
||||
session = self._get_selected_session()
|
||||
if not session:
|
||||
self._show_error("Keine Session ausgewählt")
|
||||
return
|
||||
|
||||
self._attempt_login(self._selected_user, password, session)
|
||||
|
||||
def _attempt_login(self, user: User, password: str, session: Session) -> None:
|
||||
"""Attempt to authenticate and start a session via greetd IPC."""
|
||||
sock_path = os.environ.get("GREETD_SOCK")
|
||||
if not sock_path:
|
||||
self._show_error("GREETD_SOCK nicht gesetzt")
|
||||
return
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(sock_path)
|
||||
self._greetd_sock = sock
|
||||
|
||||
# Step 1: Create session
|
||||
response = create_session(sock, user.username)
|
||||
|
||||
if response.get("type") == "error":
|
||||
self._show_error(response.get("description", "Authentifizierung fehlgeschlagen"))
|
||||
sock.close()
|
||||
return
|
||||
|
||||
# Step 2: Send password if auth message received
|
||||
if response.get("type") == "auth_message":
|
||||
response = post_auth_response(sock, password)
|
||||
|
||||
if response.get("type") == "error":
|
||||
self._show_error(response.get("description", "Falsches Passwort"))
|
||||
sock.close()
|
||||
return
|
||||
|
||||
# Step 3: Start session
|
||||
if response.get("type") == "success":
|
||||
cmd = session.exec_cmd.split()
|
||||
response = start_session(sock, cmd)
|
||||
|
||||
if response.get("type") == "success":
|
||||
self._save_last_user(user.username)
|
||||
sock.close()
|
||||
self.get_application().quit()
|
||||
return
|
||||
else:
|
||||
self._show_error(response.get("description", "Session konnte nicht gestartet werden"))
|
||||
|
||||
sock.close()
|
||||
|
||||
except ConnectionError as e:
|
||||
self._show_error(f"Verbindungsfehler: {e}")
|
||||
except OSError as e:
|
||||
self._show_error(f"Socket-Fehler: {e}")
|
||||
|
||||
def _cancel_pending_session(self) -> None:
|
||||
"""Cancel any in-progress greetd session."""
|
||||
if self._greetd_sock:
|
||||
try:
|
||||
cancel_session(self._greetd_sock)
|
||||
self._greetd_sock.close()
|
||||
except (ConnectionError, OSError):
|
||||
pass
|
||||
self._greetd_sock = None
|
||||
|
||||
def _get_selected_session(self) -> Session | None:
|
||||
"""Get the currently selected session from the dropdown."""
|
||||
if not self._sessions:
|
||||
return None
|
||||
idx = self._session_dropdown.get_selected()
|
||||
if idx < len(self._sessions):
|
||||
return self._sessions[idx]
|
||||
return None
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
"""Display an error message below the password field."""
|
||||
self._error_label.set_text(message)
|
||||
self._error_label.set_visible(True)
|
||||
self._password_entry.set_text("")
|
||||
self._password_entry.grab_focus()
|
||||
|
||||
def _on_reboot_clicked(self, button: Gtk.Button) -> None:
|
||||
"""Handle reboot button click."""
|
||||
reboot()
|
||||
|
||||
def _on_shutdown_clicked(self, button: Gtk.Button) -> None:
|
||||
"""Handle shutdown button click."""
|
||||
shutdown()
|
||||
|
||||
@staticmethod
|
||||
def _load_last_user() -> str | None:
|
||||
"""Load the last logged-in username from cache."""
|
||||
if LAST_USER_PATH.exists():
|
||||
try:
|
||||
return LAST_USER_PATH.read_text().strip()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _save_last_user(username: str) -> None:
|
||||
"""Save the last logged-in username to cache."""
|
||||
try:
|
||||
LAST_USER_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
LAST_USER_PATH.write_text(username)
|
||||
except OSError:
|
||||
pass # Non-critical — cache dir may not be writable
|
||||
@@ -0,0 +1,51 @@
|
||||
# ABOUTME: greetd IPC protocol implementation — communicates via Unix socket.
|
||||
# ABOUTME: Uses length-prefixed JSON encoding as specified by the greetd IPC protocol.
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
|
||||
def send_message(sock: Any, msg: dict) -> None:
|
||||
"""Send a length-prefixed JSON message to the greetd socket."""
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
header = struct.pack("!I", len(payload))
|
||||
sock.sendall(header + payload)
|
||||
|
||||
|
||||
def recv_message(sock: Any) -> dict:
|
||||
"""Receive a length-prefixed JSON message from the greetd socket."""
|
||||
header = sock.recv(4)
|
||||
if len(header) < 4:
|
||||
raise ConnectionError("Connection closed while reading message header")
|
||||
|
||||
length = struct.unpack("!I", header)[0]
|
||||
payload = sock.recv(length)
|
||||
if len(payload) < length:
|
||||
raise ConnectionError("Connection closed while reading message body")
|
||||
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def create_session(sock: Any, username: str) -> dict:
|
||||
"""Send a create_session request to greetd and return the response."""
|
||||
send_message(sock, {"type": "create_session", "username": username})
|
||||
return recv_message(sock)
|
||||
|
||||
|
||||
def post_auth_response(sock: Any, response: str | None) -> dict:
|
||||
"""Send an authentication response (e.g. password) to greetd."""
|
||||
send_message(sock, {"type": "post_auth_message_response", "response": response})
|
||||
return recv_message(sock)
|
||||
|
||||
|
||||
def start_session(sock: Any, cmd: list[str]) -> dict:
|
||||
"""Send a start_session request to launch the user's session."""
|
||||
send_message(sock, {"type": "start_session", "cmd": cmd})
|
||||
return recv_message(sock)
|
||||
|
||||
|
||||
def cancel_session(sock: Any) -> dict:
|
||||
"""Cancel the current authentication session."""
|
||||
send_message(sock, {"type": "cancel_session"})
|
||||
return recv_message(sock)
|
||||
@@ -0,0 +1,74 @@
|
||||
# ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell.
|
||||
# ABOUTME: Handles CLI invocation and initializes the greeter window.
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from moongreet.greeter import GreeterWindow
|
||||
|
||||
# gtk4-layer-shell is optional for development/testing
|
||||
try:
|
||||
gi.require_version("Gtk4LayerShell", "1.0")
|
||||
from gi.repository import Gtk4LayerShell
|
||||
HAS_LAYER_SHELL = True
|
||||
except (ValueError, ImportError):
|
||||
HAS_LAYER_SHELL = False
|
||||
|
||||
|
||||
class MoongreetApp(Gtk.Application):
|
||||
"""GTK Application for the Moongreet greeter."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id="dev.moonarch.moongreet")
|
||||
|
||||
def do_activate(self) -> None:
|
||||
"""Create and present the greeter window."""
|
||||
self._load_css()
|
||||
window = GreeterWindow(application=self)
|
||||
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(window)
|
||||
|
||||
window.present()
|
||||
|
||||
def _load_css(self) -> None:
|
||||
"""Load the CSS stylesheet for the greeter."""
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_path = os.path.join(os.path.dirname(__file__), "style.css")
|
||||
css_provider.load_from_path(css_path)
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(),
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
def _setup_layer_shell(self, window: Gtk.Window) -> None:
|
||||
"""Configure gtk4-layer-shell for fullscreen greeter display."""
|
||||
Gtk4LayerShell.init_for_window(window)
|
||||
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP)
|
||||
Gtk4LayerShell.set_keyboard_mode(
|
||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||
)
|
||||
# Anchor to all edges for fullscreen
|
||||
for edge in [
|
||||
Gtk4LayerShell.Edge.TOP,
|
||||
Gtk4LayerShell.Edge.BOTTOM,
|
||||
Gtk4LayerShell.Edge.LEFT,
|
||||
Gtk4LayerShell.Edge.RIGHT,
|
||||
]:
|
||||
Gtk4LayerShell.set_anchor(window, edge, True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the Moongreet application."""
|
||||
app = MoongreetApp()
|
||||
app.run(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
# ABOUTME: Power actions — reboot and shutdown via loginctl.
|
||||
# ABOUTME: Simple wrappers around system commands for the greeter UI.
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def reboot() -> None:
|
||||
"""Reboot the system via loginctl."""
|
||||
subprocess.run(["loginctl", "reboot"], check=True)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Shut down the system via loginctl."""
|
||||
subprocess.run(["loginctl", "poweroff"], check=True)
|
||||
@@ -0,0 +1,62 @@
|
||||
# ABOUTME: Session detection — discovers available Wayland and X11 sessions.
|
||||
# ABOUTME: Parses .desktop files from standard session directories.
|
||||
|
||||
import configparser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_WAYLAND_DIRS = [Path("/usr/share/wayland-sessions")]
|
||||
DEFAULT_XSESSION_DIRS = [Path("/usr/share/xsessions")]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Represents an available login session."""
|
||||
|
||||
name: str
|
||||
exec_cmd: str
|
||||
session_type: str # "wayland" or "x11"
|
||||
|
||||
|
||||
def _parse_desktop_file(path: Path, session_type: str) -> Session | None:
|
||||
"""Parse a .desktop file and return a Session, or None if invalid."""
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read(path)
|
||||
|
||||
section = "Desktop Entry"
|
||||
if not config.has_section(section):
|
||||
return None
|
||||
|
||||
name = config.get(section, "Name", fallback=None)
|
||||
exec_cmd = config.get(section, "Exec", fallback=None)
|
||||
|
||||
if not name or not exec_cmd:
|
||||
return None
|
||||
|
||||
return Session(name=name, exec_cmd=exec_cmd, session_type=session_type)
|
||||
|
||||
|
||||
def get_sessions(
|
||||
wayland_dirs: list[Path] = DEFAULT_WAYLAND_DIRS,
|
||||
xsession_dirs: list[Path] = DEFAULT_XSESSION_DIRS,
|
||||
) -> list[Session]:
|
||||
"""Discover available sessions from .desktop files."""
|
||||
sessions: list[Session] = []
|
||||
|
||||
for directory in wayland_dirs:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for desktop_file in sorted(directory.glob("*.desktop")):
|
||||
session = _parse_desktop_file(desktop_file, "wayland")
|
||||
if session:
|
||||
sessions.append(session)
|
||||
|
||||
for directory in xsession_dirs:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for desktop_file in sorted(directory.glob("*.desktop")):
|
||||
session = _parse_desktop_file(desktop_file, "x11")
|
||||
if session:
|
||||
sessions.append(session)
|
||||
|
||||
return sessions
|
||||
@@ -0,0 +1,96 @@
|
||||
/* ABOUTME: GTK4 CSS stylesheet for the Moongreet greeter. */
|
||||
/* ABOUTME: Defines styling for the login screen layout. */
|
||||
|
||||
/* Main window background */
|
||||
window.greeter {
|
||||
background-color: #1a1a2e;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Central login area */
|
||||
.login-box {
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
background-color: alpha(@window_bg_color, 0.7);
|
||||
}
|
||||
|
||||
/* Round avatar image */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
min-width: 96px;
|
||||
min-height: 96px;
|
||||
background-color: #3a3a5c;
|
||||
border: 3px solid alpha(white, 0.3);
|
||||
}
|
||||
|
||||
/* Username label */
|
||||
.username-label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Session dropdown */
|
||||
.session-dropdown {
|
||||
min-width: 200px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Password entry field */
|
||||
.password-entry {
|
||||
min-width: 280px;
|
||||
min-height: 40px;
|
||||
border-radius: 20px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Error message label */
|
||||
.error-label {
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* User list on the bottom left */
|
||||
.user-list {
|
||||
background-color: transparent;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.user-list-item {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-list-item:hover {
|
||||
background-color: alpha(white, 0.15);
|
||||
}
|
||||
|
||||
.user-list-item:selected {
|
||||
background-color: alpha(white, 0.2);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# ABOUTME: User detection — parses /etc/passwd for login users, finds avatars and GTK themes.
|
||||
# ABOUTME: Provides User dataclass and helper functions for the greeter UI.
|
||||
|
||||
import configparser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"}
|
||||
MIN_UID = 1000
|
||||
MAX_UID = 65533
|
||||
|
||||
DEFAULT_PASSWD = Path("/etc/passwd")
|
||||
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Represents a system user suitable for login."""
|
||||
|
||||
username: str
|
||||
uid: int
|
||||
gecos: str
|
||||
home: Path
|
||||
shell: str
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Return gecos if available, otherwise username."""
|
||||
return self.gecos if self.gecos else self.username
|
||||
|
||||
|
||||
def get_users(passwd_path: Path = DEFAULT_PASSWD) -> list[User]:
|
||||
"""Parse /etc/passwd and return users with UID in the login range."""
|
||||
users: list[User] = []
|
||||
|
||||
if not passwd_path.exists():
|
||||
return users
|
||||
|
||||
for line in passwd_path.read_text().splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
|
||||
username, _, uid_str, _, gecos, home, shell = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
|
||||
uid = int(uid_str)
|
||||
|
||||
if uid < MIN_UID or uid > MAX_UID:
|
||||
continue
|
||||
if shell in NOLOGIN_SHELLS:
|
||||
continue
|
||||
|
||||
users.append(User(
|
||||
username=username,
|
||||
uid=uid,
|
||||
gecos=gecos,
|
||||
home=Path(home),
|
||||
shell=shell,
|
||||
))
|
||||
|
||||
return users
|
||||
|
||||
|
||||
def get_avatar_path(
|
||||
username: str,
|
||||
accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR,
|
||||
home_dir: Path | None = None,
|
||||
) -> Path | None:
|
||||
"""Find avatar for a user: AccountsService icon → ~/.face → None."""
|
||||
# AccountsService icon
|
||||
icon = accountsservice_dir / username
|
||||
if icon.exists():
|
||||
return icon
|
||||
|
||||
# ~/.face fallback
|
||||
if home_dir is not None:
|
||||
face = home_dir / ".face"
|
||||
if face.exists():
|
||||
return face
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_gtk_theme(config_dir: Path | None = None) -> str | None:
|
||||
"""Read the GTK theme name from a user's gtk-4.0/settings.ini."""
|
||||
if config_dir is None:
|
||||
return None
|
||||
|
||||
settings_file = config_dir / "settings.ini"
|
||||
if not settings_file.exists():
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(settings_file)
|
||||
|
||||
if config.has_option("Settings", "gtk-theme-name"):
|
||||
return config.get("Settings", "gtk-theme-name")
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user