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:
parent
6554dc625d
commit
0f72df8603
@ -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
113
src/moongreet/i18n.py
Normal 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
118
tests/test_i18n.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user