commit 87c2e7d9c8cc3c0fb7aab1993450ad95b9d37873 Author: nevaforget Date: Thu Mar 26 09:47:19 2026 +0100 feat: initial Moongreet greeter implementation greetd-Greeter für Wayland mit Python + GTK4 + gtk4-layer-shell. Enthält IPC-Protokoll, User/Session-Erkennung, Power-Actions, komplettes UI-Layout und 36 Tests (Unit + Integration). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b32f0fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.venv/ +.pytest_cache/ +.pyright/ +*.egg diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5040941 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# Moongreet + +**Name**: Selene (Mondgöttin — passend zu Moon-greet) + +## Projekt + +Moongreet ist ein greetd-Greeter für Wayland, gebaut mit Python + GTK4 + gtk4-layer-shell. +Teil des Moonarch-Ökosystems. + +## Tech-Stack + +- Python 3.11+, PyGObject (GTK 4.0) +- gtk4-layer-shell für Wayland Layer Shell +- greetd IPC über Unix Domain Socket (length-prefixed JSON) +- pytest für Tests + +## Projektstruktur + +- `src/moongreet/` — Quellcode +- `tests/` — pytest Tests +- `data/` — Assets (Icons, Default-Avatar) +- `config/` — Konfigurationsdateien + +## Kommandos + +```bash +# Tests ausführen +uv run pytest tests/ -v + +# Typ-Checks +uv run pyright src/ + +# Greeter starten (nur zum Testen, braucht normalerweise greetd) +uv run moongreet +``` + +## Architektur + +- `ipc.py` — greetd Socket-Kommunikation (length-prefixed JSON) +- `users.py` — Benutzer aus /etc/passwd, Avatare, GTK-Themes +- `sessions.py` — Wayland/X11 Sessions aus .desktop Files +- `power.py` — Reboot/Shutdown via loginctl +- `greeter.py` — GTK4 UI (Overlay-Layout) +- `main.py` — Entry Point, GTK App, Layer Shell Setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8ba533 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Moongreet + +A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell. +Part of the Moonarch ecosystem. + +## Features + +- **greetd IPC** — Communicates via `$GREETD_SOCK` (length-prefixed JSON) +- **User list** — Parsed from `/etc/passwd` (UID 1000–65533) +- **Avatars** — AccountsService icons, `~/.face` fallback, default SVG +- **Sessions** — Discovered from `/usr/share/wayland-sessions/` and `/usr/share/xsessions/` +- **Last user** — Remembered in `/var/cache/moongreet/last-user` +- **Power actions** — Reboot / Shutdown via `loginctl` +- **Layer Shell** — Fullscreen via gtk4-layer-shell + +## Requirements + +- Python 3.11+ +- GTK 4, PyGObject +- gtk4-layer-shell (for Wayland fullscreen) +- greetd + +## Installation + +```bash +uv pip install -e . +``` + +## Usage + +Configure greetd to use Moongreet: + +```ini +[default_session] +command = "moongreet" +``` + +## Development + +```bash +# Run tests +uv run pytest tests/ -v + +# Type checking +uv run pyright src/ +``` + +## License + +MIT diff --git a/config/moongreet.toml b/config/moongreet.toml new file mode 100644 index 0000000..ac0f1ac --- /dev/null +++ b/config/moongreet.toml @@ -0,0 +1,9 @@ +# ABOUTME: Optional configuration for the Moongreet greeter. +# ABOUTME: Background image and other visual settings. + +[appearance] +# background = "/usr/share/backgrounds/moonarch.jpg" + +[behavior] +# show_user_list = true +# default_session = "Hyprland" diff --git a/data/default-avatar.svg b/data/default-avatar.svg new file mode 100644 index 0000000..f853292 --- /dev/null +++ b/data/default-avatar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5e7f5da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "moongreet" +version = "0.1.0" +description = "A greetd greeter for Wayland with GTK4" +requires-python = ">=3.11" +license = "MIT" +dependencies = [ + "PyGObject>=3.46", +] + +[project.scripts] +moongreet = "moongreet.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/moongreet"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.pyright] +pythonVersion = "3.11" +pythonPlatform = "Linux" +venvPath = "." +venv = ".venv" +typeCheckingMode = "standard" diff --git a/src/moongreet/__init__.py b/src/moongreet/__init__.py new file mode 100644 index 0000000..61840b5 --- /dev/null +++ b/src/moongreet/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4. +# ABOUTME: Part of the Moonarch ecosystem. diff --git a/src/moongreet/greeter.py b/src/moongreet/greeter.py new file mode 100644 index 0000000..ca698c2 --- /dev/null +++ b/src/moongreet/greeter.py @@ -0,0 +1,376 @@ +# 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 os +import socket +from pathlib import Path + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gtk, Gdk, GLib, Gio, GdkPixbuf + +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 + +LAST_USER_PATH = Path("/var/cache/moongreet/last-user") +DEFAULT_AVATAR_PATH = Path(__file__).parent.parent.parent / "data" / "default-avatar.svg" + + +class GreeterWindow(Gtk.ApplicationWindow): + """The main greeter window with login UI.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.add_css_class("greeter") + self.set_default_size(1920, 1080) + + self._users = get_users() + self._sessions = get_sessions() + self._selected_user: User | None = None + self._greetd_sock: socket.socket | None = None + + self._build_ui() + self._select_initial_user() + self._setup_keyboard_navigation() + + def _build_ui(self) -> None: + """Build the complete greeter UI layout.""" + # Root overlay for layering + overlay = Gtk.Overlay() + self.set_child(overlay) + + # Background fills the whole window + 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_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_box.set_hexpand(True) + main_box.set_vexpand(True) + overlay.add_overlay(main_box) + + # Top spacer + top_spacer = Gtk.Box() + top_spacer.set_vexpand(True) + main_box.append(top_spacer) + + # Center: login box + center_box = self._build_login_box() + center_box.set_halign(Gtk.Align.CENTER) + main_box.append(center_box) + + # Bottom spacer + bottom_spacer = Gtk.Box() + bottom_spacer.set_vexpand(True) + main_box.append(bottom_spacer) + + # Bottom bar overlay (user list left, power buttons right) + bottom_bar = self._build_bottom_bar() + bottom_bar.set_valign(Gtk.Align.END) + overlay.add_overlay(bottom_bar) + + def _build_login_box(self) -> Gtk.Box: + """Build the central login area with avatar, name, session, password.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add_css_class("login-box") + box.set_halign(Gtk.Align.CENTER) + box.set_valign(Gtk.Align.CENTER) + box.set_spacing(4) + + # Avatar — wrapped in a fixed-size frame to constrain the Picture + avatar_frame = Gtk.Box() + avatar_frame.set_size_request(96, 96) + avatar_frame.set_halign(Gtk.Align.CENTER) + avatar_frame.set_overflow(Gtk.Overflow.HIDDEN) + avatar_frame.add_css_class("avatar") + self._avatar_image = Gtk.Picture() + self._avatar_image.set_size_request(96, 96) + self._avatar_image.set_content_fit(Gtk.ContentFit.COVER) + self._avatar_image.set_hexpand(False) + self._avatar_image.set_vexpand(False) + avatar_frame.append(self._avatar_image) + box.append(avatar_frame) + + # Username label + self._username_label = Gtk.Label(label="") + self._username_label.add_css_class("username-label") + box.append(self._username_label) + + # Session dropdown + self._session_dropdown = Gtk.DropDown() + self._session_dropdown.add_css_class("session-dropdown") + self._session_dropdown.set_halign(Gtk.Align.CENTER) + if self._sessions: + session_names = [s.name for s in self._sessions] + string_list = Gtk.StringList.new(session_names) + self._session_dropdown.set_model(string_list) + box.append(self._session_dropdown) + + # Password entry + self._password_entry = Gtk.PasswordEntry() + self._password_entry.set_property("placeholder-text", "Passwort") + self._password_entry.set_property("show-peek-icon", True) + self._password_entry.add_css_class("password-entry") + self._password_entry.set_halign(Gtk.Align.CENTER) + self._password_entry.connect("activate", self._on_login_activate) + box.append(self._password_entry) + + # Error label (hidden by default) + self._error_label = Gtk.Label(label="") + self._error_label.add_css_class("error-label") + self._error_label.set_visible(False) + box.append(self._error_label) + + return box + + def _build_bottom_bar(self) -> Gtk.Box: + """Build the bottom bar with user list (left) and power buttons (right).""" + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + bar.set_hexpand(True) + bar.set_margin_start(16) + bar.set_margin_end(16) + bar.set_margin_bottom(16) + + # User list (left) + user_list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + user_list_box.add_css_class("user-list") + user_list_box.set_halign(Gtk.Align.START) + user_list_box.set_valign(Gtk.Align.END) + + for user in self._users: + btn = Gtk.Button(label=user.display_name) + btn.add_css_class("user-list-item") + btn.connect("clicked", self._on_user_clicked, user) + user_list_box.append(btn) + + bar.append(user_list_box) + + # Spacer + spacer = Gtk.Box() + spacer.set_hexpand(True) + bar.append(spacer) + + # Power buttons (right) + power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + power_box.set_halign(Gtk.Align.END) + power_box.set_valign(Gtk.Align.END) + power_box.set_spacing(8) + + reboot_btn = Gtk.Button() + reboot_btn.set_icon_name("system-reboot-symbolic") + reboot_btn.add_css_class("power-button") + reboot_btn.set_tooltip_text("Neustart") + reboot_btn.connect("clicked", self._on_reboot_clicked) + power_box.append(reboot_btn) + + shutdown_btn = Gtk.Button() + shutdown_btn.set_icon_name("system-shutdown-symbolic") + shutdown_btn.add_css_class("power-button") + shutdown_btn.set_tooltip_text("Herunterfahren") + shutdown_btn.connect("clicked", self._on_shutdown_clicked) + power_box.append(shutdown_btn) + + bar.append(power_box) + + return bar + + def _select_initial_user(self) -> None: + """Select the last user or the first available user.""" + if not self._users: + return + + # Try to load last user + last_username = self._load_last_user() + target_user = None + + if last_username: + for user in self._users: + if user.username == last_username: + target_user = user + break + + if target_user is None: + target_user = self._users[0] + + self._switch_to_user(target_user) + + def _switch_to_user(self, user: User) -> None: + """Update the UI to show the selected user.""" + self._selected_user = user + self._username_label.set_text(user.display_name) + self._password_entry.set_text("") + self._error_label.set_visible(False) + + # Update avatar + avatar_path = get_avatar_path( + user.username, home_dir=user.home + ) + if avatar_path and avatar_path.exists(): + self._avatar_image.set_filename(str(avatar_path)) + elif DEFAULT_AVATAR_PATH.exists(): + self._avatar_image.set_filename(str(DEFAULT_AVATAR_PATH)) + + # Apply user's GTK theme if available + self._apply_user_theme(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) + + settings = Gtk.Settings.get_default() + if settings is None: + return + + if theme_name: + settings.set_property("gtk-theme-name", theme_name) + else: + settings.reset_property("gtk-theme-name") + + def _setup_keyboard_navigation(self) -> None: + """Set up keyboard shortcuts.""" + controller = Gtk.EventControllerKey() + controller.connect("key-pressed", self._on_key_pressed) + self.add_controller(controller) + + def _on_key_pressed( + self, + controller: Gtk.EventControllerKey, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> bool: + """Handle global key presses.""" + if keyval == Gdk.KEY_Escape: + self._password_entry.set_text("") + self._error_label.set_visible(False) + return True + return False + + def _on_user_clicked(self, button: Gtk.Button, user: User) -> None: + """Handle user selection from the user list.""" + self._cancel_pending_session() + self._switch_to_user(user) + + def _on_login_activate(self, entry: Gtk.PasswordEntry) -> None: + """Handle Enter key in the password field — attempt login.""" + if not self._selected_user: + return + + password = entry.get_text() + session = self._get_selected_session() + if not session: + self._show_error("Keine Session ausgewählt") + return + + self._attempt_login(self._selected_user, password, session) + + def _attempt_login(self, user: User, password: str, session: Session) -> None: + """Attempt to authenticate and start a session via greetd IPC.""" + sock_path = os.environ.get("GREETD_SOCK") + if not sock_path: + self._show_error("GREETD_SOCK nicht gesetzt") + return + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(sock_path) + self._greetd_sock = sock + + # Step 1: Create session + response = create_session(sock, user.username) + + if response.get("type") == "error": + self._show_error(response.get("description", "Authentifizierung fehlgeschlagen")) + sock.close() + return + + # Step 2: Send password if auth message received + if response.get("type") == "auth_message": + response = post_auth_response(sock, password) + + if response.get("type") == "error": + self._show_error(response.get("description", "Falsches Passwort")) + sock.close() + return + + # Step 3: Start session + if response.get("type") == "success": + cmd = session.exec_cmd.split() + response = start_session(sock, cmd) + + if response.get("type") == "success": + self._save_last_user(user.username) + sock.close() + self.get_application().quit() + return + else: + self._show_error(response.get("description", "Session konnte nicht gestartet werden")) + + sock.close() + + except ConnectionError as e: + self._show_error(f"Verbindungsfehler: {e}") + except OSError as e: + self._show_error(f"Socket-Fehler: {e}") + + def _cancel_pending_session(self) -> None: + """Cancel any in-progress greetd session.""" + if self._greetd_sock: + try: + cancel_session(self._greetd_sock) + self._greetd_sock.close() + except (ConnectionError, OSError): + pass + self._greetd_sock = None + + def _get_selected_session(self) -> Session | None: + """Get the currently selected session from the dropdown.""" + if not self._sessions: + return None + idx = self._session_dropdown.get_selected() + if idx < len(self._sessions): + return self._sessions[idx] + return None + + def _show_error(self, message: str) -> None: + """Display an error message below the password field.""" + self._error_label.set_text(message) + self._error_label.set_visible(True) + self._password_entry.set_text("") + self._password_entry.grab_focus() + + def _on_reboot_clicked(self, button: Gtk.Button) -> None: + """Handle reboot button click.""" + reboot() + + def _on_shutdown_clicked(self, button: Gtk.Button) -> None: + """Handle shutdown button click.""" + shutdown() + + @staticmethod + def _load_last_user() -> str | None: + """Load the last logged-in username from cache.""" + if LAST_USER_PATH.exists(): + try: + return LAST_USER_PATH.read_text().strip() + except OSError: + return None + return None + + @staticmethod + def _save_last_user(username: str) -> None: + """Save the last logged-in username to cache.""" + try: + LAST_USER_PATH.parent.mkdir(parents=True, exist_ok=True) + LAST_USER_PATH.write_text(username) + except OSError: + pass # Non-critical — cache dir may not be writable diff --git a/src/moongreet/ipc.py b/src/moongreet/ipc.py new file mode 100644 index 0000000..ce4e951 --- /dev/null +++ b/src/moongreet/ipc.py @@ -0,0 +1,51 @@ +# ABOUTME: greetd IPC protocol implementation — communicates via Unix socket. +# ABOUTME: Uses length-prefixed JSON encoding as specified by the greetd IPC protocol. + +import json +import struct +from typing import Any + + +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)) + sock.sendall(header + payload) + + +def recv_message(sock: Any) -> dict: + """Receive a length-prefixed JSON message from the greetd socket.""" + header = sock.recv(4) + if len(header) < 4: + raise ConnectionError("Connection closed while reading message header") + + length = struct.unpack("!I", header)[0] + payload = sock.recv(length) + if len(payload) < length: + raise ConnectionError("Connection closed while reading message body") + + return json.loads(payload.decode("utf-8")) + + +def create_session(sock: Any, username: str) -> dict: + """Send a create_session request to greetd and return the response.""" + send_message(sock, {"type": "create_session", "username": username}) + return recv_message(sock) + + +def post_auth_response(sock: Any, response: str | None) -> dict: + """Send an authentication response (e.g. password) to greetd.""" + send_message(sock, {"type": "post_auth_message_response", "response": response}) + return recv_message(sock) + + +def start_session(sock: Any, cmd: list[str]) -> dict: + """Send a start_session request to launch the user's session.""" + send_message(sock, {"type": "start_session", "cmd": cmd}) + return recv_message(sock) + + +def cancel_session(sock: Any) -> dict: + """Cancel the current authentication session.""" + send_message(sock, {"type": "cancel_session"}) + return recv_message(sock) diff --git a/src/moongreet/main.py b/src/moongreet/main.py new file mode 100644 index 0000000..4e60497 --- /dev/null +++ b/src/moongreet/main.py @@ -0,0 +1,74 @@ +# ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell. +# ABOUTME: Handles CLI invocation and initializes the greeter window. + +import sys +import os + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gtk, Gdk + +from moongreet.greeter import GreeterWindow + +# gtk4-layer-shell is optional for development/testing +try: + gi.require_version("Gtk4LayerShell", "1.0") + from gi.repository import Gtk4LayerShell + HAS_LAYER_SHELL = True +except (ValueError, ImportError): + HAS_LAYER_SHELL = False + + +class MoongreetApp(Gtk.Application): + """GTK Application for the Moongreet greeter.""" + + def __init__(self) -> None: + super().__init__(application_id="dev.moonarch.moongreet") + + def do_activate(self) -> None: + """Create and present the greeter window.""" + self._load_css() + window = GreeterWindow(application=self) + + if HAS_LAYER_SHELL: + self._setup_layer_shell(window) + + window.present() + + def _load_css(self) -> None: + """Load the CSS stylesheet for the greeter.""" + css_provider = Gtk.CssProvider() + css_path = os.path.join(os.path.dirname(__file__), "style.css") + css_provider.load_from_path(css_path) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _setup_layer_shell(self, window: Gtk.Window) -> None: + """Configure gtk4-layer-shell for fullscreen greeter display.""" + Gtk4LayerShell.init_for_window(window) + Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP) + Gtk4LayerShell.set_keyboard_mode( + window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE + ) + # Anchor to all edges for fullscreen + for edge in [ + Gtk4LayerShell.Edge.TOP, + Gtk4LayerShell.Edge.BOTTOM, + Gtk4LayerShell.Edge.LEFT, + Gtk4LayerShell.Edge.RIGHT, + ]: + Gtk4LayerShell.set_anchor(window, edge, True) + + +def main() -> None: + """Run the Moongreet application.""" + app = MoongreetApp() + app.run(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/src/moongreet/power.py b/src/moongreet/power.py new file mode 100644 index 0000000..84f672e --- /dev/null +++ b/src/moongreet/power.py @@ -0,0 +1,14 @@ +# ABOUTME: Power actions — reboot and shutdown via loginctl. +# ABOUTME: Simple wrappers around system commands for the greeter UI. + +import subprocess + + +def reboot() -> None: + """Reboot the system via loginctl.""" + subprocess.run(["loginctl", "reboot"], check=True) + + +def shutdown() -> None: + """Shut down the system via loginctl.""" + subprocess.run(["loginctl", "poweroff"], check=True) diff --git a/src/moongreet/sessions.py b/src/moongreet/sessions.py new file mode 100644 index 0000000..a8a162e --- /dev/null +++ b/src/moongreet/sessions.py @@ -0,0 +1,62 @@ +# ABOUTME: Session detection — discovers available Wayland and X11 sessions. +# ABOUTME: Parses .desktop files from standard session directories. + +import configparser +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_WAYLAND_DIRS = [Path("/usr/share/wayland-sessions")] +DEFAULT_XSESSION_DIRS = [Path("/usr/share/xsessions")] + + +@dataclass +class Session: + """Represents an available login session.""" + + name: str + exec_cmd: str + session_type: str # "wayland" or "x11" + + +def _parse_desktop_file(path: Path, session_type: str) -> Session | None: + """Parse a .desktop file and return a Session, or None if invalid.""" + config = configparser.ConfigParser(interpolation=None) + config.read(path) + + section = "Desktop Entry" + if not config.has_section(section): + return None + + name = config.get(section, "Name", fallback=None) + exec_cmd = config.get(section, "Exec", fallback=None) + + if not name or not exec_cmd: + return None + + return Session(name=name, exec_cmd=exec_cmd, session_type=session_type) + + +def get_sessions( + wayland_dirs: list[Path] = DEFAULT_WAYLAND_DIRS, + xsession_dirs: list[Path] = DEFAULT_XSESSION_DIRS, +) -> list[Session]: + """Discover available sessions from .desktop files.""" + sessions: list[Session] = [] + + for directory in wayland_dirs: + if not directory.exists(): + continue + for desktop_file in sorted(directory.glob("*.desktop")): + session = _parse_desktop_file(desktop_file, "wayland") + if session: + sessions.append(session) + + for directory in xsession_dirs: + if not directory.exists(): + continue + for desktop_file in sorted(directory.glob("*.desktop")): + session = _parse_desktop_file(desktop_file, "x11") + if session: + sessions.append(session) + + return sessions diff --git a/src/moongreet/style.css b/src/moongreet/style.css new file mode 100644 index 0000000..89b1102 --- /dev/null +++ b/src/moongreet/style.css @@ -0,0 +1,96 @@ +/* ABOUTME: GTK4 CSS stylesheet for the Moongreet greeter. */ +/* ABOUTME: Defines styling for the login screen layout. */ + +/* Main window background */ +window.greeter { + background-color: #1a1a2e; + background-size: cover; + background-position: center; +} + +/* Central login area */ +.login-box { + padding: 40px; + border-radius: 12px; + background-color: alpha(@window_bg_color, 0.7); +} + +/* Round avatar image */ +.avatar { + border-radius: 50%; + min-width: 96px; + min-height: 96px; + background-color: #3a3a5c; + border: 3px solid alpha(white, 0.3); +} + +/* Username label */ +.username-label { + font-size: 24px; + font-weight: bold; + color: white; + margin-top: 12px; + margin-bottom: 8px; +} + +/* Session dropdown */ +.session-dropdown { + min-width: 200px; + margin-bottom: 12px; + border-radius: 6px; +} + +/* Password entry field */ +.password-entry { + min-width: 280px; + min-height: 40px; + border-radius: 20px; + padding-left: 16px; + padding-right: 16px; + font-size: 16px; + margin-top: 8px; +} + +/* Error message label */ +.error-label { + color: #ff6b6b; + font-size: 14px; + margin-top: 8px; +} + +/* User list on the bottom left */ +.user-list { + background-color: transparent; + padding: 8px; +} + +.user-list-item { + padding: 8px 16px; + border-radius: 8px; + color: white; + font-size: 14px; +} + +.user-list-item:hover { + background-color: alpha(white, 0.15); +} + +.user-list-item:selected { + background-color: alpha(white, 0.2); +} + +/* Power buttons on the bottom right */ +.power-button { + min-width: 48px; + min-height: 48px; + padding: 0px; + border-radius: 24px; + background-color: alpha(white, 0.1); + color: white; + border: none; + margin: 4px; +} + +.power-button:hover { + background-color: alpha(white, 0.25); +} diff --git a/src/moongreet/users.py b/src/moongreet/users.py new file mode 100644 index 0000000..46cd681 --- /dev/null +++ b/src/moongreet/users.py @@ -0,0 +1,98 @@ +# ABOUTME: User detection — parses /etc/passwd for login users, finds avatars and GTK themes. +# ABOUTME: Provides User dataclass and helper functions for the greeter UI. + +import configparser +from dataclasses import dataclass +from pathlib import Path + +NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"} +MIN_UID = 1000 +MAX_UID = 65533 + +DEFAULT_PASSWD = Path("/etc/passwd") +DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons") + + +@dataclass +class User: + """Represents a system user suitable for login.""" + + username: str + uid: int + gecos: str + home: Path + shell: str + + @property + def display_name(self) -> str: + """Return gecos if available, otherwise username.""" + return self.gecos if self.gecos else self.username + + +def get_users(passwd_path: Path = DEFAULT_PASSWD) -> list[User]: + """Parse /etc/passwd and return users with UID in the login range.""" + users: list[User] = [] + + if not passwd_path.exists(): + return users + + for line in passwd_path.read_text().splitlines(): + parts = line.split(":") + if len(parts) < 7: + continue + + username, _, uid_str, _, gecos, home, shell = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] + uid = int(uid_str) + + if uid < MIN_UID or uid > MAX_UID: + continue + if shell in NOLOGIN_SHELLS: + continue + + users.append(User( + username=username, + uid=uid, + gecos=gecos, + home=Path(home), + shell=shell, + )) + + return users + + +def get_avatar_path( + username: str, + accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR, + home_dir: Path | None = None, +) -> Path | None: + """Find avatar for a user: AccountsService icon → ~/.face → None.""" + # AccountsService icon + icon = accountsservice_dir / username + if icon.exists(): + return icon + + # ~/.face fallback + if home_dir is not None: + face = home_dir / ".face" + if face.exists(): + return face + + return None + + +def get_user_gtk_theme(config_dir: Path | None = None) -> str | None: + """Read the GTK theme name from a user's gtk-4.0/settings.ini.""" + if config_dir is None: + return None + + settings_file = config_dir / "settings.ini" + if not settings_file.exists(): + return None + + config = configparser.ConfigParser() + config.read(settings_file) + + if config.has_option("Settings", "gtk-theme-name"): + return config.get("Settings", "gtk-theme-name") + + return None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..71064c2 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,170 @@ +# ABOUTME: Integration tests — verifies the login flow end-to-end via a mock greetd socket. +# ABOUTME: Tests the IPC sequence: create_session → post_auth → start_session. + +import json +import os +import socket +import struct +import threading +from pathlib import Path + +import pytest + +from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session + + +class MockGreetd: + """A mock greetd server that listens on a Unix socket and responds to IPC messages.""" + + def __init__(self, sock_path: Path) -> None: + self.sock_path = sock_path + self._responses: list[dict] = [] + self._received: list[dict] = [] + self._server: socket.socket | None = None + + def expect(self, response: dict) -> None: + """Queue a response to send for the next received message.""" + self._responses.append(response) + + @property + def received(self) -> list[dict]: + return self._received + + def start(self) -> None: + """Start the mock server in a background thread.""" + self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._server.bind(str(self.sock_path)) + self._server.listen(1) + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + def _serve(self) -> None: + conn, _ = self._server.accept() + try: + for response in self._responses: + # Receive a message + header = conn.recv(4) + if len(header) < 4: + break + length = struct.unpack("!I", header)[0] + payload = conn.recv(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) + finally: + conn.close() + + def stop(self) -> None: + if self._server: + self._server.close() + + +class TestLoginFlow: + """Integration tests for the complete login flow via mock greetd.""" + + def test_successful_login(self, tmp_path: Path) -> None: + """Simulate a complete successful login: create → auth → start.""" + sock_path = tmp_path / "greetd.sock" + mock = MockGreetd(sock_path) + mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) + mock.expect({"type": "success"}) + mock.expect({"type": "success"}) + mock.start() + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(str(sock_path)) + + # Step 1: Create session + response = create_session(sock, "dominik") + assert response["type"] == "auth_message" + + # Step 2: Send password + response = post_auth_response(sock, "geheim") + assert response["type"] == "success" + + # Step 3: Start session + response = start_session(sock, ["Hyprland"]) + assert response["type"] == "success" + + sock.close() + finally: + mock.stop() + + # Verify what the mock received + assert mock.received[0] == {"type": "create_session", "username": "dominik"} + assert mock.received[1] == {"type": "post_auth_message_response", "response": "geheim"} + assert mock.received[2] == {"type": "start_session", "cmd": ["Hyprland"]} + + def test_wrong_password(self, tmp_path: Path) -> None: + """Simulate a failed login due to wrong password.""" + sock_path = tmp_path / "greetd.sock" + mock = MockGreetd(sock_path) + mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) + mock.expect({"type": "error", "error_type": "auth_error", "description": "Authentication failed"}) + mock.start() + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(str(sock_path)) + + response = create_session(sock, "dominik") + assert response["type"] == "auth_message" + + response = post_auth_response(sock, "falsch") + assert response["type"] == "error" + assert response["description"] == "Authentication failed" + + sock.close() + finally: + mock.stop() + + def test_cancel_session(self, tmp_path: Path) -> None: + """Simulate cancelling a session after create.""" + sock_path = tmp_path / "greetd.sock" + mock = MockGreetd(sock_path) + mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"}) + mock.expect({"type": "success"}) + mock.start() + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(str(sock_path)) + + create_session(sock, "dominik") + response = cancel_session(sock) + assert response["type"] == "success" + + sock.close() + finally: + mock.stop() + + assert mock.received[1] == {"type": "cancel_session"} + + +class TestLastUser: + """Tests for saving and loading the last logged-in user.""" + + def test_save_and_load_last_user(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cache_path = tmp_path / "cache" / "moongreet" / "last-user" + monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path) + + from moongreet.greeter import GreeterWindow + GreeterWindow._save_last_user("dominik") + + assert cache_path.exists() + assert cache_path.read_text() == "dominik" + + result = GreeterWindow._load_last_user() + assert result == "dominik" + + def test_load_last_user_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cache_path = tmp_path / "nonexistent" / "last-user" + monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path) + + from moongreet.greeter import GreeterWindow + result = GreeterWindow._load_last_user() + assert result is None diff --git a/tests/test_ipc.py b/tests/test_ipc.py new file mode 100644 index 0000000..ab1cee1 --- /dev/null +++ b/tests/test_ipc.py @@ -0,0 +1,203 @@ +# ABOUTME: Tests for greetd IPC protocol — socket communication with length-prefixed JSON. +# ABOUTME: Uses mock sockets to verify message encoding/decoding and greetd request types. + +import json +import struct +import socket +from unittest.mock import MagicMock, patch + +import pytest + +from moongreet.ipc import ( + send_message, + recv_message, + create_session, + post_auth_response, + start_session, + cancel_session, +) + + +class FakeSocket: + """A fake socket that records sent data and provides canned receive data.""" + + def __init__(self, recv_data: bytes = b""): + self.sent = bytearray() + self._recv_data = recv_data + self._recv_offset = 0 + + def sendall(self, data: bytes) -> None: + self.sent.extend(data) + + def recv(self, n: int, flags: int = 0) -> bytes: + chunk = self._recv_data[self._recv_offset : self._recv_offset + n] + self._recv_offset += n + return chunk + + @classmethod + 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 + return cls(recv_data=data) + + +class TestSendMessage: + """Tests for encoding and sending length-prefixed JSON messages.""" + + def test_sends_length_prefixed_json(self) -> None: + sock = FakeSocket() + msg = {"type": "create_session", "username": "testuser"} + + send_message(sock, msg) + + payload = json.dumps(msg).encode("utf-8") + expected = struct.pack("!I", len(payload)) + payload + assert bytes(sock.sent) == expected + + def test_sends_empty_dict(self) -> None: + sock = FakeSocket() + + send_message(sock, {}) + + payload = json.dumps({}).encode("utf-8") + expected = struct.pack("!I", len(payload)) + payload + assert bytes(sock.sent) == expected + + def test_sends_nested_message(self) -> None: + sock = FakeSocket() + msg = {"type": "post_auth_message_response", "response": "secret123"} + + send_message(sock, msg) + + # Verify the payload is correctly length-prefixed + length_bytes = bytes(sock.sent[:4]) + 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 + + +class TestRecvMessage: + """Tests for receiving and decoding length-prefixed JSON messages.""" + + def test_receives_valid_message(self) -> None: + response = {"type": "success"} + sock = FakeSocket.with_response(response) + + result = recv_message(sock) + + assert result == response + + def test_receives_complex_message(self) -> None: + response = { + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password:", + } + sock = FakeSocket.with_response(response) + + result = recv_message(sock) + + assert result == response + + def test_raises_on_empty_recv(self) -> None: + sock = FakeSocket(recv_data=b"") + + with pytest.raises(ConnectionError): + recv_message(sock) + + +class TestCreateSession: + """Tests for the create_session greetd request.""" + + def test_sends_create_session_request(self) -> None: + response = { + "type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password:", + } + sock = FakeSocket.with_response(response) + + result = create_session(sock, "dominik") + + # Verify sent message + 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 + + +class TestPostAuthResponse: + """Tests for posting authentication responses (passwords).""" + + def test_sends_password_response(self) -> None: + response = {"type": "success"} + sock = FakeSocket.with_response(response) + + result = post_auth_response(sock, "mypassword") + + 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", + "response": "mypassword", + } + assert result == response + + def test_sends_none_response(self) -> None: + """For auth types that don't require a response.""" + response = {"type": "success"} + sock = FakeSocket.with_response(response) + + result = post_auth_response(sock, None) + + 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", + "response": None, + } + + +class TestStartSession: + """Tests for starting a session after authentication.""" + + def test_sends_start_session_request(self) -> None: + response = {"type": "success"} + sock = FakeSocket.with_response(response) + + result = start_session(sock, ["Hyprland"]) + + 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 + + def test_sends_multi_arg_command(self) -> None: + response = {"type": "success"} + sock = FakeSocket.with_response(response) + + result = start_session(sock, ["sway", "--config", "/etc/sway/config"]) + + 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": ["sway", "--config", "/etc/sway/config"], + } + + +class TestCancelSession: + """Tests for cancelling an in-progress session.""" + + def test_sends_cancel_session_request(self) -> None: + response = {"type": "success"} + sock = FakeSocket.with_response(response) + + result = cancel_session(sock) + + 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 diff --git a/tests/test_power.py b/tests/test_power.py new file mode 100644 index 0000000..21eccf3 --- /dev/null +++ b/tests/test_power.py @@ -0,0 +1,32 @@ +# ABOUTME: Tests for power actions — reboot and shutdown via loginctl. +# ABOUTME: Uses mocking to avoid actually calling system commands. + +from unittest.mock import patch, call + +import pytest + +from moongreet.power import reboot, shutdown + + +class TestReboot: + """Tests for the reboot power action.""" + + @patch("moongreet.power.subprocess.run") + def test_calls_loginctl_reboot(self, mock_run) -> None: + reboot() + + mock_run.assert_called_once_with( + ["loginctl", "reboot"], check=True + ) + + +class TestShutdown: + """Tests for the shutdown power action.""" + + @patch("moongreet.power.subprocess.run") + def test_calls_loginctl_poweroff(self, mock_run) -> None: + shutdown() + + mock_run.assert_called_once_with( + ["loginctl", "poweroff"], check=True + ) diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 0000000..3e94858 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,104 @@ +# ABOUTME: Tests for session detection — parsing .desktop files from wayland/xsessions dirs. +# ABOUTME: Uses temporary directories to simulate session file locations. + +from pathlib import Path + +import pytest + +from moongreet.sessions import Session, get_sessions + + +class TestGetSessions: + """Tests for discovering available sessions from .desktop files.""" + + def test_finds_wayland_session(self, tmp_path: Path) -> None: + wayland_dir = tmp_path / "wayland-sessions" + wayland_dir.mkdir() + desktop = wayland_dir / "hyprland.desktop" + desktop.write_text( + "[Desktop Entry]\n" + "Name=Hyprland\n" + "Exec=Hyprland\n" + "Type=Application\n" + ) + + sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) + + assert len(sessions) == 1 + assert sessions[0].name == "Hyprland" + assert sessions[0].exec_cmd == "Hyprland" + assert sessions[0].session_type == "wayland" + + def test_finds_xsession(self, tmp_path: Path) -> None: + x_dir = tmp_path / "xsessions" + x_dir.mkdir() + desktop = x_dir / "i3.desktop" + desktop.write_text( + "[Desktop Entry]\n" + "Name=i3\n" + "Exec=i3\n" + "Type=Application\n" + ) + + sessions = get_sessions(wayland_dirs=[], xsession_dirs=[x_dir]) + + assert len(sessions) == 1 + assert sessions[0].session_type == "x11" + + def test_finds_sessions_from_multiple_dirs(self, tmp_path: Path) -> None: + wayland_dir = tmp_path / "wayland-sessions" + wayland_dir.mkdir() + (wayland_dir / "sway.desktop").write_text( + "[Desktop Entry]\nName=Sway\nExec=sway\n" + ) + + x_dir = tmp_path / "xsessions" + x_dir.mkdir() + (x_dir / "openbox.desktop").write_text( + "[Desktop Entry]\nName=Openbox\nExec=openbox-session\n" + ) + + sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[x_dir]) + + names = {s.name for s in sessions} + assert names == {"Sway", "Openbox"} + + def test_returns_empty_for_no_sessions(self, tmp_path: Path) -> None: + empty = tmp_path / "empty" + + sessions = get_sessions(wayland_dirs=[empty], xsession_dirs=[empty]) + + assert sessions == [] + + def test_skips_files_without_name(self, tmp_path: Path) -> None: + wayland_dir = tmp_path / "wayland-sessions" + wayland_dir.mkdir() + (wayland_dir / "broken.desktop").write_text( + "[Desktop Entry]\nExec=something\n" + ) + + sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) + + assert sessions == [] + + def test_skips_files_without_exec(self, tmp_path: Path) -> None: + wayland_dir = tmp_path / "wayland-sessions" + wayland_dir.mkdir() + (wayland_dir / "noexec.desktop").write_text( + "[Desktop Entry]\nName=NoExec\n" + ) + + sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) + + assert sessions == [] + + def test_handles_exec_with_arguments(self, tmp_path: Path) -> None: + wayland_dir = tmp_path / "wayland-sessions" + wayland_dir.mkdir() + (wayland_dir / "sway.desktop").write_text( + "[Desktop Entry]\nName=Sway\nExec=sway --config /etc/sway/config\n" + ) + + sessions = get_sessions(wayland_dirs=[wayland_dir], xsession_dirs=[]) + + assert sessions[0].exec_cmd == "sway --config /etc/sway/config" diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..beb11cc --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,134 @@ +# ABOUTME: Tests for user detection — parsing /etc/passwd, avatar lookup, GTK theme reading. +# ABOUTME: Uses temporary files and mocking to avoid system dependencies. + +from pathlib import Path +from dataclasses import dataclass + +import pytest + +from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme + + +class TestGetUsers: + """Tests for parsing /etc/passwd to find login users.""" + + def test_returns_users_in_uid_range(self, tmp_path: Path) -> None: + passwd = tmp_path / "passwd" + passwd.write_text( + "root:x:0:0:root:/root:/bin/bash\n" + "nobody:x:65534:65534:Nobody:/:/usr/bin/nologin\n" + "dominik:x:1000:1000:Dominik:/home/dominik:/bin/zsh\n" + "testuser:x:1001:1001:Test User:/home/testuser:/bin/bash\n" + ) + + users = get_users(passwd_path=passwd) + + assert len(users) == 2 + assert users[0].username == "dominik" + assert users[0].uid == 1000 + assert users[0].gecos == "Dominik" + assert users[0].home == Path("/home/dominik") + assert users[1].username == "testuser" + + def test_excludes_nologin_shells(self, tmp_path: Path) -> None: + passwd = tmp_path / "passwd" + passwd.write_text( + "systemuser:x:1000:1000:System:/home/system:/usr/sbin/nologin\n" + "falseuser:x:1001:1001:False:/home/false:/bin/false\n" + "realuser:x:1002:1002:Real:/home/real:/bin/bash\n" + ) + + users = get_users(passwd_path=passwd) + + assert len(users) == 1 + assert users[0].username == "realuser" + + def test_returns_empty_for_no_matching_users(self, tmp_path: Path) -> None: + passwd = tmp_path / "passwd" + passwd.write_text("root:x:0:0:root:/root:/bin/bash\n") + + users = get_users(passwd_path=passwd) + + assert users == [] + + def test_handles_missing_gecos_field(self, tmp_path: Path) -> None: + passwd = tmp_path / "passwd" + passwd.write_text("user:x:1000:1000::/home/user:/bin/bash\n") + + users = get_users(passwd_path=passwd) + + assert len(users) == 1 + assert users[0].gecos == "" + assert users[0].display_name == "user" + + +class TestGetAvatarPath: + """Tests for avatar file lookup.""" + + def test_finds_accountsservice_icon(self, tmp_path: Path) -> None: + icons_dir = tmp_path / "icons" + icons_dir.mkdir() + avatar = icons_dir / "dominik" + avatar.write_bytes(b"PNG") + + result = get_avatar_path("dominik", accountsservice_dir=icons_dir) + + assert result == avatar + + def test_falls_back_to_dot_face(self, tmp_path: Path) -> None: + home = tmp_path / "home" / "dominik" + home.mkdir(parents=True) + face = home / ".face" + face.write_bytes(b"PNG") + empty_icons = tmp_path / "no_icons" + + result = get_avatar_path( + "dominik", accountsservice_dir=empty_icons, home_dir=home + ) + + assert result == face + + def test_returns_none_when_no_avatar(self, tmp_path: Path) -> None: + empty_icons = tmp_path / "no_icons" + home = tmp_path / "home" / "nobody" + + result = get_avatar_path( + "nobody", accountsservice_dir=empty_icons, home_dir=home + ) + + assert result is None + + +class TestGetUserGtkTheme: + """Tests for reading GTK theme from user's settings.ini.""" + + def test_reads_theme_from_settings(self, tmp_path: Path) -> None: + gtk_dir = tmp_path / ".config" / "gtk-4.0" + gtk_dir.mkdir(parents=True) + settings = gtk_dir / "settings.ini" + settings.write_text( + "[Settings]\n" + "gtk-theme-name=Adwaita-dark\n" + "gtk-icon-theme-name=Papirus\n" + ) + + result = get_user_gtk_theme(config_dir=gtk_dir) + + assert result == "Adwaita-dark" + + def test_returns_none_when_no_settings(self, tmp_path: Path) -> None: + gtk_dir = tmp_path / "nonexistent" + + result = get_user_gtk_theme(config_dir=gtk_dir) + + assert result is None + + def test_returns_none_when_no_theme_key(self, tmp_path: Path) -> None: + gtk_dir = tmp_path / ".config" / "gtk-4.0" + gtk_dir.mkdir(parents=True) + settings = gtk_dir / "settings.ini" + settings.write_text("[Settings]\ngtk-icon-theme-name=Papirus\n") + + result = get_user_gtk_theme(config_dir=gtk_dir) + + assert result is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..aeaae93 --- /dev/null +++ b/uv.lock @@ -0,0 +1,45 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "moongreet" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pygobject" }, +] + +[package.metadata] +requires-dist = [{ name = "pygobject", specifier = ">=3.46" }] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, + { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, + { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, + { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, +] + +[[package]] +name = "pygobject" +version = "3.56.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }