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
|
- `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.
|
||||||
|
|||||||
@ -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"
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
with open(config_path, "rb") as f:
|
try:
|
||||||
data = tomllib.load(f)
|
with open(config_path, "rb") as 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
|
||||||
|
|||||||
@ -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,12 +404,18 @@ 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."""
|
||||||
if self._greetd_sock:
|
with self._greetd_sock_lock:
|
||||||
try:
|
if self._greetd_sock:
|
||||||
self._greetd_sock.close()
|
try:
|
||||||
except OSError:
|
self._greetd_sock.close()
|
||||||
pass
|
except OSError:
|
||||||
self._greetd_sock = None
|
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:
|
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."""
|
||||||
@ -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)
|
||||||
self._greetd_sock = sock
|
with self._greetd_sock_lock:
|
||||||
|
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,56 +458,71 @@ 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."""
|
||||||
if self._greetd_sock:
|
with self._greetd_sock_lock:
|
||||||
try:
|
if self._greetd_sock:
|
||||||
cancel_session(self._greetd_sock)
|
try:
|
||||||
except (ConnectionError, OSError):
|
cancel_session(self._greetd_sock)
|
||||||
pass
|
except (ConnectionError, OSError):
|
||||||
self._close_greetd_sock()
|
pass
|
||||||
|
self._close_greetd_sock()
|
||||||
|
|
||||||
def _get_selected_session(self) -> Session | None:
|
def _get_selected_session(self) -> Session | None:
|
||||||
"""Get the currently selected session from the dropdown."""
|
"""Get the currently selected session from the dropdown."""
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,13 +98,14 @@ 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)
|
||||||
Gtk4LayerShell.set_keyboard_mode(
|
if keyboard:
|
||||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
Gtk4LayerShell.set_keyboard_mode(
|
||||||
)
|
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||||
|
)
|
||||||
# Anchor to all edges for fullscreen
|
# Anchor to all edges for fullscreen
|
||||||
for edge in [
|
for edge in [
|
||||||
Gtk4LayerShell.Edge.TOP,
|
Gtk4LayerShell.Edge.TOP,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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] = []
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user