Compare commits
5 Commits
9a964aaecb
...
9738e71ecc
| Author | SHA1 | Date | |
|---|---|---|---|
| 9738e71ecc | |||
| 8f2540024d | |||
| 4cd73a430b | |||
| 8b1608f99d | |||
| 65d3ba64f9 |
@ -42,5 +42,10 @@ uv run moongreet
|
||||
- `sessions.py` — Wayland/X11 Sessions aus .desktop Files
|
||||
- `power.py` — Reboot/Shutdown via loginctl
|
||||
- `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN)
|
||||
- `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen
|
||||
- `main.py` — Entry Point, GTK App, Layer Shell Setup
|
||||
- `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, 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.
|
||||
|
||||
@ -4,7 +4,3 @@
|
||||
[appearance]
|
||||
# Absolute path to wallpaper image
|
||||
background = "/usr/share/backgrounds/wallpaper.jpg"
|
||||
|
||||
[behavior]
|
||||
# show_user_list = true
|
||||
# default_session = "Hyprland"
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
# 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
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import as_file, files
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_CONFIG_PATHS = [
|
||||
@ -33,8 +35,11 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
if not config_path.exists():
|
||||
return Config()
|
||||
|
||||
with open(config_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, OSError):
|
||||
return Config()
|
||||
|
||||
config = Config()
|
||||
appearance = data.get("appearance", {})
|
||||
@ -47,3 +52,24 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
config.background = bg_path
|
||||
|
||||
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
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
# 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 logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
@ -14,18 +18,21 @@ gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
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.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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
|
||||
FAILLOCK_MAX_ATTEMPTS = 3
|
||||
VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
MAX_USERNAME_LENGTH = 256
|
||||
PACKAGE_DATA = files("moongreet") / "data"
|
||||
DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg"
|
||||
DEFAULT_WALLPAPER_PATH = PACKAGE_DATA / "wallpaper.jpg"
|
||||
AVATAR_SIZE = 128
|
||||
|
||||
|
||||
@ -41,10 +48,35 @@ def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str
|
||||
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):
|
||||
"""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)
|
||||
self.add_css_class("greeter")
|
||||
self.set_default_size(1920, 1080)
|
||||
@ -55,13 +87,26 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
self._sessions = get_sessions()
|
||||
self._selected_user: User | None = None
|
||||
self._greetd_sock: socket.socket | None = None
|
||||
self._greetd_sock_lock = threading.Lock()
|
||||
self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None
|
||||
self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {}
|
||||
self._failed_attempts: dict[str, int] = {}
|
||||
self._bg_path = bg_path
|
||||
|
||||
self._build_ui()
|
||||
self._select_initial_user()
|
||||
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:
|
||||
"""Build the complete greeter UI layout."""
|
||||
@ -69,21 +114,8 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
overlay = Gtk.Overlay()
|
||||
self.set_child(overlay)
|
||||
|
||||
# Background wallpaper (blurred and darkened)
|
||||
bg_path = self._config.background
|
||||
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)
|
||||
# Background wallpaper
|
||||
overlay.set_child(_build_wallpaper_widget(self._bg_path))
|
||||
|
||||
# Main layout: 3 rows (top spacer, center login, bottom bar)
|
||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
@ -249,10 +281,10 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
)
|
||||
if avatar_path and avatar_path.exists():
|
||||
self._set_avatar_from_file(avatar_path, user.username)
|
||||
elif DEFAULT_AVATAR_PATH.exists():
|
||||
self._set_default_avatar()
|
||||
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
|
||||
self._apply_user_theme(user)
|
||||
@ -372,12 +404,18 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
|
||||
def _close_greetd_sock(self) -> None:
|
||||
"""Close the greetd socket and reset the reference."""
|
||||
if self._greetd_sock:
|
||||
try:
|
||||
self._greetd_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._greetd_sock = None
|
||||
with self._greetd_sock_lock:
|
||||
if self._greetd_sock:
|
||||
try:
|
||||
self._greetd_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
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:
|
||||
"""Attempt to authenticate and start a session via greetd IPC."""
|
||||
@ -389,18 +427,29 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
if not self._validate_greetd_sock(sock_path):
|
||||
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:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(10.0)
|
||||
sock.connect(sock_path)
|
||||
self._greetd_sock = sock
|
||||
with self._greetd_sock_lock:
|
||||
self._greetd_sock = sock
|
||||
|
||||
# Step 1: Create session
|
||||
response = create_session(sock, user.username)
|
||||
|
||||
if response.get("type") == "error":
|
||||
self._show_greetd_error(response, self._strings.auth_failed)
|
||||
self._close_greetd_sock()
|
||||
GLib.idle_add(self._on_login_error, response, self._strings.auth_failed)
|
||||
return
|
||||
|
||||
# Step 2: Send password if auth message received
|
||||
@ -409,56 +458,71 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
|
||||
if response.get("type") == "error":
|
||||
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)
|
||||
if warning:
|
||||
current = self._error_label.get_text()
|
||||
self._error_label.set_text(f"{current}\n{warning}")
|
||||
self._close_greetd_sock()
|
||||
GLib.idle_add(self._on_login_auth_error, response, warning)
|
||||
return
|
||||
|
||||
if response.get("type") == "auth_message":
|
||||
# Multi-stage auth (e.g. TOTP) is not supported
|
||||
cancel_session(sock)
|
||||
self._close_greetd_sock()
|
||||
self._show_error(self._strings.multi_stage_unsupported)
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.multi_stage_unsupported)
|
||||
return
|
||||
|
||||
# Step 3: Start session
|
||||
if response.get("type") == "success":
|
||||
cmd = shlex.split(session.exec_cmd)
|
||||
if not cmd or not Path(cmd[0]).is_absolute():
|
||||
self._show_error(self._strings.invalid_session_command)
|
||||
if not cmd or not shutil.which(cmd[0]):
|
||||
cancel_session(sock)
|
||||
self._close_greetd_sock()
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.invalid_session_command)
|
||||
return
|
||||
response = start_session(sock, cmd)
|
||||
|
||||
if response.get("type") == "success":
|
||||
self._save_last_user(user.username)
|
||||
self._close_greetd_sock()
|
||||
self.get_application().quit()
|
||||
GLib.idle_add(self.get_application().quit)
|
||||
return
|
||||
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()
|
||||
|
||||
except ConnectionError as e:
|
||||
logger.error("greetd connection error: %s", e)
|
||||
self._close_greetd_sock()
|
||||
self._show_error(self._strings.connection_error.format(error=e))
|
||||
except OSError as e:
|
||||
GLib.idle_add(self._on_login_error, None, self._strings.connection_error)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error("greetd socket error: %s", e)
|
||||
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:
|
||||
"""Cancel any in-progress greetd session."""
|
||||
if self._greetd_sock:
|
||||
try:
|
||||
cancel_session(self._greetd_sock)
|
||||
except (ConnectionError, OSError):
|
||||
pass
|
||||
self._close_greetd_sock()
|
||||
with self._greetd_sock_lock:
|
||||
if self._greetd_sock:
|
||||
try:
|
||||
cancel_session(self._greetd_sock)
|
||||
except (ConnectionError, OSError):
|
||||
pass
|
||||
self._close_greetd_sock()
|
||||
|
||||
def _get_selected_session(self) -> Session | None:
|
||||
"""Get the currently selected session from the dropdown."""
|
||||
@ -505,9 +569,12 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
||||
"""Load the last logged-in username from cache."""
|
||||
if LAST_USER_PATH.exists():
|
||||
try:
|
||||
return LAST_USER_PATH.read_text().strip()
|
||||
username = LAST_USER_PATH.read_text().strip()
|
||||
except OSError:
|
||||
return None
|
||||
if len(username) > MAX_USERNAME_LENGTH or not VALID_USERNAME.match(username):
|
||||
return None
|
||||
return username
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -31,9 +31,11 @@ class Strings:
|
||||
reboot_failed: str
|
||||
shutdown_failed: str
|
||||
|
||||
# Templates (use .format())
|
||||
# Error messages (continued)
|
||||
connection_error: str
|
||||
socket_error: str
|
||||
|
||||
# Templates (use .format())
|
||||
faillock_attempts_remaining: str
|
||||
faillock_locked: str
|
||||
|
||||
@ -54,8 +56,8 @@ _STRINGS_DE = Strings(
|
||||
session_start_failed="Session konnte nicht gestartet werden",
|
||||
reboot_failed="Neustart fehlgeschlagen",
|
||||
shutdown_failed="Herunterfahren fehlgeschlagen",
|
||||
connection_error="Verbindungsfehler: {error}",
|
||||
socket_error="Socket-Fehler: {error}",
|
||||
connection_error="Verbindungsfehler",
|
||||
socket_error="Socket-Fehler",
|
||||
faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!",
|
||||
faillock_locked="Konto ist möglicherweise gesperrt",
|
||||
)
|
||||
@ -76,8 +78,8 @@ _STRINGS_EN = Strings(
|
||||
session_start_failed="Failed to start session",
|
||||
reboot_failed="Reboot failed",
|
||||
shutdown_failed="Shutdown failed",
|
||||
connection_error="Connection error: {error}",
|
||||
socket_error="Socket error: {error}",
|
||||
connection_error="Connection error",
|
||||
socket_error="Socket error",
|
||||
faillock_attempts_remaining="{n} attempt(s) remaining before lockout!",
|
||||
faillock_locked="Account may be locked",
|
||||
)
|
||||
@ -102,7 +104,9 @@ def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
|
||||
return "en"
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# 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
|
||||
from importlib.resources import files
|
||||
|
||||
@ -9,7 +10,8 @@ gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
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
|
||||
try:
|
||||
@ -19,23 +21,65 @@ try:
|
||||
except (ValueError, ImportError):
|
||||
HAS_LAYER_SHELL = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MoongreetApp(Gtk.Application):
|
||||
"""GTK Application for the Moongreet greeter."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id="dev.moonarch.moongreet")
|
||||
self._wallpaper_ctx = None
|
||||
self._secondary_windows: list[WallpaperWindow] = []
|
||||
|
||||
def do_activate(self) -> None:
|
||||
"""Create and present the greeter window."""
|
||||
"""Create and present greeter windows on all monitors."""
|
||||
self._register_icons()
|
||||
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:
|
||||
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:
|
||||
"""Register custom icons from the package data/icons directory."""
|
||||
@ -54,13 +98,14 @@ class MoongreetApp(Gtk.Application):
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
def _setup_layer_shell(self, window: Gtk.Window) -> None:
|
||||
"""Configure gtk4-layer-shell for fullscreen greeter display."""
|
||||
def _setup_layer_shell(self, window: Gtk.Window, keyboard: bool = False) -> None:
|
||||
"""Configure gtk4-layer-shell for fullscreen display."""
|
||||
Gtk4LayerShell.init_for_window(window)
|
||||
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP)
|
||||
Gtk4LayerShell.set_keyboard_mode(
|
||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||
)
|
||||
if keyboard:
|
||||
Gtk4LayerShell.set_keyboard_mode(
|
||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||
)
|
||||
# Anchor to all edges for fullscreen
|
||||
for edge in [
|
||||
Gtk4LayerShell.Edge.TOP,
|
||||
|
||||
@ -4,11 +4,14 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
POWER_TIMEOUT = 30
|
||||
|
||||
|
||||
def reboot() -> None:
|
||||
"""Reboot the system via loginctl."""
|
||||
subprocess.run(["loginctl", "reboot"], check=True)
|
||||
subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Shut down the system via loginctl."""
|
||||
subprocess.run(["loginctl", "poweroff"], check=True)
|
||||
subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
# ABOUTME: Parses .desktop files from standard session directories.
|
||||
|
||||
import configparser
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@ -37,8 +38,8 @@ def _parse_desktop_file(path: Path, session_type: str) -> Session | None:
|
||||
|
||||
|
||||
def get_sessions(
|
||||
wayland_dirs: tuple[Path, ...] = DEFAULT_WAYLAND_DIRS,
|
||||
xsession_dirs: tuple[Path, ...] = DEFAULT_XSESSION_DIRS,
|
||||
wayland_dirs: Sequence[Path] = DEFAULT_WAYLAND_DIRS,
|
||||
xsession_dirs: Sequence[Path] = DEFAULT_XSESSION_DIRS,
|
||||
) -> list[Session]:
|
||||
"""Discover available sessions from .desktop files."""
|
||||
sessions: list[Session] = []
|
||||
|
||||
@ -15,13 +15,11 @@ window.greeter {
|
||||
background-color: alpha(@theme_bg_color, 0.7);
|
||||
}
|
||||
|
||||
/* Round avatar image */
|
||||
/* Round avatar image — size is set via set_size_request() in code */
|
||||
.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);
|
||||
}
|
||||
|
||||
@ -2,9 +2,12 @@
|
||||
# ABOUTME: Provides User dataclass and helper functions for the greeter UI.
|
||||
|
||||
import configparser
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
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"}
|
||||
MIN_UID = 1000
|
||||
MAX_UID = 65533
|
||||
@ -102,6 +105,9 @@ def get_user_gtk_theme(config_dir: Path | None = None) -> str | None:
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from moongreet.config import load_config, Config
|
||||
from moongreet.config import load_config, resolve_wallpaper_path, Config
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
@ -35,6 +35,14 @@ class TestLoadConfig:
|
||||
|
||||
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:
|
||||
toml_file = tmp_path / "moongreet.toml"
|
||||
toml_file.write_text(
|
||||
@ -45,3 +53,38 @@ class TestLoadConfig:
|
||||
config = load_config(toml_file)
|
||||
|
||||
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)
|
||||
|
||||
@ -63,6 +63,13 @@ class TestDetectLocale:
|
||||
|
||||
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:
|
||||
"""Tests for loading the correct string table."""
|
||||
@ -111,8 +118,9 @@ class TestLoadStrings:
|
||||
result = strings.faillock_attempts_remaining.format(n=1)
|
||||
assert "1" in result
|
||||
|
||||
def test_connection_error_template(self) -> None:
|
||||
def test_connection_error_is_generic(self) -> None:
|
||||
strings = load_strings("en")
|
||||
|
||||
result = strings.connection_error.format(error="timeout")
|
||||
assert "timeout" in result
|
||||
# Error messages should not contain format placeholders (no info leakage)
|
||||
assert "{" not in strings.connection_error
|
||||
assert "{" not in strings.socket_error
|
||||
|
||||
@ -11,6 +11,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -39,16 +40,27 @@ class MockGreetd:
|
||||
self._thread = threading.Thread(target=self._serve, daemon=True)
|
||||
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:
|
||||
conn, _ = self._server.accept()
|
||||
try:
|
||||
for response in self._responses:
|
||||
# Receive a message
|
||||
header = conn.recv(4)
|
||||
header = self._recvall(conn, 4)
|
||||
if len(header) < 4:
|
||||
break
|
||||
length = struct.unpack("!I", header)[0]
|
||||
payload = conn.recv(length)
|
||||
payload = self._recvall(conn, length)
|
||||
msg = json.loads(payload.decode("utf-8"))
|
||||
self._received.append(msg)
|
||||
|
||||
@ -181,23 +193,31 @@ class TestLoginFlow:
|
||||
class TestFaillockWarning:
|
||||
"""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:
|
||||
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:
|
||||
warning = faillock_warning(2)
|
||||
strings = load_strings("de")
|
||||
warning = faillock_warning(2, strings)
|
||||
assert warning is not None
|
||||
assert "1" in warning # 1 Versuch übrig
|
||||
|
||||
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 "gesperrt" in warning.lower()
|
||||
assert warning == strings.faillock_locked
|
||||
|
||||
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 "gesperrt" in warning.lower()
|
||||
assert warning == strings.faillock_locked
|
||||
|
||||
def test_max_attempts_constant_is_three(self) -> None:
|
||||
assert FAILLOCK_MAX_ATTEMPTS == 3
|
||||
@ -226,3 +246,21 @@ class TestLastUser:
|
||||
from moongreet.greeter import GreeterWindow
|
||||
result = GreeterWindow._load_last_user()
|
||||
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
|
||||
|
||||
@ -6,7 +6,7 @@ from unittest.mock import patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from moongreet.power import reboot, shutdown
|
||||
from moongreet.power import reboot, shutdown, POWER_TIMEOUT
|
||||
|
||||
|
||||
class TestReboot:
|
||||
@ -17,7 +17,7 @@ class TestReboot:
|
||||
reboot()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["loginctl", "reboot"], check=True
|
||||
["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moongreet.power.subprocess.run")
|
||||
@ -36,7 +36,7 @@ class TestShutdown:
|
||||
shutdown()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["loginctl", "poweroff"], check=True
|
||||
["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moongreet.power.subprocess.run")
|
||||
|
||||
@ -187,7 +187,7 @@ class TestGetUserGtkTheme:
|
||||
assert result is 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.mkdir(parents=True)
|
||||
settings = gtk_dir / "settings.ini"
|
||||
@ -195,7 +195,18 @@ class TestGetUserGtkTheme:
|
||||
|
||||
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:
|
||||
"""AccountsService icon as symlink should be ignored to prevent traversal."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user