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:
2026-03-26 09:47:19 +01:00
commit 87c2e7d9c8
21 changed files with 1610 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4.
# ABOUTME: Part of the Moonarch ecosystem.
+376
View File
@@ -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
+51
View File
@@ -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)
+74
View File
@@ -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()
+14
View File
@@ -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)
+62
View File
@@ -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
+96
View File
@@ -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);
}
+98
View File
@@ -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