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).
This commit is contained in:
commit
87c2e7d9c8
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.pyright/
|
||||||
|
*.egg
|
||||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@ -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
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@ -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
|
||||||
9
config/moongreet.toml
Normal file
9
config/moongreet.toml
Normal file
@ -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"
|
||||||
6
data/default-avatar.svg
Normal file
6
data/default-avatar.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="96" height="96">
|
||||||
|
<!-- Default user avatar: silhouette circle -->
|
||||||
|
<circle cx="60" cy="60" r="60" fill="#3a3a5c"/>
|
||||||
|
<circle cx="60" cy="45" r="20" fill="#6c6c8a"/>
|
||||||
|
<ellipse cx="60" cy="95" rx="35" ry="25" fill="#6c6c8a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
@ -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"
|
||||||
2
src/moongreet/__init__.py
Normal file
2
src/moongreet/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# ABOUTME: Moongreet package — a greetd greeter for Wayland with GTK4.
|
||||||
|
# ABOUTME: Part of the Moonarch ecosystem.
|
||||||
376
src/moongreet/greeter.py
Normal file
376
src/moongreet/greeter.py
Normal file
@ -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
|
||||||
51
src/moongreet/ipc.py
Normal file
51
src/moongreet/ipc.py
Normal file
@ -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)
|
||||||
74
src/moongreet/main.py
Normal file
74
src/moongreet/main.py
Normal file
@ -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()
|
||||||
14
src/moongreet/power.py
Normal file
14
src/moongreet/power.py
Normal file
@ -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)
|
||||||
62
src/moongreet/sessions.py
Normal file
62
src/moongreet/sessions.py
Normal file
@ -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
|
||||||
96
src/moongreet/style.css
Normal file
96
src/moongreet/style.css
Normal file
@ -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);
|
||||||
|
}
|
||||||
98
src/moongreet/users.py
Normal file
98
src/moongreet/users.py
Normal file
@ -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
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
170
tests/test_integration.py
Normal file
170
tests/test_integration.py
Normal file
@ -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
|
||||||
203
tests/test_ipc.py
Normal file
203
tests/test_ipc.py
Normal file
@ -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
|
||||||
32
tests/test_power.py
Normal file
32
tests/test_power.py
Normal file
@ -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
|
||||||
|
)
|
||||||
104
tests/test_sessions.py
Normal file
104
tests/test_sessions.py
Normal file
@ -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"
|
||||||
134
tests/test_users.py
Normal file
134
tests/test_users.py
Normal file
@ -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
|
||||||
45
uv.lock
generated
Normal file
45
uv.lock
generated
Normal file
@ -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" }
|
||||||
Loading…
x
Reference in New Issue
Block a user