// ABOUTME: Locale detection and string lookup for the lockscreen UI. // ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. use std::env; use std::fs; use std::path::Path; use std::sync::OnceLock; const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf"; /// Cached locale prefix — detected once, reused for all subsequent calls. static CACHED_LOCALE: OnceLock = OnceLock::new(); #[derive(Debug, Clone)] pub struct Strings { pub password_placeholder: &'static str, pub reboot_tooltip: &'static str, pub shutdown_tooltip: &'static str, pub fingerprint_prompt: &'static str, pub fingerprint_success: &'static str, pub fingerprint_failed: &'static str, pub wrong_password: &'static str, pub reboot_failed: &'static str, pub shutdown_failed: &'static str, pub reboot_confirm: &'static str, pub shutdown_confirm: &'static str, pub confirm_yes: &'static str, pub confirm_no: &'static str, pub faillock_attempts_remaining: &'static str, pub faillock_locked: &'static str, pub auth_timeout: &'static str, } const STRINGS_DE: Strings = Strings { password_placeholder: "Passwort", reboot_tooltip: "Neustart", shutdown_tooltip: "Herunterfahren", fingerprint_prompt: "Fingerabdruck auflegen zum Entsperren", fingerprint_success: "Fingerabdruck erkannt", fingerprint_failed: "Fingerabdruck nicht erkannt", wrong_password: "Falsches Passwort", reboot_failed: "Neustart fehlgeschlagen", shutdown_failed: "Herunterfahren fehlgeschlagen", reboot_confirm: "Wirklich neu starten?", shutdown_confirm: "Wirklich herunterfahren?", confirm_yes: "Ja", confirm_no: "Abbrechen", faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!", faillock_locked: "Konto ist möglicherweise gesperrt", auth_timeout: "Authentifizierung abgelaufen — bitte erneut versuchen", }; const STRINGS_EN: Strings = Strings { password_placeholder: "Password", reboot_tooltip: "Reboot", shutdown_tooltip: "Shut down", fingerprint_prompt: "Place finger on reader to unlock", fingerprint_success: "Fingerprint recognized", fingerprint_failed: "Fingerprint not recognized", wrong_password: "Wrong password", reboot_failed: "Reboot failed", shutdown_failed: "Shutdown failed", reboot_confirm: "Really reboot?", shutdown_confirm: "Really shut down?", confirm_yes: "Yes", confirm_no: "Cancel", faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!", faillock_locked: "Account may be locked", auth_timeout: "Authentication timed out — please try again", }; fn parse_lang_prefix(lang: &str) -> String { if lang.is_empty() || lang == "C" || lang == "POSIX" { return "en".to_string(); } let prefix = lang.split('_').next().unwrap_or(lang).split('.').next().unwrap_or(lang).to_lowercase(); if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() { prefix } else { "en".to_string() } } fn read_lang_from_conf(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; for line in content.lines() { if let Some(value) = line.strip_prefix("LANG=") { let value = value.trim(); if !value.is_empty() { return Some(value.to_string()); } } } None } pub fn detect_locale() -> String { let lang = env::var("LANG").ok().filter(|s| !s.is_empty()) .or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); match lang { Some(l) => parse_lang_prefix(&l), None => "en".to_string() } } pub fn load_strings(locale: Option<&str>) -> &'static Strings { let locale = match locale { Some(l) => l, None => CACHED_LOCALE.get_or_init(detect_locale), }; match locale { "de" => &STRINGS_DE, _ => &STRINGS_EN } } /// Returns a warning when the user is close to lockout (2 or fewer attempts remaining). /// Caller is responsible for handling the locked state (count >= max_attempts). pub fn faillock_warning(attempt_count: u32, max_attempts: u32, strings: &Strings) -> Option { let remaining = max_attempts.saturating_sub(attempt_count); if remaining > 0 && remaining <= 2 { return Some(strings.faillock_attempts_remaining.replace("{n}", &remaining.to_string())); } None } #[cfg(test)] mod tests { use super::*; use std::io::Write; #[test] fn parse_german() { assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); } #[test] fn parse_english() { assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); } #[test] fn parse_c() { assert_eq!(parse_lang_prefix("C"), "en"); } #[test] fn parse_empty() { assert_eq!(parse_lang_prefix(""), "en"); } #[test] fn read_conf() { let dir = tempfile::tempdir().unwrap(); let conf = dir.path().join("locale.conf"); let mut f = fs::File::create(&conf).unwrap(); writeln!(f, "LANG=de_DE.UTF-8").unwrap(); assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string())); } #[test] fn strings_de() { let s = load_strings(Some("de")); assert_eq!(s.password_placeholder, "Passwort"); } #[test] fn strings_en() { let s = load_strings(Some("en")); assert_eq!(s.password_placeholder, "Password"); } #[test] fn strings_fallback() { let s = load_strings(Some("fr")); assert_eq!(s.password_placeholder, "Password"); } #[test] fn fingerprint_strings() { let s = load_strings(Some("de")); assert!(!s.fingerprint_prompt.is_empty()); assert!(!s.fingerprint_success.is_empty()); assert!(!s.fingerprint_failed.is_empty()); } #[test] fn faillock_zero() { assert!(faillock_warning(0, 3, load_strings(Some("en"))).is_none()); } #[test] fn faillock_one() { assert!(faillock_warning(1, 3, load_strings(Some("en"))).is_some()); } #[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); } #[test] fn faillock_three() { assert!(faillock_warning(3, 3, load_strings(Some("en"))).is_none()); } #[test] fn faillock_caller_contract() { // Mirrors the lockscreen.rs usage: caller handles count >= max separately, // faillock_warning is only called when count < max. let max = 3u32; let strings = load_strings(Some("en")); for count in 0..max { let result = faillock_warning(count, max, strings); let remaining = max - count; if remaining <= 2 { assert!(result.is_some(), "should warn at count={count} (remaining={remaining})"); } else { assert!(result.is_none(), "should not warn at count={count}"); } } } #[test] fn faillock_warns_progressively_with_higher_max() { let strings = load_strings(Some("en")); // With max=5: warn at count 3 (rem=2) and count 4 (rem=1), not at 0-2 assert!(faillock_warning(0, 5, strings).is_none()); assert!(faillock_warning(2, 5, strings).is_none()); assert!(faillock_warning(3, 5, strings).is_some()); assert!(faillock_warning(4, 5, strings).is_some()); assert!(faillock_warning(5, 5, strings).is_none()); // at max, caller handles lockout } }