Compare commits

..

5 Commits

Author SHA1 Message Date
9738e71ecc feat: Multi-Monitor-Support — Wallpaper auf Sekundärmonitoren
- WallpaperWindow für Sekundärmonitore (nur Hintergrundbild)
- GreeterWindow bekommt bg_path als Parameter
- resolve_wallpaper_path() aus config.py extrahiert (wiederverwendbar)
- main.py: Monitor-Enumeration, Layer-Shell pro Monitor
- Keyboard-Exclusive nur auf dem primären Monitor
- CSS: ungültige max-width/max-height Properties entfernt
2026-03-26 13:05:29 +01:00
8f2540024d fix: do_unrealize durch Signal-Handler ersetzen (PyGObject VFunc-Kompatibilität)
do_unrealize als GObject-VFunc-Override crashte beim super()-Chain-Up.
Signal-basierter _on_unrealize-Handler wie bei _on_realize.
2026-03-26 12:59:45 +01:00
4cd73a430b fix: Audit-Findings — Realize-Handler, Thread-Safety, Input-Validierung
- _on_realize implementiert (war nur connected, nicht definiert)
- do_unrealize für as_file() Context-Manager-Cleanup
- threading.Lock für _greetd_sock Race Condition
- TOML-Parsing-Fehler abfangen statt Crash
- last-user Datei: Längen- und Zeichenvalidierung
- detect_locale: non-alpha LANG-Werte abweisen
- exec_cmd Plausibility-Check mit shutil.which
- Exception-Details ins Log statt in die UI
- subprocess.run Timeout für Power-Actions
- Sequence[Path] statt tuple[Path, ...] in get_sessions
- Mock-Server _recvall für fragmentierte Reads
- [behavior]-Config-Sektion entfernt (unimplementiert)
- Design Decisions in CLAUDE.md dokumentiert
2026-03-26 12:56:52 +01:00
8b1608f99d fix: Audit-Findings — Theme-Validierung, locale-unabhängige Tests
- Theme-Name aus settings.ini gegen Regex validiert (nur [A-Za-z0-9_-]),
  verhindert Path-Traversal über GTK-Theme-Loading (S-05)
- Faillock-Tests nutzen expliziten strings-Parameter statt System-Locale,
  Tests laufen jetzt auch auf EN-Systemen (MAINT-4)
- Test für Path-Traversal im Theme-Namen ergänzt
2026-03-26 12:36:16 +01:00
65d3ba64f9 fix: Audit-Findings — Login-Thread, Traversable-Assets, Session-Exec
- Login-IPC in Background-Thread ausgelagert, UI friert nicht mehr ein
  bei langsamem PAM/greetd (PERF-2/BUG-4)
- importlib.resources korrekt via as_file()/read_text() statt
  Path(str()) — funktioniert in ZIP-Wheels (BUG-1)
- is_absolute()-Check für Session-Exec entfernt, greetd löst PATH
  selbst auf — relative Executables wie 'sway' blockieren nicht mehr (LOGIC-1)
- ValueError (json.JSONDecodeError) im except abgefangen (BUG-2)
2026-03-26 12:34:19 +01:00
15 changed files with 355 additions and 104 deletions

View File

@ -42,5 +42,10 @@ uv run moongreet
- `sessions.py` — Wayland/X11 Sessions aus .desktop Files - `sessions.py` — Wayland/X11 Sessions aus .desktop Files
- `power.py` — Reboot/Shutdown via loginctl - `power.py` — Reboot/Shutdown via loginctl
- `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN) - `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN)
- `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen - `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen, WallpaperWindow für Sekundärmonitore
- `main.py` — Entry Point, GTK App, Layer Shell Setup - `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor-Orchestrierung
## Design Decisions
- **Synchrones I/O im GTK-Konstruktor**: `load_config`, `load_strings`, `get_users` und `get_sessions` laufen synchron in `GreeterWindow.__init__`. Async Loading mit Placeholder-UI wäre möglich, erhöht aber die Komplexität erheblich. Der Greeter startet 1x pro Boot auf lokaler Hardware — die Daten sind klein (passwd, locale.conf, wenige .desktop-Files), die Latenz im Normalfall vernachlässigbar.
- **Synchrones Avatar-Decoding**: `GdkPixbuf.Pixbuf.new_from_file_at_scale` läuft synchron auf dem Main Thread. Bei großen Bildern als `.face`-Datei kann die UI kurz stocken. Der Avatar-Cache (`_avatar_cache`) federt das nach dem ersten Laden ab. Async Decoding per Worker-Thread + `GLib.idle_add` wäre die Alternative, rechtfertigt aber den Aufwand nicht für einen Single-User-Greeter.

View File

@ -4,7 +4,3 @@
[appearance] [appearance]
# Absolute path to wallpaper image # Absolute path to wallpaper image
background = "/usr/share/backgrounds/wallpaper.jpg" background = "/usr/share/backgrounds/wallpaper.jpg"
[behavior]
# show_user_list = true
# default_session = "Hyprland"

View File

@ -1,8 +1,10 @@
# ABOUTME: Configuration loading from moongreet.toml. # ABOUTME: Configuration loading from moongreet.toml.
# ABOUTME: Parses appearance and behavior settings for the greeter. # ABOUTME: Parses appearance and behavior settings with wallpaper path resolution.
import tomllib import tomllib
from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from importlib.resources import as_file, files
from pathlib import Path from pathlib import Path
DEFAULT_CONFIG_PATHS = [ DEFAULT_CONFIG_PATHS = [
@ -33,8 +35,11 @@ def load_config(config_path: Path | None = None) -> Config:
if not config_path.exists(): if not config_path.exists():
return Config() return Config()
try:
with open(config_path, "rb") as f: with open(config_path, "rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError):
return Config()
config = Config() config = Config()
appearance = data.get("appearance", {}) appearance = data.get("appearance", {})
@ -47,3 +52,24 @@ def load_config(config_path: Path | None = None) -> Config:
config.background = bg_path config.background = bg_path
return config return config
_PACKAGE_DATA = files("moongreet") / "data"
_DEFAULT_WALLPAPER_PATH = _PACKAGE_DATA / "wallpaper.jpg"
def resolve_wallpaper_path(
config: Config,
) -> tuple[Path, AbstractContextManager | None]:
"""Resolve the wallpaper path from config or fall back to the package default.
Returns (path, context_manager). The context_manager is non-None when a
package resource was extracted to a temporary file the caller must keep
it alive and call __exit__ when done.
"""
if config.background and config.background.exists():
return config.background, None
ctx = as_file(_DEFAULT_WALLPAPER_PATH)
path = ctx.__enter__()
return path, ctx

View File

@ -1,11 +1,15 @@
# ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter. # ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter.
# ABOUTME: Handles user selection, session choice, password entry, and power actions. # ABOUTME: Handles user selection, session choice, password entry, and power actions.
import logging
import os import os
import re
import shlex import shlex
import shutil
import socket import socket
import stat import stat
import subprocess import subprocess
import threading
from importlib.resources import files from importlib.resources import files
from pathlib import Path from pathlib import Path
@ -14,18 +18,21 @@ gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
from moongreet.config import load_config from moongreet.config import load_config, resolve_wallpaper_path
from moongreet.i18n import load_strings, Strings from moongreet.i18n import load_strings, Strings
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session 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.users import User, get_users, get_avatar_path, get_user_gtk_theme
from moongreet.sessions import Session, get_sessions from moongreet.sessions import Session, get_sessions
from moongreet.power import reboot, shutdown from moongreet.power import reboot, shutdown
logger = logging.getLogger(__name__)
LAST_USER_PATH = Path("/var/cache/moongreet/last-user") LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
FAILLOCK_MAX_ATTEMPTS = 3 FAILLOCK_MAX_ATTEMPTS = 3
VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$")
MAX_USERNAME_LENGTH = 256
PACKAGE_DATA = files("moongreet") / "data" PACKAGE_DATA = files("moongreet") / "data"
DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg" DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg"
DEFAULT_WALLPAPER_PATH = PACKAGE_DATA / "wallpaper.jpg"
AVATAR_SIZE = 128 AVATAR_SIZE = 128
@ -41,10 +48,35 @@ def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str
return None return None
def _build_wallpaper_widget(bg_path: Path | None) -> Gtk.Widget:
"""Create a wallpaper widget that fills the available space."""
if bg_path and bg_path.exists():
background = Gtk.Picture()
background.set_filename(str(bg_path))
background.set_content_fit(Gtk.ContentFit.COVER)
background.set_hexpand(True)
background.set_vexpand(True)
return background
background = Gtk.Box()
background.set_hexpand(True)
background.set_vexpand(True)
return background
class WallpaperWindow(Gtk.ApplicationWindow):
"""A window that shows only the wallpaper — used for secondary monitors."""
def __init__(self, bg_path: Path | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self.add_css_class("greeter")
self.set_default_size(1920, 1080)
self.set_child(_build_wallpaper_widget(bg_path))
class GreeterWindow(Gtk.ApplicationWindow): class GreeterWindow(Gtk.ApplicationWindow):
"""The main greeter window with login UI.""" """The main greeter window with login UI."""
def __init__(self, **kwargs) -> None: def __init__(self, bg_path: Path | None = None, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.add_css_class("greeter") self.add_css_class("greeter")
self.set_default_size(1920, 1080) self.set_default_size(1920, 1080)
@ -55,13 +87,26 @@ class GreeterWindow(Gtk.ApplicationWindow):
self._sessions = get_sessions() self._sessions = get_sessions()
self._selected_user: User | None = None self._selected_user: User | None = None
self._greetd_sock: socket.socket | None = None self._greetd_sock: socket.socket | None = None
self._greetd_sock_lock = threading.Lock()
self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None
self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {} self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {}
self._failed_attempts: dict[str, int] = {} self._failed_attempts: dict[str, int] = {}
self._bg_path = bg_path
self._build_ui() self._build_ui()
self._select_initial_user()
self._setup_keyboard_navigation() self._setup_keyboard_navigation()
# Defer initial user selection until the window is realized,
# so get_color() returns the actual theme foreground for SVG tinting
self.connect("realize", self._on_realize)
def _on_realize(self, widget: Gtk.Widget) -> None:
"""Called when the window is realized — select initial user.
Deferred from __init__ so get_color() returns actual theme values
for SVG tinting. Uses idle_add so the first frame renders before
avatar loading blocks the main loop.
"""
GLib.idle_add(self._select_initial_user)
def _build_ui(self) -> None: def _build_ui(self) -> None:
"""Build the complete greeter UI layout.""" """Build the complete greeter UI layout."""
@ -69,21 +114,8 @@ class GreeterWindow(Gtk.ApplicationWindow):
overlay = Gtk.Overlay() overlay = Gtk.Overlay()
self.set_child(overlay) self.set_child(overlay)
# Background wallpaper (blurred and darkened) # Background wallpaper
bg_path = self._config.background overlay.set_child(_build_wallpaper_widget(self._bg_path))
if not bg_path or not bg_path.exists():
bg_path = Path(str(DEFAULT_WALLPAPER_PATH))
if bg_path.exists():
background = Gtk.Picture()
background.set_filename(str(bg_path))
background.set_content_fit(Gtk.ContentFit.COVER)
background.set_hexpand(True)
background.set_vexpand(True)
else:
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 layout: 3 rows (top spacer, center login, bottom bar)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@ -249,10 +281,10 @@ class GreeterWindow(Gtk.ApplicationWindow):
) )
if avatar_path and avatar_path.exists(): if avatar_path and avatar_path.exists():
self._set_avatar_from_file(avatar_path, user.username) self._set_avatar_from_file(avatar_path, user.username)
elif DEFAULT_AVATAR_PATH.exists():
self._set_default_avatar()
else: else:
self._avatar_image.set_from_icon_name("avatar-default-symbolic") # Default avatar — _set_default_avatar uses Traversable.read_text()
# which works in ZIP wheels too, no exists() check needed
self._set_default_avatar()
# Apply user's GTK theme if available # Apply user's GTK theme if available
self._apply_user_theme(user) self._apply_user_theme(user)
@ -372,6 +404,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
def _close_greetd_sock(self) -> None: def _close_greetd_sock(self) -> None:
"""Close the greetd socket and reset the reference.""" """Close the greetd socket and reset the reference."""
with self._greetd_sock_lock:
if self._greetd_sock: if self._greetd_sock:
try: try:
self._greetd_sock.close() self._greetd_sock.close()
@ -379,6 +412,11 @@ class GreeterWindow(Gtk.ApplicationWindow):
pass pass
self._greetd_sock = None self._greetd_sock = None
def _set_login_sensitive(self, sensitive: bool) -> None:
"""Enable or disable login controls during authentication."""
self._password_entry.set_sensitive(sensitive)
self._session_dropdown.set_sensitive(sensitive)
def _attempt_login(self, user: User, password: str, session: Session) -> None: def _attempt_login(self, user: User, password: str, session: Session) -> None:
"""Attempt to authenticate and start a session via greetd IPC.""" """Attempt to authenticate and start a session via greetd IPC."""
sock_path = os.environ.get("GREETD_SOCK") sock_path = os.environ.get("GREETD_SOCK")
@ -389,18 +427,29 @@ class GreeterWindow(Gtk.ApplicationWindow):
if not self._validate_greetd_sock(sock_path): if not self._validate_greetd_sock(sock_path):
return return
# Disable UI while authenticating — the IPC runs in a background thread
self._set_login_sensitive(False)
thread = threading.Thread(
target=self._login_worker,
args=(user, password, session, sock_path),
daemon=True,
)
thread.start()
def _login_worker(self, user: User, password: str, session: Session, sock_path: str) -> None:
"""Run greetd IPC in a background thread to avoid blocking the GTK main loop."""
try: try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(10.0) sock.settimeout(10.0)
sock.connect(sock_path) sock.connect(sock_path)
with self._greetd_sock_lock:
self._greetd_sock = sock self._greetd_sock = sock
# Step 1: Create session # Step 1: Create session
response = create_session(sock, user.username) response = create_session(sock, user.username)
if response.get("type") == "error": if response.get("type") == "error":
self._show_greetd_error(response, self._strings.auth_failed) GLib.idle_add(self._on_login_error, response, self._strings.auth_failed)
self._close_greetd_sock()
return return
# Step 2: Send password if auth message received # Step 2: Send password if auth message received
@ -409,50 +458,65 @@ class GreeterWindow(Gtk.ApplicationWindow):
if response.get("type") == "error": if response.get("type") == "error":
self._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1 self._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1
self._show_greetd_error(response, self._strings.wrong_password)
warning = faillock_warning(self._failed_attempts[user.username], self._strings) warning = faillock_warning(self._failed_attempts[user.username], self._strings)
if warning: GLib.idle_add(self._on_login_auth_error, response, warning)
current = self._error_label.get_text()
self._error_label.set_text(f"{current}\n{warning}")
self._close_greetd_sock()
return return
if response.get("type") == "auth_message": if response.get("type") == "auth_message":
# Multi-stage auth (e.g. TOTP) is not supported # Multi-stage auth (e.g. TOTP) is not supported
cancel_session(sock) cancel_session(sock)
self._close_greetd_sock() GLib.idle_add(self._on_login_error, None, self._strings.multi_stage_unsupported)
self._show_error(self._strings.multi_stage_unsupported)
return return
# Step 3: Start session # Step 3: Start session
if response.get("type") == "success": if response.get("type") == "success":
cmd = shlex.split(session.exec_cmd) cmd = shlex.split(session.exec_cmd)
if not cmd or not Path(cmd[0]).is_absolute(): if not cmd or not shutil.which(cmd[0]):
self._show_error(self._strings.invalid_session_command)
cancel_session(sock) cancel_session(sock)
self._close_greetd_sock() GLib.idle_add(self._on_login_error, None, self._strings.invalid_session_command)
return return
response = start_session(sock, cmd) response = start_session(sock, cmd)
if response.get("type") == "success": if response.get("type") == "success":
self._save_last_user(user.username) self._save_last_user(user.username)
self._close_greetd_sock() self._close_greetd_sock()
self.get_application().quit() GLib.idle_add(self.get_application().quit)
return return
else: else:
self._show_greetd_error(response, self._strings.session_start_failed) GLib.idle_add(self._on_login_error, response, self._strings.session_start_failed)
self._close_greetd_sock() self._close_greetd_sock()
except ConnectionError as e: except ConnectionError as e:
logger.error("greetd connection error: %s", e)
self._close_greetd_sock() self._close_greetd_sock()
self._show_error(self._strings.connection_error.format(error=e)) GLib.idle_add(self._on_login_error, None, self._strings.connection_error)
except OSError as e: except (OSError, ValueError) as e:
logger.error("greetd socket error: %s", e)
self._close_greetd_sock() self._close_greetd_sock()
self._show_error(self._strings.socket_error.format(error=e)) GLib.idle_add(self._on_login_error, None, self._strings.socket_error)
def _on_login_error(self, response: dict | None, message: str) -> None:
"""Handle login error on the GTK main thread."""
if response:
self._show_greetd_error(response, message)
else:
self._show_error(message)
self._close_greetd_sock()
self._set_login_sensitive(True)
def _on_login_auth_error(self, response: dict, warning: str | None) -> None:
"""Handle authentication failure with optional faillock warning on the GTK main thread."""
self._show_greetd_error(response, self._strings.wrong_password)
if warning:
current = self._error_label.get_text()
self._error_label.set_text(f"{current}\n{warning}")
self._close_greetd_sock()
self._set_login_sensitive(True)
def _cancel_pending_session(self) -> None: def _cancel_pending_session(self) -> None:
"""Cancel any in-progress greetd session.""" """Cancel any in-progress greetd session."""
with self._greetd_sock_lock:
if self._greetd_sock: if self._greetd_sock:
try: try:
cancel_session(self._greetd_sock) cancel_session(self._greetd_sock)
@ -505,9 +569,12 @@ class GreeterWindow(Gtk.ApplicationWindow):
"""Load the last logged-in username from cache.""" """Load the last logged-in username from cache."""
if LAST_USER_PATH.exists(): if LAST_USER_PATH.exists():
try: try:
return LAST_USER_PATH.read_text().strip() username = LAST_USER_PATH.read_text().strip()
except OSError: except OSError:
return None return None
if len(username) > MAX_USERNAME_LENGTH or not VALID_USERNAME.match(username):
return None
return username
return None return None
@staticmethod @staticmethod

View File

@ -31,9 +31,11 @@ class Strings:
reboot_failed: str reboot_failed: str
shutdown_failed: str shutdown_failed: str
# Templates (use .format()) # Error messages (continued)
connection_error: str connection_error: str
socket_error: str socket_error: str
# Templates (use .format())
faillock_attempts_remaining: str faillock_attempts_remaining: str
faillock_locked: str faillock_locked: str
@ -54,8 +56,8 @@ _STRINGS_DE = Strings(
session_start_failed="Session konnte nicht gestartet werden", session_start_failed="Session konnte nicht gestartet werden",
reboot_failed="Neustart fehlgeschlagen", reboot_failed="Neustart fehlgeschlagen",
shutdown_failed="Herunterfahren fehlgeschlagen", shutdown_failed="Herunterfahren fehlgeschlagen",
connection_error="Verbindungsfehler: {error}", connection_error="Verbindungsfehler",
socket_error="Socket-Fehler: {error}", socket_error="Socket-Fehler",
faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!", faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked="Konto ist möglicherweise gesperrt", faillock_locked="Konto ist möglicherweise gesperrt",
) )
@ -76,8 +78,8 @@ _STRINGS_EN = Strings(
session_start_failed="Failed to start session", session_start_failed="Failed to start session",
reboot_failed="Reboot failed", reboot_failed="Reboot failed",
shutdown_failed="Shutdown failed", shutdown_failed="Shutdown failed",
connection_error="Connection error: {error}", connection_error="Connection error",
socket_error="Socket error: {error}", socket_error="Socket error",
faillock_attempts_remaining="{n} attempt(s) remaining before lockout!", faillock_attempts_remaining="{n} attempt(s) remaining before lockout!",
faillock_locked="Account may be locked", faillock_locked="Account may be locked",
) )
@ -102,7 +104,9 @@ def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
return "en" return "en"
# Extract language prefix: "de_DE.UTF-8" → "de" # Extract language prefix: "de_DE.UTF-8" → "de"
lang = lang.split("_")[0].split(".")[0] lang = lang.split("_")[0].split(".")[0].lower()
if not lang.isalpha():
return "en"
return lang return lang

View File

@ -1,6 +1,7 @@
# ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell. # ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell.
# ABOUTME: Handles CLI invocation and initializes the greeter window. # ABOUTME: Handles multi-monitor setup: login UI on primary, wallpaper on secondary monitors.
import logging
import sys import sys
from importlib.resources import files from importlib.resources import files
@ -9,7 +10,8 @@ gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk from gi.repository import Gtk, Gdk
from moongreet.greeter import GreeterWindow from moongreet.config import load_config, resolve_wallpaper_path
from moongreet.greeter import GreeterWindow, WallpaperWindow
# gtk4-layer-shell is optional for development/testing # gtk4-layer-shell is optional for development/testing
try: try:
@ -19,23 +21,65 @@ try:
except (ValueError, ImportError): except (ValueError, ImportError):
HAS_LAYER_SHELL = False HAS_LAYER_SHELL = False
logger = logging.getLogger(__name__)
class MoongreetApp(Gtk.Application): class MoongreetApp(Gtk.Application):
"""GTK Application for the Moongreet greeter.""" """GTK Application for the Moongreet greeter."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(application_id="dev.moonarch.moongreet") super().__init__(application_id="dev.moonarch.moongreet")
self._wallpaper_ctx = None
self._secondary_windows: list[WallpaperWindow] = []
def do_activate(self) -> None: def do_activate(self) -> None:
"""Create and present the greeter window.""" """Create and present greeter windows on all monitors."""
self._register_icons() self._register_icons()
self._load_css() self._load_css()
window = GreeterWindow(application=self)
# Resolve wallpaper once, share across all windows
config = load_config()
bg_path, self._wallpaper_ctx = resolve_wallpaper_path(config)
display = Gdk.Display.get_default()
monitors = display.get_monitors()
primary_monitor = None
# Find primary monitor — fall back to first available
for i in range(monitors.get_n_items()):
monitor = monitors.get_item(i)
if hasattr(monitor, 'is_primary') and monitor.is_primary():
primary_monitor = monitor
break
if primary_monitor is None and monitors.get_n_items() > 0:
primary_monitor = monitors.get_item(0)
# Main greeter window (login UI) on primary monitor
greeter = GreeterWindow(bg_path=bg_path, application=self)
if HAS_LAYER_SHELL: if HAS_LAYER_SHELL:
self._setup_layer_shell(window) self._setup_layer_shell(greeter, keyboard=True)
if primary_monitor is not None:
Gtk4LayerShell.set_monitor(greeter, primary_monitor)
greeter.present()
window.present() # Wallpaper-only windows on secondary monitors
for i in range(monitors.get_n_items()):
monitor = monitors.get_item(i)
if monitor == primary_monitor:
continue
wallpaper_win = WallpaperWindow(bg_path=bg_path, application=self)
if HAS_LAYER_SHELL:
self._setup_layer_shell(wallpaper_win, keyboard=False)
Gtk4LayerShell.set_monitor(wallpaper_win, monitor)
wallpaper_win.present()
self._secondary_windows.append(wallpaper_win)
def do_shutdown(self) -> None:
"""Clean up wallpaper context manager on exit."""
if self._wallpaper_ctx is not None:
self._wallpaper_ctx.__exit__(None, None, None)
self._wallpaper_ctx = None
Gtk.Application.do_shutdown(self)
def _register_icons(self) -> None: def _register_icons(self) -> None:
"""Register custom icons from the package data/icons directory.""" """Register custom icons from the package data/icons directory."""
@ -54,10 +98,11 @@ class MoongreetApp(Gtk.Application):
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
) )
def _setup_layer_shell(self, window: Gtk.Window) -> None: def _setup_layer_shell(self, window: Gtk.Window, keyboard: bool = False) -> None:
"""Configure gtk4-layer-shell for fullscreen greeter display.""" """Configure gtk4-layer-shell for fullscreen display."""
Gtk4LayerShell.init_for_window(window) Gtk4LayerShell.init_for_window(window)
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP) Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP)
if keyboard:
Gtk4LayerShell.set_keyboard_mode( Gtk4LayerShell.set_keyboard_mode(
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
) )

View File

@ -4,11 +4,14 @@
import subprocess import subprocess
POWER_TIMEOUT = 30
def reboot() -> None: def reboot() -> None:
"""Reboot the system via loginctl.""" """Reboot the system via loginctl."""
subprocess.run(["loginctl", "reboot"], check=True) subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT)
def shutdown() -> None: def shutdown() -> None:
"""Shut down the system via loginctl.""" """Shut down the system via loginctl."""
subprocess.run(["loginctl", "poweroff"], check=True) subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT)

View File

@ -2,6 +2,7 @@
# ABOUTME: Parses .desktop files from standard session directories. # ABOUTME: Parses .desktop files from standard session directories.
import configparser import configparser
from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -37,8 +38,8 @@ def _parse_desktop_file(path: Path, session_type: str) -> Session | None:
def get_sessions( def get_sessions(
wayland_dirs: tuple[Path, ...] = DEFAULT_WAYLAND_DIRS, wayland_dirs: Sequence[Path] = DEFAULT_WAYLAND_DIRS,
xsession_dirs: tuple[Path, ...] = DEFAULT_XSESSION_DIRS, xsession_dirs: Sequence[Path] = DEFAULT_XSESSION_DIRS,
) -> list[Session]: ) -> list[Session]:
"""Discover available sessions from .desktop files.""" """Discover available sessions from .desktop files."""
sessions: list[Session] = [] sessions: list[Session] = []

View File

@ -15,13 +15,11 @@ window.greeter {
background-color: alpha(@theme_bg_color, 0.7); background-color: alpha(@theme_bg_color, 0.7);
} }
/* Round avatar image */ /* Round avatar image — size is set via set_size_request() in code */
.avatar { .avatar {
border-radius: 50%; border-radius: 50%;
min-width: 128px; min-width: 128px;
min-height: 128px; min-height: 128px;
max-width: 128px;
max-height: 128px;
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
border: 3px solid alpha(white, 0.3); border: 3px solid alpha(white, 0.3);
} }

View File

@ -2,9 +2,12 @@
# ABOUTME: Provides User dataclass and helper functions for the greeter UI. # ABOUTME: Provides User dataclass and helper functions for the greeter UI.
import configparser import configparser
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
VALID_THEME_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"} NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"}
MIN_UID = 1000 MIN_UID = 1000
MAX_UID = 65533 MAX_UID = 65533
@ -102,6 +105,9 @@ def get_user_gtk_theme(config_dir: Path | None = None) -> str | None:
return None return None
if config.has_option("Settings", "gtk-theme-name"): if config.has_option("Settings", "gtk-theme-name"):
return config.get("Settings", "gtk-theme-name") theme = config.get("Settings", "gtk-theme-name")
# Validate against path traversal — only allow safe theme names
if theme and VALID_THEME_NAME.match(theme):
return theme
return None return None

View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
from moongreet.config import load_config, Config from moongreet.config import load_config, resolve_wallpaper_path, Config
class TestLoadConfig: class TestLoadConfig:
@ -35,6 +35,14 @@ class TestLoadConfig:
assert config.background is None assert config.background is None
def test_returns_defaults_for_corrupt_toml(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text("this is not valid [[[ toml !!!")
config = load_config(toml_file)
assert config.background is None
def test_resolves_relative_path_against_config_dir(self, tmp_path: Path) -> None: def test_resolves_relative_path_against_config_dir(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml" toml_file = tmp_path / "moongreet.toml"
toml_file.write_text( toml_file.write_text(
@ -45,3 +53,38 @@ class TestLoadConfig:
config = load_config(toml_file) config = load_config(toml_file)
assert config.background == tmp_path / "wallpaper.jpg" assert config.background == tmp_path / "wallpaper.jpg"
class TestResolveWallpaperPath:
"""Tests for resolving the wallpaper path from config or package default."""
def test_uses_configured_path_when_exists(self, tmp_path: Path) -> None:
wallpaper = tmp_path / "custom.jpg"
wallpaper.write_bytes(b"fake-image")
config = Config(background=wallpaper)
path, ctx = resolve_wallpaper_path(config)
assert path == wallpaper
assert ctx is None
def test_falls_back_to_package_default(self) -> None:
config = Config(background=None)
path, ctx = resolve_wallpaper_path(config)
assert path is not None
assert path.exists()
assert ctx is not None
# Clean up context manager
ctx.__exit__(None, None, None)
def test_falls_back_when_configured_path_missing(self, tmp_path: Path) -> None:
config = Config(background=tmp_path / "nonexistent.jpg")
path, ctx = resolve_wallpaper_path(config)
assert path is not None
assert path.exists()
assert ctx is not None
ctx.__exit__(None, None, None)

View File

@ -63,6 +63,13 @@ class TestDetectLocale:
assert result == "en" assert result == "en"
def test_rejects_non_alpha_lang(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANG", "../../etc")
result = detect_locale()
assert result == "en"
class TestLoadStrings: class TestLoadStrings:
"""Tests for loading the correct string table.""" """Tests for loading the correct string table."""
@ -111,8 +118,9 @@ class TestLoadStrings:
result = strings.faillock_attempts_remaining.format(n=1) result = strings.faillock_attempts_remaining.format(n=1)
assert "1" in result assert "1" in result
def test_connection_error_template(self) -> None: def test_connection_error_is_generic(self) -> None:
strings = load_strings("en") strings = load_strings("en")
result = strings.connection_error.format(error="timeout") # Error messages should not contain format placeholders (no info leakage)
assert "timeout" in result assert "{" not in strings.connection_error
assert "{" not in strings.socket_error

View File

@ -11,6 +11,7 @@ from pathlib import Path
import pytest import pytest
from moongreet.greeter import faillock_warning, FAILLOCK_MAX_ATTEMPTS from moongreet.greeter import faillock_warning, FAILLOCK_MAX_ATTEMPTS
from moongreet.i18n import load_strings
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
@ -39,16 +40,27 @@ class MockGreetd:
self._thread = threading.Thread(target=self._serve, daemon=True) self._thread = threading.Thread(target=self._serve, daemon=True)
self._thread.start() self._thread.start()
@staticmethod
def _recvall(conn: socket.socket, n: int) -> bytes:
"""Receive exactly n bytes from a socket, handling fragmented reads."""
buf = bytearray()
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
break
buf.extend(chunk)
return bytes(buf)
def _serve(self) -> None: def _serve(self) -> None:
conn, _ = self._server.accept() conn, _ = self._server.accept()
try: try:
for response in self._responses: for response in self._responses:
# Receive a message # Receive a message
header = conn.recv(4) header = self._recvall(conn, 4)
if len(header) < 4: if len(header) < 4:
break break
length = struct.unpack("!I", header)[0] length = struct.unpack("!I", header)[0]
payload = conn.recv(length) payload = self._recvall(conn, length)
msg = json.loads(payload.decode("utf-8")) msg = json.loads(payload.decode("utf-8"))
self._received.append(msg) self._received.append(msg)
@ -181,23 +193,31 @@ class TestLoginFlow:
class TestFaillockWarning: class TestFaillockWarning:
"""Tests for the faillock warning message logic.""" """Tests for the faillock warning message logic."""
def test_no_warning_on_zero_attempts(self) -> None:
strings = load_strings("de")
assert faillock_warning(0, strings) is None
def test_no_warning_on_first_attempt(self) -> None: def test_no_warning_on_first_attempt(self) -> None:
assert faillock_warning(1) is None strings = load_strings("de")
assert faillock_warning(1, strings) is None
def test_warning_on_second_attempt(self) -> None: def test_warning_on_second_attempt(self) -> None:
warning = faillock_warning(2) strings = load_strings("de")
warning = faillock_warning(2, strings)
assert warning is not None assert warning is not None
assert "1" in warning # 1 Versuch übrig assert "1" in warning # 1 Versuch übrig
def test_warning_on_third_attempt(self) -> None: def test_warning_on_third_attempt(self) -> None:
warning = faillock_warning(3) strings = load_strings("de")
warning = faillock_warning(3, strings)
assert warning is not None assert warning is not None
assert "gesperrt" in warning.lower() assert warning == strings.faillock_locked
def test_warning_beyond_max_attempts(self) -> None: def test_warning_beyond_max_attempts(self) -> None:
warning = faillock_warning(4) strings = load_strings("de")
warning = faillock_warning(4, strings)
assert warning is not None assert warning is not None
assert "gesperrt" in warning.lower() assert warning == strings.faillock_locked
def test_max_attempts_constant_is_three(self) -> None: def test_max_attempts_constant_is_three(self) -> None:
assert FAILLOCK_MAX_ATTEMPTS == 3 assert FAILLOCK_MAX_ATTEMPTS == 3
@ -226,3 +246,21 @@ class TestLastUser:
from moongreet.greeter import GreeterWindow from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_user() result = GreeterWindow._load_last_user()
assert result is None assert result is None
def test_load_last_user_rejects_oversized_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cache_path = tmp_path / "last-user"
cache_path.write_text("a" * 300)
monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path)
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_user()
assert result is None
def test_load_last_user_rejects_invalid_characters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cache_path = tmp_path / "last-user"
cache_path.write_text("../../etc/passwd")
monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path)
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_user()
assert result is None

View File

@ -6,7 +6,7 @@ from unittest.mock import patch, call
import pytest import pytest
from moongreet.power import reboot, shutdown from moongreet.power import reboot, shutdown, POWER_TIMEOUT
class TestReboot: class TestReboot:
@ -17,7 +17,7 @@ class TestReboot:
reboot() reboot()
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
["loginctl", "reboot"], check=True ["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT
) )
@patch("moongreet.power.subprocess.run") @patch("moongreet.power.subprocess.run")
@ -36,7 +36,7 @@ class TestShutdown:
shutdown() shutdown()
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
["loginctl", "poweroff"], check=True ["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT
) )
@patch("moongreet.power.subprocess.run") @patch("moongreet.power.subprocess.run")

View File

@ -187,7 +187,7 @@ class TestGetUserGtkTheme:
assert result is None assert result is None
def test_handles_interpolation_characters(self, tmp_path: Path) -> None: def test_handles_interpolation_characters(self, tmp_path: Path) -> None:
"""Theme names with % characters should not trigger interpolation errors.""" """Theme names with % characters are rejected by validation."""
gtk_dir = tmp_path / ".config" / "gtk-4.0" gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True) gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini" settings = gtk_dir / "settings.ini"
@ -195,7 +195,18 @@ class TestGetUserGtkTheme:
result = get_user_gtk_theme(config_dir=gtk_dir) result = get_user_gtk_theme(config_dir=gtk_dir)
assert result == "My%Theme" assert result is None
def test_rejects_path_traversal_theme_name(self, tmp_path: Path) -> None:
"""Theme names with path traversal characters should be rejected."""
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text("[Settings]\ngtk-theme-name=../../../../etc/evil\n")
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result is None
def test_ignores_symlinked_accountsservice_icon(self, tmp_path: Path) -> None: def test_ignores_symlinked_accountsservice_icon(self, tmp_path: Path) -> None:
"""AccountsService icon as symlink should be ignored to prevent traversal.""" """AccountsService icon as symlink should be ignored to prevent traversal."""