- power: RAII DoneGuard sets done=true on every wait() exit path, so the timeout thread no longer sleeps its full 30 s holding a spawn_blocking slot when child.wait() errors. A separate timed_out AtomicBool marks our own SIGKILL so we do not misclassify an external OOM-kill. Memory ordering on the flags is now Release/Acquire. - i18n: detect_locale now reads LC_ALL, LC_MESSAGES, LANG in POSIX priority order before falling back to /etc/locale.conf, so systems installed in English with LC_ALL=de_DE.UTF-8 pick up the correct UI. - panel: execute_action desensitizes button_box on entry and re-enables it on error paths, so double-click or keyboard repeat cannot fire the same power action twice. - config: accept_wallpaper helper applies an extension allowlist (jpg, jpeg, png, webp) plus symlink rejection and a 10 MB size cap, applied to both the user-configured path and the Moonarch ecosystem fallback. Bounds worst-case decode latency and narrows the gdk-pixbuf parser attack surface.
323 lines
10 KiB
Rust
323 lines
10 KiB
Rust
// ABOUTME: Locale detection and string lookup for the power menu 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;
|
|
|
|
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
|
|
|
/// All user-visible strings for the power menu UI.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Strings {
|
|
// Button labels
|
|
pub lock_label: &'static str,
|
|
pub logout_label: &'static str,
|
|
pub hibernate_label: &'static str,
|
|
pub reboot_label: &'static str,
|
|
pub shutdown_label: &'static str,
|
|
|
|
// Confirmation prompts
|
|
pub logout_confirm: &'static str,
|
|
pub hibernate_confirm: &'static str,
|
|
pub reboot_confirm: &'static str,
|
|
pub shutdown_confirm: &'static str,
|
|
|
|
// Confirmation buttons
|
|
pub confirm_yes: &'static str,
|
|
pub confirm_no: &'static str,
|
|
|
|
// Error messages
|
|
pub lock_failed: &'static str,
|
|
pub logout_failed: &'static str,
|
|
pub hibernate_failed: &'static str,
|
|
pub reboot_failed: &'static str,
|
|
pub shutdown_failed: &'static str,
|
|
}
|
|
|
|
const STRINGS_DE: Strings = Strings {
|
|
lock_label: "Sperren",
|
|
logout_label: "Abmelden",
|
|
hibernate_label: "Ruhezustand",
|
|
reboot_label: "Neustart",
|
|
shutdown_label: "Herunterfahren",
|
|
logout_confirm: "Wirklich abmelden?",
|
|
hibernate_confirm: "Wirklich in den Ruhezustand?",
|
|
reboot_confirm: "Wirklich neu starten?",
|
|
shutdown_confirm: "Wirklich herunterfahren?",
|
|
confirm_yes: "Ja",
|
|
confirm_no: "Abbrechen",
|
|
lock_failed: "Sperren fehlgeschlagen",
|
|
logout_failed: "Abmelden fehlgeschlagen",
|
|
hibernate_failed: "Ruhezustand fehlgeschlagen",
|
|
reboot_failed: "Neustart fehlgeschlagen",
|
|
shutdown_failed: "Herunterfahren fehlgeschlagen",
|
|
};
|
|
|
|
const STRINGS_EN: Strings = Strings {
|
|
lock_label: "Lock",
|
|
logout_label: "Log out",
|
|
hibernate_label: "Hibernate",
|
|
reboot_label: "Reboot",
|
|
shutdown_label: "Shut down",
|
|
logout_confirm: "Really log out?",
|
|
hibernate_confirm: "Really hibernate?",
|
|
reboot_confirm: "Really reboot?",
|
|
shutdown_confirm: "Really shut down?",
|
|
confirm_yes: "Yes",
|
|
confirm_no: "Cancel",
|
|
lock_failed: "Lock failed",
|
|
logout_failed: "Log out failed",
|
|
hibernate_failed: "Hibernate failed",
|
|
reboot_failed: "Reboot failed",
|
|
shutdown_failed: "Shutdown failed",
|
|
};
|
|
|
|
/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de".
|
|
/// Returns "en" for empty, "C", or "POSIX" values.
|
|
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()
|
|
}
|
|
}
|
|
|
|
/// Read the LANG= value from a locale.conf file.
|
|
fn read_lang_from_conf(path: &Path) -> Option<String> {
|
|
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
|
|
}
|
|
|
|
/// Determine the system language from POSIX locale env vars or /etc/locale.conf.
|
|
/// Checks LC_ALL, LC_MESSAGES, LANG in POSIX priority order (LC_ALL overrides
|
|
/// everything; LC_MESSAGES overrides LANG for text categories).
|
|
pub fn detect_locale() -> String {
|
|
let env_val = env::var("LC_ALL")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.or_else(|| env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
|
|
.or_else(|| env::var("LANG").ok().filter(|s| !s.is_empty()));
|
|
detect_locale_with(env_val.as_deref(), Path::new(DEFAULT_LOCALE_CONF))
|
|
}
|
|
|
|
/// Determine locale with configurable inputs (for testing).
|
|
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
|
|
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
|
|
(Some(val.to_string()), "env")
|
|
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
|
|
(Some(val), "locale.conf")
|
|
} else {
|
|
(None, "default")
|
|
};
|
|
|
|
let result = match raw {
|
|
Some(l) => parse_lang_prefix(&l),
|
|
None => "en".to_string(),
|
|
};
|
|
log::debug!("Detected locale: {result} (source: {source})");
|
|
result
|
|
}
|
|
|
|
/// Return the string table for the given locale, defaulting to English.
|
|
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
|
let locale = match locale {
|
|
Some(l) => l.to_string(),
|
|
None => detect_locale(),
|
|
};
|
|
|
|
match locale.as_str() {
|
|
"de" => &STRINGS_DE,
|
|
_ => &STRINGS_EN,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
|
|
// -- parse_lang_prefix tests (no env manipulation needed) --
|
|
|
|
#[test]
|
|
fn parse_german_locale() {
|
|
assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_english_locale() {
|
|
assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_c_falls_back_to_english() {
|
|
assert_eq!(parse_lang_prefix("C"), "en");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_posix_falls_back_to_english() {
|
|
assert_eq!(parse_lang_prefix("POSIX"), "en");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_empty_falls_back_to_english() {
|
|
assert_eq!(parse_lang_prefix(""), "en");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_unsupported_returns_prefix() {
|
|
assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_bare_language_code() {
|
|
assert_eq!(parse_lang_prefix("de"), "de");
|
|
}
|
|
|
|
// -- read_lang_from_conf tests --
|
|
|
|
#[test]
|
|
fn read_conf_extracts_lang() {
|
|
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 read_conf_returns_none_for_missing_file() {
|
|
assert_eq!(read_lang_from_conf(Path::new("/nonexistent/locale.conf")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn read_conf_returns_none_for_empty_lang() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let conf = dir.path().join("locale.conf");
|
|
let mut f = fs::File::create(&conf).unwrap();
|
|
writeln!(f, "LANG=").unwrap();
|
|
assert_eq!(read_lang_from_conf(&conf), None);
|
|
}
|
|
|
|
#[test]
|
|
fn read_conf_skips_non_lang_lines() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let conf = dir.path().join("locale.conf");
|
|
let mut f = fs::File::create(&conf).unwrap();
|
|
writeln!(f, "LC_ALL=en_US.UTF-8").unwrap();
|
|
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
|
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
|
}
|
|
|
|
// -- load_strings tests --
|
|
|
|
#[test]
|
|
fn load_strings_german() {
|
|
let strings = load_strings(Some("de"));
|
|
assert_eq!(strings.lock_label, "Sperren");
|
|
assert_eq!(strings.confirm_yes, "Ja");
|
|
assert_eq!(strings.confirm_no, "Abbrechen");
|
|
}
|
|
|
|
#[test]
|
|
fn load_strings_english() {
|
|
let strings = load_strings(Some("en"));
|
|
assert_eq!(strings.lock_label, "Lock");
|
|
assert_eq!(strings.confirm_yes, "Yes");
|
|
}
|
|
|
|
#[test]
|
|
fn load_strings_unknown_falls_back_to_english() {
|
|
let strings = load_strings(Some("fr"));
|
|
assert_eq!(strings.lock_label, "Lock");
|
|
}
|
|
|
|
#[test]
|
|
fn all_string_fields_nonempty() {
|
|
for locale in &["de", "en"] {
|
|
let s = load_strings(Some(locale));
|
|
assert!(!s.lock_label.is_empty(), "{locale}: lock_label empty");
|
|
assert!(!s.logout_label.is_empty(), "{locale}: logout_label empty");
|
|
assert!(!s.hibernate_label.is_empty(), "{locale}: hibernate_label empty");
|
|
assert!(!s.reboot_label.is_empty(), "{locale}: reboot_label empty");
|
|
assert!(!s.shutdown_label.is_empty(), "{locale}: shutdown_label empty");
|
|
assert!(!s.logout_confirm.is_empty(), "{locale}: logout_confirm empty");
|
|
assert!(!s.hibernate_confirm.is_empty(), "{locale}: hibernate_confirm empty");
|
|
assert!(!s.reboot_confirm.is_empty(), "{locale}: reboot_confirm empty");
|
|
assert!(!s.shutdown_confirm.is_empty(), "{locale}: shutdown_confirm empty");
|
|
assert!(!s.confirm_yes.is_empty(), "{locale}: confirm_yes empty");
|
|
assert!(!s.confirm_no.is_empty(), "{locale}: confirm_no empty");
|
|
assert!(!s.lock_failed.is_empty(), "{locale}: lock_failed empty");
|
|
assert!(!s.logout_failed.is_empty(), "{locale}: logout_failed empty");
|
|
assert!(!s.hibernate_failed.is_empty(), "{locale}: hibernate_failed empty");
|
|
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed empty");
|
|
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed empty");
|
|
}
|
|
}
|
|
|
|
// -- detect_locale_with tests --
|
|
|
|
#[test]
|
|
fn detect_locale_uses_env_lang() {
|
|
let result = detect_locale_with(Some("de_DE.UTF-8"), Path::new("/nonexistent"));
|
|
assert_eq!(result, "de");
|
|
}
|
|
|
|
#[test]
|
|
fn detect_locale_falls_back_to_conf_file() {
|
|
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();
|
|
let result = detect_locale_with(None, &conf);
|
|
assert_eq!(result, "de");
|
|
}
|
|
|
|
#[test]
|
|
fn detect_locale_ignores_empty_env_lang() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let conf = dir.path().join("locale.conf");
|
|
let mut f = fs::File::create(&conf).unwrap();
|
|
writeln!(f, "LANG=fr_FR.UTF-8").unwrap();
|
|
let result = detect_locale_with(Some(""), &conf);
|
|
assert_eq!(result, "fr");
|
|
}
|
|
|
|
#[test]
|
|
fn detect_locale_defaults_to_english() {
|
|
let result = detect_locale_with(None, Path::new("/nonexistent"));
|
|
assert_eq!(result, "en");
|
|
}
|
|
|
|
#[test]
|
|
fn error_messages_contain_failed() {
|
|
let s = load_strings(Some("en"));
|
|
assert!(s.lock_failed.to_lowercase().contains("failed"));
|
|
assert!(s.logout_failed.to_lowercase().contains("failed"));
|
|
assert!(s.hibernate_failed.to_lowercase().contains("failed"));
|
|
assert!(s.reboot_failed.to_lowercase().contains("failed"));
|
|
assert!(s.shutdown_failed.to_lowercase().contains("failed"));
|
|
}
|
|
}
|