# 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 shlex import socket import stat import subprocess from importlib.resources import files 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, GdkPixbuf from moongreet.config import load_config from moongreet.i18n import load_strings, Strings from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme from moongreet.sessions import Session, get_sessions from moongreet.power import reboot, shutdown LAST_USER_PATH = Path("/var/cache/moongreet/last-user") FAILLOCK_MAX_ATTEMPTS = 3 PACKAGE_DATA = files("moongreet") / "data" DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg" DEFAULT_WALLPAPER_PATH = PACKAGE_DATA / "wallpaper.jpg" AVATAR_SIZE = 128 def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str | None: """Return a warning if the user is approaching or has reached the faillock limit.""" if strings is None: strings = load_strings() remaining = FAILLOCK_MAX_ATTEMPTS - attempt_count if remaining <= 0: return strings.faillock_locked if remaining == 1: return strings.faillock_attempts_remaining.format(n=remaining) return None 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._config = load_config() self._strings = load_strings() self._users = get_users() self._sessions = get_sessions() self._selected_user: User | None = None self._greetd_sock: socket.socket | None = None self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {} self._failed_attempts: dict[str, int] = {} 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 wallpaper (blurred and darkened) bg_path = self._config.background if not bg_path or not bg_path.exists(): bg_path = Path(str(DEFAULT_WALLPAPER_PATH)) if bg_path.exists(): background = Gtk.Picture() background.set_filename(str(bg_path)) background.set_content_fit(Gtk.ContentFit.COVER) background.set_hexpand(True) background.set_vexpand(True) else: 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(12) # Avatar — wrapped in a clipping frame for round shape avatar_frame = Gtk.Box() avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE) avatar_frame.set_halign(Gtk.Align.CENTER) avatar_frame.set_overflow(Gtk.Overflow.HIDDEN) avatar_frame.add_css_class("avatar") self._avatar_image = Gtk.Image() self._avatar_image.set_pixel_size(AVATAR_SIZE) 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_hexpand(True) 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_hexpand(True) self._password_entry.set_property("placeholder-text", self._strings.password_placeholder) self._password_entry.set_property("show-peek-icon", True) self._password_entry.add_css_class("password-entry") 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(self._strings.reboot_tooltip) 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(self._strings.shutdown_tooltip) 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 (use cache if available) if user.username in self._avatar_cache: self._avatar_image.set_from_pixbuf(self._avatar_cache[user.username]) else: avatar_path = get_avatar_path( user.username, home_dir=user.home ) if avatar_path and avatar_path.exists(): self._set_avatar_from_file(avatar_path, user.username) elif DEFAULT_AVATAR_PATH.exists(): self._set_default_avatar() else: self._avatar_image.set_from_icon_name("avatar-default-symbolic") # 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 current = settings.get_property("gtk-theme-name") if theme_name and current != theme_name: settings.set_property("gtk-theme-name", theme_name) elif not theme_name and current: settings.reset_property("gtk-theme-name") def _get_foreground_color(self) -> str: """Get the current GTK theme foreground color as a hex string.""" rgba = self.get_color() r = int(rgba.red * 255) g = int(rgba.green * 255) b = int(rgba.blue * 255) return f"#{r:02x}{g:02x}{b:02x}" def _set_default_avatar(self) -> None: """Load the default avatar SVG, tinted with the GTK foreground color.""" if self._default_avatar_pixbuf: self._avatar_image.set_from_pixbuf(self._default_avatar_pixbuf) return try: svg_text = DEFAULT_AVATAR_PATH.read_text() fg_color = self._get_foreground_color() svg_text = svg_text.replace("#PLACEHOLDER", fg_color) svg_bytes = svg_text.encode("utf-8") loader = GdkPixbuf.PixbufLoader.new_with_type("svg") loader.set_size(AVATAR_SIZE, AVATAR_SIZE) loader.write(svg_bytes) loader.close() pixbuf = loader.get_pixbuf() if pixbuf: self._default_avatar_pixbuf = pixbuf self._avatar_image.set_from_pixbuf(pixbuf) except (GLib.Error, OSError): self._avatar_image.set_from_icon_name("avatar-default-symbolic") def _set_avatar_from_file(self, path: Path, username: str | None = None) -> None: """Load an image file and set it as the avatar, scaled to AVATAR_SIZE.""" try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( str(path), AVATAR_SIZE, AVATAR_SIZE, True ) if username: self._avatar_cache[username] = pixbuf self._avatar_image.set_from_pixbuf(pixbuf) except GLib.Error: self._avatar_image.set_from_icon_name("avatar-default-symbolic") 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(self._strings.no_session_selected) return self._attempt_login(self._selected_user, password, session) def _validate_greetd_sock(self, sock_path: str) -> bool: """Validate that GREETD_SOCK points to an absolute path and a real socket.""" path = Path(sock_path) if not path.is_absolute(): self._show_error(self._strings.greetd_sock_not_absolute) return False try: mode = path.stat().st_mode if not stat.S_ISSOCK(mode): self._show_error(self._strings.greetd_sock_not_socket) return False except OSError: self._show_error(self._strings.greetd_sock_unreachable) return False return True def _close_greetd_sock(self) -> None: """Close the greetd socket and reset the reference.""" if self._greetd_sock: try: self._greetd_sock.close() except OSError: pass self._greetd_sock = None 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(self._strings.greetd_sock_not_set) return if not self._validate_greetd_sock(sock_path): return try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(10.0) 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_greetd_error(response, self._strings.auth_failed) self._close_greetd_sock() 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._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1 self._show_greetd_error(response, self._strings.wrong_password) warning = faillock_warning(self._failed_attempts[user.username], self._strings) if warning: current = self._error_label.get_text() self._error_label.set_text(f"{current}\n{warning}") self._close_greetd_sock() return if response.get("type") == "auth_message": # Multi-stage auth (e.g. TOTP) is not supported cancel_session(sock) self._close_greetd_sock() self._show_error(self._strings.multi_stage_unsupported) return # Step 3: Start session if response.get("type") == "success": cmd = shlex.split(session.exec_cmd) if not cmd or not Path(cmd[0]).is_absolute(): self._show_error(self._strings.invalid_session_command) cancel_session(sock) self._close_greetd_sock() return response = start_session(sock, cmd) if response.get("type") == "success": self._save_last_user(user.username) self._close_greetd_sock() self.get_application().quit() return else: self._show_greetd_error(response, self._strings.session_start_failed) self._close_greetd_sock() except ConnectionError as e: self._close_greetd_sock() self._show_error(self._strings.connection_error.format(error=e)) except OSError as e: self._close_greetd_sock() self._show_error(self._strings.socket_error.format(error=e)) def _cancel_pending_session(self) -> None: """Cancel any in-progress greetd session.""" if self._greetd_sock: try: cancel_session(self._greetd_sock) except (ConnectionError, OSError): pass self._close_greetd_sock() 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 MAX_GREETD_ERROR_LENGTH = 200 def _show_greetd_error(self, response: dict, fallback: str) -> None: """Display an error from greetd, using a fallback for missing or oversized descriptions.""" description = response.get("description", "") if description and len(description) <= self.MAX_GREETD_ERROR_LENGTH: self._show_error(description) else: self._show_error(fallback) 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.""" try: reboot() except subprocess.CalledProcessError: self._show_error(self._strings.reboot_failed) def _on_shutdown_clicked(self, button: Gtk.Button) -> None: """Handle shutdown button click.""" try: shutdown() except subprocess.CalledProcessError: self._show_error(self._strings.shutdown_failed) @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