From 0f72df8603fc0d334e857b825414d12f9f2eb5f3 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Thu, 26 Mar 2026 11:55:41 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20i18n=20=E2=80=94=20Locale-basierte=20St?= =?UTF-8?q?rings=20(DE/EN)=20statt=20hardcoded=20Deutsch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liest LANG aus Umgebung oder /etc/locale.conf und liefert deutsche oder englische UI-Strings. Alle hardcoded Strings in greeter.py durch Strings-Dataclass ersetzt. Fallback auf Englisch bei unbekannter Locale. --- src/moongreet/greeter.py | 46 ++++++++------- src/moongreet/i18n.py | 113 +++++++++++++++++++++++++++++++++++++ tests/test_i18n.py | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 src/moongreet/i18n.py create mode 100644 tests/test_i18n.py diff --git a/src/moongreet/greeter.py b/src/moongreet/greeter.py index e976198..f8359d5 100644 --- a/src/moongreet/greeter.py +++ b/src/moongreet/greeter.py @@ -15,6 +15,7 @@ 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 @@ -27,13 +28,15 @@ DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg" AVATAR_SIZE = 128 -def faillock_warning(attempt_count: int) -> str | None: +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 "Konto ist möglicherweise gesperrt" + return strings.faillock_locked if remaining == 1: - return f"Noch {remaining} Versuch vor Kontosperrung!" + return strings.faillock_attempts_remaining.format(n=remaining) return None @@ -46,6 +49,7 @@ class GreeterWindow(Gtk.ApplicationWindow): 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 @@ -140,7 +144,7 @@ class GreeterWindow(Gtk.ApplicationWindow): # Password entry self._password_entry = Gtk.PasswordEntry() self._password_entry.set_hexpand(True) - self._password_entry.set_property("placeholder-text", "Passwort") + 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) @@ -190,14 +194,14 @@ class GreeterWindow(Gtk.ApplicationWindow): 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.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("Herunterfahren") + shutdown_btn.set_tooltip_text(self._strings.shutdown_tooltip) shutdown_btn.connect("clicked", self._on_shutdown_clicked) power_box.append(shutdown_btn) @@ -341,7 +345,7 @@ class GreeterWindow(Gtk.ApplicationWindow): password = entry.get_text() session = self._get_selected_session() if not session: - self._show_error("Keine Session ausgewählt") + self._show_error(self._strings.no_session_selected) return self._attempt_login(self._selected_user, password, session) @@ -350,15 +354,15 @@ class GreeterWindow(Gtk.ApplicationWindow): """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("GREETD_SOCK ist kein absoluter Pfad") + 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("GREETD_SOCK zeigt nicht auf einen Socket") + self._show_error(self._strings.greetd_sock_not_socket) return False except OSError: - self._show_error("GREETD_SOCK nicht erreichbar") + self._show_error(self._strings.greetd_sock_unreachable) return False return True @@ -375,7 +379,7 @@ class GreeterWindow(Gtk.ApplicationWindow): """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") + self._show_error(self._strings.greetd_sock_not_set) return if not self._validate_greetd_sock(sock_path): @@ -391,7 +395,7 @@ class GreeterWindow(Gtk.ApplicationWindow): response = create_session(sock, user.username) if response.get("type") == "error": - self._show_greetd_error(response, "Authentifizierung fehlgeschlagen") + self._show_greetd_error(response, self._strings.auth_failed) self._close_greetd_sock() return @@ -401,8 +405,8 @@ class GreeterWindow(Gtk.ApplicationWindow): if response.get("type") == "error": self._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1 - self._show_greetd_error(response, "Falsches Passwort") - warning = faillock_warning(self._failed_attempts[user.username]) + 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}") @@ -413,14 +417,14 @@ class GreeterWindow(Gtk.ApplicationWindow): # Multi-stage auth (e.g. TOTP) is not supported cancel_session(sock) self._close_greetd_sock() - self._show_error("Mehrstufige Authentifizierung wird nicht unterstützt") + 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("Ungültiger Session-Befehl") + self._show_error(self._strings.invalid_session_command) cancel_session(sock) self._close_greetd_sock() return @@ -432,16 +436,16 @@ class GreeterWindow(Gtk.ApplicationWindow): self.get_application().quit() return else: - self._show_greetd_error(response, "Session konnte nicht gestartet werden") + 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(f"Verbindungsfehler: {e}") + self._show_error(self._strings.connection_error.format(error=e)) except OSError as e: self._close_greetd_sock() - self._show_error(f"Socket-Fehler: {e}") + self._show_error(self._strings.socket_error.format(error=e)) def _cancel_pending_session(self) -> None: """Cancel any in-progress greetd session.""" @@ -483,14 +487,14 @@ class GreeterWindow(Gtk.ApplicationWindow): try: reboot() except subprocess.CalledProcessError: - self._show_error("Neustart fehlgeschlagen") + 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("Herunterfahren fehlgeschlagen") + self._show_error(self._strings.shutdown_failed) @staticmethod def _load_last_user() -> str | None: diff --git a/src/moongreet/i18n.py b/src/moongreet/i18n.py new file mode 100644 index 0000000..85943fa --- /dev/null +++ b/src/moongreet/i18n.py @@ -0,0 +1,113 @@ +# ABOUTME: Locale detection and string lookup for the greeter UI. +# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. + +import os +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_LOCALE_CONF = Path("/etc/locale.conf") + + +@dataclass(frozen=True) +class Strings: + """All user-visible strings for the greeter UI.""" + + # UI labels + password_placeholder: str + reboot_tooltip: str + shutdown_tooltip: str + + # Error messages + no_session_selected: str + greetd_sock_not_set: str + greetd_sock_not_absolute: str + greetd_sock_not_socket: str + greetd_sock_unreachable: str + auth_failed: str + wrong_password: str + multi_stage_unsupported: str + invalid_session_command: str + session_start_failed: str + reboot_failed: str + shutdown_failed: str + + # Templates (use .format()) + connection_error: str + socket_error: str + faillock_attempts_remaining: str + faillock_locked: str + + +_STRINGS_DE = Strings( + password_placeholder="Passwort", + reboot_tooltip="Neustart", + shutdown_tooltip="Herunterfahren", + no_session_selected="Keine Session ausgewählt", + greetd_sock_not_set="GREETD_SOCK nicht gesetzt", + greetd_sock_not_absolute="GREETD_SOCK ist kein absoluter Pfad", + greetd_sock_not_socket="GREETD_SOCK zeigt nicht auf einen Socket", + greetd_sock_unreachable="GREETD_SOCK nicht erreichbar", + auth_failed="Authentifizierung fehlgeschlagen", + wrong_password="Falsches Passwort", + multi_stage_unsupported="Mehrstufige Authentifizierung wird nicht unterstützt", + invalid_session_command="Ungültiger Session-Befehl", + session_start_failed="Session konnte nicht gestartet werden", + reboot_failed="Neustart fehlgeschlagen", + shutdown_failed="Herunterfahren fehlgeschlagen", + connection_error="Verbindungsfehler: {error}", + socket_error="Socket-Fehler: {error}", + faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!", + faillock_locked="Konto ist möglicherweise gesperrt", +) + +_STRINGS_EN = Strings( + password_placeholder="Password", + reboot_tooltip="Reboot", + shutdown_tooltip="Shut down", + no_session_selected="No session selected", + greetd_sock_not_set="GREETD_SOCK not set", + greetd_sock_not_absolute="GREETD_SOCK is not an absolute path", + greetd_sock_not_socket="GREETD_SOCK does not point to a socket", + greetd_sock_unreachable="GREETD_SOCK unreachable", + auth_failed="Authentication failed", + wrong_password="Wrong password", + multi_stage_unsupported="Multi-stage authentication is not supported", + invalid_session_command="Invalid session command", + session_start_failed="Failed to start session", + reboot_failed="Reboot failed", + shutdown_failed="Shutdown failed", + connection_error="Connection error: {error}", + socket_error="Socket error: {error}", + faillock_attempts_remaining="{n} attempt(s) remaining before lockout!", + faillock_locked="Account may be locked", +) + +_LOCALE_MAP: dict[str, Strings] = { + "de": _STRINGS_DE, + "en": _STRINGS_EN, +} + + +def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str: + """Determine the system language from LANG env var or /etc/locale.conf.""" + lang = os.environ.get("LANG") + + if not lang and locale_conf_path.exists(): + for line in locale_conf_path.read_text().splitlines(): + if line.startswith("LANG="): + lang = line.split("=", 1)[1].strip() + break + + if not lang or lang in ("C", "POSIX"): + return "en" + + # Extract language prefix: "de_DE.UTF-8" → "de" + lang = lang.split("_")[0].split(".")[0] + return lang + + +def load_strings(locale: str | None = None) -> Strings: + """Return the string table for the given locale, defaulting to English.""" + if locale is None: + locale = detect_locale() + return _LOCALE_MAP.get(locale, _STRINGS_EN) diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..39afe14 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,118 @@ +# ABOUTME: Tests for locale detection and string lookup. +# ABOUTME: Verifies DE/EN selection based on system locale. + +from pathlib import Path + +import pytest + +from moongreet.i18n import detect_locale, load_strings, Strings + + +class TestDetectLocale: + """Tests for system locale detection.""" + + def test_reads_lang_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANG", "de_DE.UTF-8") + + result = detect_locale() + + assert result == "de" + + def test_reads_lang_without_region(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANG", "en_US.UTF-8") + + result = detect_locale() + + assert result == "en" + + def test_falls_back_to_locale_conf(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LANG", raising=False) + locale_conf = tmp_path / "locale.conf" + locale_conf.write_text("LANG=de_AT.UTF-8\n") + + result = detect_locale(locale_conf_path=locale_conf) + + assert result == "de" + + def test_defaults_to_english(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LANG", raising=False) + missing = tmp_path / "nonexistent" + + result = detect_locale(locale_conf_path=missing) + + assert result == "en" + + def test_handles_bare_language_code(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANG", "de") + + result = detect_locale() + + assert result == "de" + + def test_handles_c_locale(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANG", "C") + + result = detect_locale() + + assert result == "en" + + def test_handles_posix_locale(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANG", "POSIX") + + result = detect_locale() + + assert result == "en" + + +class TestLoadStrings: + """Tests for loading the correct string table.""" + + def test_loads_german_strings(self) -> None: + strings = load_strings("de") + + assert strings.password_placeholder == "Passwort" + assert strings.reboot_tooltip == "Neustart" + assert strings.shutdown_tooltip == "Herunterfahren" + + def test_loads_english_strings(self) -> None: + strings = load_strings("en") + + assert strings.password_placeholder == "Password" + assert strings.reboot_tooltip == "Reboot" + assert strings.shutdown_tooltip == "Shut down" + + def test_unknown_locale_falls_back_to_english(self) -> None: + strings = load_strings("fr") + + assert strings.password_placeholder == "Password" + + def test_returns_strings_dataclass(self) -> None: + strings = load_strings("de") + + assert isinstance(strings, Strings) + + def test_error_messages_are_present(self) -> None: + strings = load_strings("en") + + assert strings.wrong_password + assert strings.auth_failed + assert strings.reboot_failed + assert strings.shutdown_failed + assert strings.no_session_selected + assert strings.multi_stage_unsupported + assert strings.invalid_session_command + assert strings.session_start_failed + assert strings.faillock_locked + + def test_faillock_warning_template(self) -> None: + strings = load_strings("de") + + # Template should accept an int for remaining attempts + result = strings.faillock_attempts_remaining.format(n=1) + assert "1" in result + + def test_connection_error_template(self) -> None: + strings = load_strings("en") + + result = strings.connection_error.format(error="timeout") + assert "timeout" in result