feat: i18n — Locale-basierte Strings (DE/EN) statt hardcoded Deutsch

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.
This commit is contained in:
nevaforget 2026-03-26 11:55:41 +01:00
parent 6554dc625d
commit 0f72df8603
3 changed files with 256 additions and 21 deletions

View File

@ -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:

113
src/moongreet/i18n.py Normal file
View File

@ -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)

118
tests/test_i18n.py Normal file
View File

@ -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