fix: IPC byte order, globales GTK-Theme, Session-Vorauswahl

- ipc.py: !I (Big-Endian) → =I (Native Byte Order) für greetd-Protokoll
- Per-User GTK-Theme entfernt, stattdessen globales Theme aus moongreet.toml
- Last-Session pro User in /var/cache/moongreet/last-session/ speichern/laden
- PKGBUILD und install-Hook für last-session-Cache erweitert
This commit is contained in:
nevaforget 2026-03-26 14:51:23 +01:00
parent ba4f30f254
commit 357d2459cf
9 changed files with 154 additions and 31 deletions

View File

@ -4,3 +4,5 @@
[appearance] [appearance]
# Absolute path to wallpaper image # Absolute path to wallpaper image
background = "/usr/share/backgrounds/wallpaper.jpg" background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme for the greeter UI
gtk-theme = "Catppuccin-Mocha-Standard-Blue-Dark"

View File

@ -4,7 +4,7 @@
# Maintainer: Dominik Kressler # Maintainer: Dominik Kressler
pkgname=moongreet-git pkgname=moongreet-git
pkgver=0.1.0 pkgver=0.1.0.r6.gba4f30f
pkgrel=1 pkgrel=1
pkgdesc="A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell" pkgdesc="A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell"
arch=('any') arch=('any')
@ -46,6 +46,7 @@ package() {
# Greeter config # Greeter config
install -Dm644 config/moongreet.toml "$pkgdir/etc/moongreet/moongreet.toml" install -Dm644 config/moongreet.toml "$pkgdir/etc/moongreet/moongreet.toml"
# Cache directory # Cache directories
install -dm755 "$pkgdir/var/cache/moongreet" install -dm755 "$pkgdir/var/cache/moongreet"
install -dm755 "$pkgdir/var/cache/moongreet/last-session"
} }

View File

@ -4,6 +4,7 @@
post_install() { post_install() {
if getent passwd greeter > /dev/null 2>&1; then if getent passwd greeter > /dev/null 2>&1; then
chown greeter:greeter /var/cache/moongreet chown greeter:greeter /var/cache/moongreet
chown greeter:greeter /var/cache/moongreet/last-session
fi fi
echo "==> Moongreet installed." echo "==> Moongreet installed."

View File

@ -1,12 +1,15 @@
# ABOUTME: Configuration loading from moongreet.toml. # ABOUTME: Configuration loading from moongreet.toml.
# ABOUTME: Parses appearance and behavior settings with wallpaper path resolution. # ABOUTME: Parses appearance and behavior settings with wallpaper path resolution.
import re
import tomllib import tomllib
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from importlib.resources import as_file, files from importlib.resources import as_file, files
from pathlib import Path from pathlib import Path
VALID_THEME_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
DEFAULT_CONFIG_PATHS = [ DEFAULT_CONFIG_PATHS = [
Path("/etc/moongreet/moongreet.toml"), Path("/etc/moongreet/moongreet.toml"),
] ]
@ -17,6 +20,7 @@ class Config:
"""Greeter configuration loaded from moongreet.toml.""" """Greeter configuration loaded from moongreet.toml."""
background: Path | None = None background: Path | None = None
gtk_theme: str | None = None
def load_config(config_path: Path | None = None) -> Config: def load_config(config_path: Path | None = None) -> Config:
@ -51,6 +55,10 @@ def load_config(config_path: Path | None = None) -> Config:
bg_path = config_path.parent / bg_path bg_path = config_path.parent / bg_path
config.background = bg_path config.background = bg_path
gtk_theme = appearance.get("gtk-theme")
if gtk_theme and VALID_THEME_NAME.match(gtk_theme):
config.gtk_theme = gtk_theme
return config return config

View File

@ -21,13 +21,14 @@ from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
from moongreet.config import load_config, resolve_wallpaper_path 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
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__) logger = logging.getLogger(__name__)
LAST_USER_PATH = Path("/var/cache/moongreet/last-user") LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
LAST_SESSION_DIR = Path("/var/cache/moongreet/last-session")
FAILLOCK_MAX_ATTEMPTS = 3 FAILLOCK_MAX_ATTEMPTS = 3
VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$") VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$")
MAX_USERNAME_LENGTH = 256 MAX_USERNAME_LENGTH = 256
@ -93,6 +94,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
self._failed_attempts: dict[str, int] = {} self._failed_attempts: dict[str, int] = {}
self._bg_path = bg_path self._bg_path = bg_path
self._apply_global_theme()
self._build_ui() self._build_ui()
self._setup_keyboard_navigation() self._setup_keyboard_navigation()
# Defer initial user selection until the window is realized, # Defer initial user selection until the window is realized,
@ -286,26 +288,23 @@ class GreeterWindow(Gtk.ApplicationWindow):
# which works in ZIP wheels too, no exists() check needed # which works in ZIP wheels too, no exists() check needed
self._set_default_avatar() self._set_default_avatar()
# Apply user's GTK theme if available # Pre-select last used session for this user
self._apply_user_theme(user) self._select_last_session(user)
# Focus password entry # Focus password entry
self._password_entry.grab_focus() self._password_entry.grab_focus()
def _apply_user_theme(self, user: User) -> None: def _apply_global_theme(self) -> None:
"""Load the user's preferred GTK theme from their settings.ini.""" """Apply the GTK theme from moongreet.toml configuration."""
gtk_config_dir = user.home / ".config" / "gtk-4.0" theme_name = self._config.gtk_theme
theme_name = get_user_gtk_theme(config_dir=gtk_config_dir) if not theme_name:
return
settings = Gtk.Settings.get_default() settings = Gtk.Settings.get_default()
if settings is None: if settings is None:
return return
current = settings.get_property("gtk-theme-name") settings.set_property("gtk-theme-name", theme_name)
if theme_name and current != theme_name:
settings.set_property("gtk-theme-name", theme_name)
elif not theme_name and current:
settings.reset_property("gtk-theme-name")
def _get_foreground_color(self) -> str: def _get_foreground_color(self) -> str:
"""Get the current GTK theme foreground color as a hex string.""" """Get the current GTK theme foreground color as a hex string."""
@ -479,6 +478,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
if response.get("type") == "success": if response.get("type") == "success":
self._save_last_user(user.username) self._save_last_user(user.username)
self._save_last_session(user.username, session.name)
self._close_greetd_sock() self._close_greetd_sock()
GLib.idle_add(self.get_application().quit) GLib.idle_add(self.get_application().quit)
return return
@ -533,6 +533,18 @@ class GreeterWindow(Gtk.ApplicationWindow):
return self._sessions[idx] return self._sessions[idx]
return None return None
def _select_last_session(self, user: User) -> None:
"""Pre-select the last used session for a user in the dropdown."""
if not self._sessions:
return
last_session_name = self._load_last_session(user.username)
if not last_session_name:
return
for i, session in enumerate(self._sessions):
if session.name == last_session_name:
self._session_dropdown.set_selected(i)
return
MAX_GREETD_ERROR_LENGTH = 200 MAX_GREETD_ERROR_LENGTH = 200
def _show_greetd_error(self, response: dict, fallback: str) -> None: def _show_greetd_error(self, response: dict, fallback: str) -> None:
@ -585,3 +597,30 @@ class GreeterWindow(Gtk.ApplicationWindow):
LAST_USER_PATH.write_text(username) LAST_USER_PATH.write_text(username)
except OSError: except OSError:
pass # Non-critical — cache dir may not be writable pass # Non-critical — cache dir may not be writable
MAX_SESSION_NAME_LENGTH = 256
@staticmethod
def _save_last_session(username: str, session_name: str) -> None:
"""Save the last used session name for a user to cache."""
if not VALID_USERNAME.match(username) or len(username) > MAX_USERNAME_LENGTH:
return
try:
LAST_SESSION_DIR.mkdir(parents=True, exist_ok=True)
(LAST_SESSION_DIR / username).write_text(session_name)
except OSError:
pass # Non-critical — cache dir may not be writable
@staticmethod
def _load_last_session(username: str) -> str | None:
"""Load the last used session name for a user from cache."""
session_file = LAST_SESSION_DIR / username
if not session_file.exists():
return None
try:
name = session_file.read_text().strip()
except OSError:
return None
if not name or len(name) > GreeterWindow.MAX_SESSION_NAME_LENGTH:
return None
return name

View File

@ -22,14 +22,14 @@ def _recvall(sock: Any, n: int) -> bytes:
def send_message(sock: Any, msg: dict) -> None: def send_message(sock: Any, msg: dict) -> None:
"""Send a length-prefixed JSON message to the greetd socket.""" """Send a length-prefixed JSON message to the greetd socket."""
payload = json.dumps(msg).encode("utf-8") payload = json.dumps(msg).encode("utf-8")
header = struct.pack("!I", len(payload)) header = struct.pack("=I", len(payload))
sock.sendall(header + payload) sock.sendall(header + payload)
def recv_message(sock: Any) -> dict: def recv_message(sock: Any) -> dict:
"""Receive a length-prefixed JSON message from the greetd socket.""" """Receive a length-prefixed JSON message from the greetd socket."""
header = _recvall(sock, 4) header = _recvall(sock, 4)
length = struct.unpack("!I", header)[0] length = struct.unpack("=I", header)[0]
if length > MAX_PAYLOAD_SIZE: if length > MAX_PAYLOAD_SIZE:
raise ConnectionError(f"Payload too large: {length} bytes (max {MAX_PAYLOAD_SIZE})") raise ConnectionError(f"Payload too large: {length} bytes (max {MAX_PAYLOAD_SIZE})")

View File

@ -43,6 +43,36 @@ class TestLoadConfig:
assert config.background is None assert config.background is None
def test_loads_gtk_theme(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text(
"[appearance]\n"
'gtk-theme = "Catppuccin-Mocha-Standard-Blue-Dark"\n'
)
config = load_config(toml_file)
assert config.gtk_theme == "Catppuccin-Mocha-Standard-Blue-Dark"
def test_returns_none_gtk_theme_when_missing(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text("[appearance]\n")
config = load_config(toml_file)
assert config.gtk_theme is None
def test_rejects_gtk_theme_with_path_traversal(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text(
"[appearance]\n"
'gtk-theme = "../../etc/evil"\n'
)
config = load_config(toml_file)
assert config.gtk_theme 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(

View File

@ -10,7 +10,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, LAST_SESSION_DIR
from moongreet.i18n import load_strings 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
@ -59,14 +59,14 @@ class MockGreetd:
header = self._recvall(conn, 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 = self._recvall(conn, 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)
# Send response # Send response
resp_payload = json.dumps(response).encode("utf-8") resp_payload = json.dumps(response).encode("utf-8")
conn.sendall(struct.pack("!I", len(resp_payload)) + resp_payload) conn.sendall(struct.pack("=I", len(resp_payload)) + resp_payload)
finally: finally:
conn.close() conn.close()
@ -264,3 +264,45 @@ 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
class TestLastSession:
"""Tests for saving and loading the last session per user."""
def test_save_and_load_last_session(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
from moongreet.greeter import GreeterWindow
GreeterWindow._save_last_session("dominik", "Niri")
session_file = tmp_path / "dominik"
assert session_file.exists()
assert session_file.read_text() == "Niri"
result = GreeterWindow._load_last_session("dominik")
assert result == "Niri"
def test_load_last_session_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_session("nobody")
assert result is None
def test_load_last_session_rejects_oversized_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
(tmp_path / "dominik").write_text("A" * 300)
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_session("dominik")
assert result is None
def test_save_last_session_validates_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Usernames with path traversal should not create files outside the cache dir."""
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
from moongreet.greeter import GreeterWindow
GreeterWindow._save_last_session("../../etc/evil", "Niri")
# Should not have created any file
assert not (tmp_path / "../../etc/evil").exists()

View File

@ -38,7 +38,7 @@ class FakeSocket:
def with_response(cls, response: dict) -> "FakeSocket": def with_response(cls, response: dict) -> "FakeSocket":
"""Create a FakeSocket pre-loaded with a length-prefixed JSON response.""" """Create a FakeSocket pre-loaded with a length-prefixed JSON response."""
payload = json.dumps(response).encode("utf-8") payload = json.dumps(response).encode("utf-8")
data = struct.pack("!I", len(payload)) + payload data = struct.pack("=I", len(payload)) + payload
return cls(recv_data=data) return cls(recv_data=data)
@ -73,7 +73,7 @@ class TestSendMessage:
send_message(sock, msg) send_message(sock, msg)
payload = json.dumps(msg).encode("utf-8") payload = json.dumps(msg).encode("utf-8")
expected = struct.pack("!I", len(payload)) + payload expected = struct.pack("=I", len(payload)) + payload
assert bytes(sock.sent) == expected assert bytes(sock.sent) == expected
def test_sends_empty_dict(self) -> None: def test_sends_empty_dict(self) -> None:
@ -82,7 +82,7 @@ class TestSendMessage:
send_message(sock, {}) send_message(sock, {})
payload = json.dumps({}).encode("utf-8") payload = json.dumps({}).encode("utf-8")
expected = struct.pack("!I", len(payload)) + payload expected = struct.pack("=I", len(payload)) + payload
assert bytes(sock.sent) == expected assert bytes(sock.sent) == expected
def test_sends_nested_message(self) -> None: def test_sends_nested_message(self) -> None:
@ -93,7 +93,7 @@ class TestSendMessage:
# Verify the payload is correctly length-prefixed # Verify the payload is correctly length-prefixed
length_bytes = bytes(sock.sent[:4]) length_bytes = bytes(sock.sent[:4])
length = struct.unpack("!I", length_bytes)[0] length = struct.unpack("=I", length_bytes)[0]
decoded = json.loads(sock.sent[4:]) decoded = json.loads(sock.sent[4:])
assert length == len(json.dumps(msg).encode("utf-8")) assert length == len(json.dumps(msg).encode("utf-8"))
assert decoded == msg assert decoded == msg
@ -132,7 +132,7 @@ class TestRecvMessage:
"""recv() may return fewer bytes than requested — must loop.""" """recv() may return fewer bytes than requested — must loop."""
response = {"type": "success"} response = {"type": "success"}
payload = json.dumps(response).encode("utf-8") payload = json.dumps(response).encode("utf-8")
data = struct.pack("!I", len(payload)) + payload data = struct.pack("=I", len(payload)) + payload
sock = FragmentingSocket(data, chunk_size=3) sock = FragmentingSocket(data, chunk_size=3)
result = recv_message(sock) result = recv_message(sock)
@ -141,7 +141,7 @@ class TestRecvMessage:
def test_rejects_oversized_payload(self) -> None: def test_rejects_oversized_payload(self) -> None:
"""Payloads exceeding the size limit must be rejected.""" """Payloads exceeding the size limit must be rejected."""
header = struct.pack("!I", 10_000_000) header = struct.pack("=I", 10_000_000)
sock = FakeSocket(recv_data=header) sock = FakeSocket(recv_data=header)
with pytest.raises(ConnectionError, match="too large"): with pytest.raises(ConnectionError, match="too large"):
@ -162,7 +162,7 @@ class TestCreateSession:
result = create_session(sock, "dominik") result = create_session(sock, "dominik")
# Verify sent message # Verify sent message
length = struct.unpack("!I", bytes(sock.sent[:4]))[0] length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length]) sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {"type": "create_session", "username": "dominik"} assert sent_msg == {"type": "create_session", "username": "dominik"}
assert result == response assert result == response
@ -177,7 +177,7 @@ class TestPostAuthResponse:
result = post_auth_response(sock, "mypassword") result = post_auth_response(sock, "mypassword")
length = struct.unpack("!I", bytes(sock.sent[:4]))[0] length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length]) sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == { assert sent_msg == {
"type": "post_auth_message_response", "type": "post_auth_message_response",
@ -192,7 +192,7 @@ class TestPostAuthResponse:
result = post_auth_response(sock, None) result = post_auth_response(sock, None)
length = struct.unpack("!I", bytes(sock.sent[:4]))[0] length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length]) sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == { assert sent_msg == {
"type": "post_auth_message_response", "type": "post_auth_message_response",
@ -209,7 +209,7 @@ class TestStartSession:
result = start_session(sock, ["Hyprland"]) result = start_session(sock, ["Hyprland"])
length = struct.unpack("!I", bytes(sock.sent[:4]))[0] length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length]) sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {"type": "start_session", "cmd": ["Hyprland"]} assert sent_msg == {"type": "start_session", "cmd": ["Hyprland"]}
assert result == response assert result == response
@ -220,7 +220,7 @@ class TestStartSession:
result = start_session(sock, ["sway", "--config", "/etc/sway/config"]) result = start_session(sock, ["sway", "--config", "/etc/sway/config"])
length = struct.unpack("!I", bytes(sock.sent[:4]))[0] length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length]) sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == { assert sent_msg == {
"type": "start_session", "type": "start_session",
@ -237,7 +237,7 @@ class TestCancelSession:
result = cancel_session(sock) result = cancel_session(sock)
length = struct.unpack("!I", bytes(sock.sent[:4]))[0] length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length]) sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {"type": "cancel_session"} assert sent_msg == {"type": "cancel_session"}
assert result == response assert result == response