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:
nevaforget 2026-03-26 09:47:19 +01:00
commit 87c2e7d9c8
21 changed files with 1610 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.venv/
.pytest_cache/
.pyright/
*.egg

44
CLAUDE.md Normal file
View 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
View 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 100065533)
- **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
View 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
View 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
View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

170
tests/test_integration.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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" }