diff --git a/config/moongreet.toml b/config/moongreet.toml index 1069fae..7259477 100644 --- a/config/moongreet.toml +++ b/config/moongreet.toml @@ -4,3 +4,5 @@ [appearance] # Absolute path to wallpaper image background = "/usr/share/backgrounds/wallpaper.jpg" +# GTK theme for the greeter UI +gtk-theme = "Catppuccin-Mocha-Standard-Blue-Dark" diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index fc1db28..a339437 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -4,7 +4,7 @@ # Maintainer: Dominik Kressler pkgname=moongreet-git -pkgver=0.1.0 +pkgver=0.1.0.r6.gba4f30f pkgrel=1 pkgdesc="A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell" arch=('any') @@ -46,6 +46,7 @@ package() { # Greeter config 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/last-session" } diff --git a/pkg/moongreet.install b/pkg/moongreet.install index 5b1f646..39a56ce 100644 --- a/pkg/moongreet.install +++ b/pkg/moongreet.install @@ -4,6 +4,7 @@ post_install() { if getent passwd greeter > /dev/null 2>&1; then chown greeter:greeter /var/cache/moongreet + chown greeter:greeter /var/cache/moongreet/last-session fi echo "==> Moongreet installed." diff --git a/src/moongreet/config.py b/src/moongreet/config.py index aa93afe..2fd6808 100644 --- a/src/moongreet/config.py +++ b/src/moongreet/config.py @@ -1,12 +1,15 @@ # ABOUTME: Configuration loading from moongreet.toml. # ABOUTME: Parses appearance and behavior settings with wallpaper path resolution. +import re import tomllib from contextlib import AbstractContextManager from dataclasses import dataclass from importlib.resources import as_file, files from pathlib import Path +VALID_THEME_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + DEFAULT_CONFIG_PATHS = [ Path("/etc/moongreet/moongreet.toml"), ] @@ -17,6 +20,7 @@ class Config: """Greeter configuration loaded from moongreet.toml.""" background: Path | None = None + gtk_theme: str | None = None 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 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 diff --git a/src/moongreet/greeter.py b/src/moongreet/greeter.py index 3c1d657..3722721 100644 --- a/src/moongreet/greeter.py +++ b/src/moongreet/greeter.py @@ -21,13 +21,14 @@ from gi.repository import Gtk, Gdk, GLib, GdkPixbuf 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.users import User, get_users, get_avatar_path 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") +LAST_SESSION_DIR = Path("/var/cache/moongreet/last-session") FAILLOCK_MAX_ATTEMPTS = 3 VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$") MAX_USERNAME_LENGTH = 256 @@ -93,6 +94,7 @@ class GreeterWindow(Gtk.ApplicationWindow): self._failed_attempts: dict[str, int] = {} self._bg_path = bg_path + self._apply_global_theme() self._build_ui() self._setup_keyboard_navigation() # 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 self._set_default_avatar() - # Apply user's GTK theme if available - self._apply_user_theme(user) + # Pre-select last used session for this user + self._select_last_session(user) # Focus password entry self._password_entry.grab_focus() - def _apply_user_theme(self, user: User) -> None: - """Load the user's preferred GTK theme from their settings.ini.""" - gtk_config_dir = user.home / ".config" / "gtk-4.0" - theme_name = get_user_gtk_theme(config_dir=gtk_config_dir) + def _apply_global_theme(self) -> None: + """Apply the GTK theme from moongreet.toml configuration.""" + theme_name = self._config.gtk_theme + if not theme_name: + return settings = Gtk.Settings.get_default() if settings is None: return - current = settings.get_property("gtk-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") + settings.set_property("gtk-theme-name", theme_name) def _get_foreground_color(self) -> str: """Get the current GTK theme foreground color as a hex string.""" @@ -479,6 +478,7 @@ class GreeterWindow(Gtk.ApplicationWindow): if response.get("type") == "success": self._save_last_user(user.username) + self._save_last_session(user.username, session.name) self._close_greetd_sock() GLib.idle_add(self.get_application().quit) return @@ -533,6 +533,18 @@ class GreeterWindow(Gtk.ApplicationWindow): return self._sessions[idx] 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 def _show_greetd_error(self, response: dict, fallback: str) -> None: @@ -585,3 +597,30 @@ class GreeterWindow(Gtk.ApplicationWindow): LAST_USER_PATH.write_text(username) except OSError: 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 diff --git a/src/moongreet/ipc.py b/src/moongreet/ipc.py index 562b977..4d8bdb9 100644 --- a/src/moongreet/ipc.py +++ b/src/moongreet/ipc.py @@ -22,14 +22,14 @@ def _recvall(sock: Any, n: int) -> bytes: def send_message(sock: Any, msg: dict) -> None: """Send a length-prefixed JSON message to the greetd socket.""" payload = json.dumps(msg).encode("utf-8") - header = struct.pack("!I", len(payload)) + header = struct.pack("=I", len(payload)) sock.sendall(header + payload) def recv_message(sock: Any) -> dict: """Receive a length-prefixed JSON message from the greetd socket.""" header = _recvall(sock, 4) - length = struct.unpack("!I", header)[0] + length = struct.unpack("=I", header)[0] if length > MAX_PAYLOAD_SIZE: raise ConnectionError(f"Payload too large: {length} bytes (max {MAX_PAYLOAD_SIZE})") diff --git a/tests/test_config.py b/tests/test_config.py index 88de72d..2cdd64b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -43,6 +43,36 @@ class TestLoadConfig: 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: toml_file = tmp_path / "moongreet.toml" toml_file.write_text( diff --git a/tests/test_integration.py b/tests/test_integration.py index a824e22..cd394fc 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -10,7 +10,7 @@ from pathlib import Path 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.ipc import create_session, post_auth_response, start_session, cancel_session @@ -59,14 +59,14 @@ class MockGreetd: header = self._recvall(conn, 4) if len(header) < 4: break - length = struct.unpack("!I", header)[0] + length = struct.unpack("=I", header)[0] payload = self._recvall(conn, length) msg = json.loads(payload.decode("utf-8")) self._received.append(msg) # Send response 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: conn.close() @@ -264,3 +264,45 @@ class TestLastUser: from moongreet.greeter import GreeterWindow result = GreeterWindow._load_last_user() 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() diff --git a/tests/test_ipc.py b/tests/test_ipc.py index 6bbd608..2538824 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -38,7 +38,7 @@ class FakeSocket: def with_response(cls, response: dict) -> "FakeSocket": """Create a FakeSocket pre-loaded with a length-prefixed JSON response.""" 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) @@ -73,7 +73,7 @@ class TestSendMessage: send_message(sock, msg) 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 def test_sends_empty_dict(self) -> None: @@ -82,7 +82,7 @@ class TestSendMessage: send_message(sock, {}) 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 def test_sends_nested_message(self) -> None: @@ -93,7 +93,7 @@ class TestSendMessage: # Verify the payload is correctly length-prefixed 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:]) assert length == len(json.dumps(msg).encode("utf-8")) assert decoded == msg @@ -132,7 +132,7 @@ class TestRecvMessage: """recv() may return fewer bytes than requested — must loop.""" response = {"type": "success"} 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) result = recv_message(sock) @@ -141,7 +141,7 @@ class TestRecvMessage: def test_rejects_oversized_payload(self) -> None: """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) with pytest.raises(ConnectionError, match="too large"): @@ -162,7 +162,7 @@ class TestCreateSession: result = create_session(sock, "dominik") # 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]) assert sent_msg == {"type": "create_session", "username": "dominik"} assert result == response @@ -177,7 +177,7 @@ class TestPostAuthResponse: 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]) assert sent_msg == { "type": "post_auth_message_response", @@ -192,7 +192,7 @@ class TestPostAuthResponse: 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]) assert sent_msg == { "type": "post_auth_message_response", @@ -209,7 +209,7 @@ class TestStartSession: 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]) assert sent_msg == {"type": "start_session", "cmd": ["Hyprland"]} assert result == response @@ -220,7 +220,7 @@ class TestStartSession: 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]) assert sent_msg == { "type": "start_session", @@ -237,7 +237,7 @@ class TestCancelSession: 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]) assert sent_msg == {"type": "cancel_session"} assert result == response