From 72b552206a7db9de17092678587b57cc30986cc5 Mon Sep 17 00:00:00 2001 From: Decidetto <5384177+Decidetto@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:32:25 +0200 Subject: [PATCH] fix: localize tray and default UI language --- src-tauri/Cargo.toml | 3 +- src-tauri/capabilities/main.json | 1 + src-tauri/permissions/settings_commands.toml | 4 + src-tauri/src/lib.rs | 111 ++++++++++++++++++- src-tauri/src/settings_secure.rs | 64 +++++++++++ src/main.js | 40 ++++--- tests/frontend-static.test.mjs | 13 +++ 7 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 src-tauri/permissions/settings_commands.toml diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b2486f4..7e18d33 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,7 @@ bzip2 = "0.6" zip = { version = "2.2", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_Storage_FileSystem"] } +windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_Storage_FileSystem"] } winreg = "0.52" [target.'cfg(target_os = "macos")'.dependencies] @@ -51,4 +51,3 @@ objc = "0.2.7" core-foundation = "0.9" core-graphics = "0.23" - diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index a090a5a..09eface 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -10,6 +10,7 @@ "allow-install-app-update", "allow-get-settings", "allow-set-settings", + "allow-set-ui-language", "allow-set-provider-key", "allow-download-model-command", "allow-cancel-model-download", diff --git a/src-tauri/permissions/settings_commands.toml b/src-tauri/permissions/settings_commands.toml new file mode 100644 index 0000000..103e9d7 --- /dev/null +++ b/src-tauri/permissions/settings_commands.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-set-ui-language" +description = "Enables updating the persisted interface language." +commands.allow = ["set_ui_language"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1efe71..e2cd41e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -177,9 +177,99 @@ async fn set_settings( whisper_runner::ensure_parakeet_server_state(&sidecar_handle, &sidecar_settings); whisper_runner::ensure_whisper_server_state(&sidecar_handle, &sidecar_settings); }); + let _ = app_handle.emit("settings-changed", ()); Ok(()) } +#[tauri::command] +async fn set_ui_language(app_handle: tauri::AppHandle, ui_language: String) -> Result<(), String> { + let save_handle = app_handle.clone(); + tauri::async_runtime::spawn_blocking(move || { + settings::set_ui_language(&save_handle, &ui_language) + }) + .await + .map_err(|error| format!("Interface language storage worker failed: {error}"))??; + let _ = app_handle.emit("settings-changed", ()); + Ok(()) +} + +#[derive(Clone, Copy)] +struct TrayTranslations { + show: &'static str, + recognition_mode: &'static str, + cloud: &'static str, + local: &'static str, + quit: &'static str, +} + +fn tray_translations(language: &str) -> TrayTranslations { + match language { + "ru" => TrayTranslations { + show: "Открыть настройки", + recognition_mode: "Способ распознавания", + cloud: "Облачный ИИ", + local: "Локальный ИИ (Whisper / Parakeet)", + quit: "Выход", + }, + "de" => TrayTranslations { + show: "Einstellungen öffnen", + recognition_mode: "Erkennungsmodus", + cloud: "Cloud-KI", + local: "Lokale KI (Whisper / Parakeet)", + quit: "Beenden", + }, + "es" => TrayTranslations { + show: "Abrir ajustes", + recognition_mode: "Modo de reconocimiento", + cloud: "IA en la nube", + local: "IA local (Whisper / Parakeet)", + quit: "Salir", + }, + "fr" => TrayTranslations { + show: "Ouvrir les paramètres", + recognition_mode: "Mode de reconnaissance", + cloud: "IA cloud", + local: "IA locale (Whisper / Parakeet)", + quit: "Quitter", + }, + "it" => TrayTranslations { + show: "Apri impostazioni", + recognition_mode: "Modalità di riconoscimento", + cloud: "IA cloud", + local: "IA locale (Whisper / Parakeet)", + quit: "Esci", + }, + "zh" => TrayTranslations { + show: "打开设置", + recognition_mode: "识别模式", + cloud: "云端 AI", + local: "本地 AI (Whisper / Parakeet)", + quit: "退出", + }, + "pt" => TrayTranslations { + show: "Abrir configurações", + recognition_mode: "Modo de reconhecimento", + cloud: "IA na nuvem", + local: "IA local (Whisper / Parakeet)", + quit: "Sair", + }, + "tr" => TrayTranslations { + show: "Ayarları aç", + recognition_mode: "Tanıma modu", + cloud: "Bulut AI", + local: "Yerel AI (Whisper / Parakeet)", + quit: "Çıkış", + }, + _ => TrayTranslations { + show: "Open settings", + recognition_mode: "Recognition mode", + cloud: "Cloud AI", + local: "Local AI (Whisper / Parakeet)", + quit: "Quit", + }, + } +} + fn canonical_model_name(model_name: &str) -> Result { if model_name.len() > 64 { return Err("Invalid model name".to_string()); @@ -3733,16 +3823,17 @@ pub fn run() { } // 2. Build system tray menu - let show_i = MenuItem::with_id(app, "show", "Открыть настройки", true, None::<&str>)?; + let tray_text = tray_translations(&startup_settings.ui_language); + let show_i = MenuItem::with_id(app, "show", tray_text.show, true, None::<&str>)?; let sep1 = PredefinedMenuItem::separator(app)?; let is_cloud = startup_settings.transcription_mode == "cloud"; - let mode_cloud_i = CheckMenuItem::with_id(app, "tray_mode_cloud", "Облачный ИИ", true, is_cloud, None::<&str>)?; - let mode_local_i = CheckMenuItem::with_id(app, "tray_mode_local", "Локальный ИИ (Whisper / Parakeet)", true, !is_cloud, None::<&str>)?; - let mode_sub = Submenu::with_items(app, "Способ распознавания", true, &[&mode_cloud_i, &mode_local_i])?; + let mode_cloud_i = CheckMenuItem::with_id(app, "tray_mode_cloud", tray_text.cloud, true, is_cloud, None::<&str>)?; + let mode_local_i = CheckMenuItem::with_id(app, "tray_mode_local", tray_text.local, true, !is_cloud, None::<&str>)?; + let mode_sub = Submenu::with_items(app, tray_text.recognition_mode, true, &[&mode_cloud_i, &mode_local_i])?; let sep2 = PredefinedMenuItem::separator(app)?; - let quit_i = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "quit", tray_text.quit, true, None::<&str>)?; let menu = Menu::with_items(app, &[ &show_i, @@ -3814,12 +3905,21 @@ pub fn run() { let mode_cloud_sync = mode_cloud_i.clone(); let mode_local_sync = mode_local_i.clone(); + let show_sync = show_i.clone(); + let mode_sub_sync = mode_sub.clone(); + let quit_sync = quit_i.clone(); let app_for_sync = app_handle.clone(); app.listen("settings-changed", move |_| { if let Ok(s) = settings::load_settings(&app_for_sync) { let is_c = s.transcription_mode == "cloud"; let _ = mode_cloud_sync.set_checked(is_c); let _ = mode_local_sync.set_checked(!is_c); + let text = tray_translations(&s.ui_language); + let _ = show_sync.set_text(text.show); + let _ = mode_sub_sync.set_text(text.recognition_mode); + let _ = mode_cloud_sync.set_text(text.cloud); + let _ = mode_local_sync.set_text(text.local); + let _ = quit_sync.set_text(text.quit); } }); @@ -3984,6 +4084,7 @@ pub fn run() { get_settings, set_provider_key, set_settings, + set_ui_language, download_model_command, cancel_model_download, delete_model_command, diff --git a/src-tauri/src/settings_secure.rs b/src-tauri/src/settings_secure.rs index 09ffafe..6d30cce 100644 --- a/src-tauri/src/settings_secure.rs +++ b/src-tauri/src/settings_secure.rs @@ -23,6 +23,7 @@ const ALLOWED_ACCELERATION: &[&str] = &["cpu", "cuda"]; const ALLOWED_LANGUAGES: &[&str] = &[ "auto", "layout", "ru", "en", "de", "es", "fr", "it", "zh", "pt", "tr", ]; +const ALLOWED_UI_LANGUAGES: &[&str] = &["ru", "en", "de", "es", "fr", "it", "zh", "pt", "tr"]; const ALLOWED_SOUND_THEMES: &[&str] = &["zen", "rhodes", "scifi", "classic"]; const MAX_DICTIONARY_CHARS: usize = 4096; const MAX_HOTKEY_CHARS: usize = 64; @@ -48,6 +49,8 @@ fn lock_settings() -> MutexGuard<'static, ()> { #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(default)] pub struct Settings { + #[serde(default = "default_ui_language")] + pub ui_language: String, pub transcription_mode: String, pub api_provider: String, #[serde(default, skip_serializing)] @@ -94,6 +97,7 @@ fn default_audio_device() -> String { impl Default for Settings { fn default() -> Self { Self { + ui_language: default_ui_language(), transcription_mode: "cloud".to_string(), api_provider: "gemini".to_string(), api_key: String::new(), @@ -159,6 +163,11 @@ impl Settings { ALLOWED_ACCELERATION, )?; validate_choice("language", &self.language, ALLOWED_LANGUAGES)?; + validate_choice( + "interface language", + &self.ui_language, + ALLOWED_UI_LANGUAGES, + )?; validate_choice( "sound theme", &self.overlay_sound_theme, @@ -197,6 +206,34 @@ impl Settings { } } +fn ui_language_for_windows_lang_id(language_id: u16) -> &'static str { + const PRIMARY_LANGUAGE_MASK: u16 = 0x03ff; + match language_id & PRIMARY_LANGUAGE_MASK { + 0x0004 => "zh", + 0x0007 => "de", + 0x0009 => "en", + 0x000a => "es", + 0x000c => "fr", + 0x0010 => "it", + 0x0016 => "pt", + 0x0019 => "ru", + 0x001f => "tr", + _ => "en", + } +} + +fn default_ui_language() -> String { + #[cfg(target_os = "windows")] + { + let language_id = unsafe { windows_sys::Win32::Globalization::GetUserDefaultUILanguage() }; + ui_language_for_windows_lang_id(language_id).to_string() + } + #[cfg(not(target_os = "windows"))] + { + "en".to_string() + } +} + fn validate_choice(label: &str, value: &str, allowed: &[&str]) -> Result<(), String> { if allowed.contains(&value) { Ok(()) @@ -520,6 +557,17 @@ pub fn save_settings( save_config(&get_settings_path(app_handle)?, &normalized) } +pub fn set_ui_language( + app_handle: &tauri::AppHandle, + ui_language: &str, +) -> Result<(), String> { + validate_choice("interface language", ui_language, ALLOWED_UI_LANGUAGES)?; + let _guard = lock_settings(); + let mut settings = load_settings_unlocked(app_handle)?; + settings.ui_language = ui_language.to_string(); + save_config(&get_settings_path(app_handle)?, &settings) +} + pub fn set_provider_key( app_handle: &tauri::AppHandle, provider: &str, @@ -577,6 +625,22 @@ mod tests { settings.validate().unwrap(); } + #[test] + fn windows_ui_language_uses_supported_locale_or_english_fallback() { + assert_eq!(ui_language_for_windows_lang_id(0x0804), "zh"); + assert_eq!(ui_language_for_windows_lang_id(0x0407), "de"); + assert_eq!(ui_language_for_windows_lang_id(0x0409), "en"); + assert_eq!(ui_language_for_windows_lang_id(0x0c0a), "es"); + assert_eq!(ui_language_for_windows_lang_id(0x040c), "fr"); + assert_eq!(ui_language_for_windows_lang_id(0x0410), "it"); + assert_eq!(ui_language_for_windows_lang_id(0x0416), "pt"); + assert_eq!(ui_language_for_windows_lang_id(0x0419), "ru"); + assert_eq!(ui_language_for_windows_lang_id(0x0819), "ru"); + assert_eq!(ui_language_for_windows_lang_id(0x041f), "tr"); + assert_eq!(ui_language_for_windows_lang_id(0x0413), "en"); + assert_eq!(ui_language_for_windows_lang_id(0x0411), "en"); + } + #[test] fn settings_view_never_serializes_secrets() { let settings = Settings { diff --git a/src/main.js b/src/main.js index da9e476..e8f5587 100644 --- a/src/main.js +++ b/src/main.js @@ -2561,6 +2561,7 @@ modelCards.forEach(card => { const settings = { transcription_mode: radioLocal.checked ? "local" : "cloud", + ui_language: currentLanguage, api_provider: selectProvider.value, custom_api_url: (() => { const el = document.getElementById("input-custom-url"); @@ -3669,6 +3670,18 @@ if (e.button === 0 && !e.target.closest(".window-control-btn") && !e.target.clos } // Initialize UI language and Settings (async () => { + const supportedLangs = ["ru", "en", "de", "es", "fr", "it", "zh", "pt", "tr"]; + let legacyUiLang = localStorage.getItem("aura_ui_lang"); + if (legacyUiLang === null) { + legacyUiLang = localStorage.getItem("ui-language"); + } + if (!supportedLangs.includes(legacyUiLang)) { + legacyUiLang = null; + } + const browserUiLang = navigator.language.toLowerCase().split(/[-_]/)[0]; + const provisionalUiLang = legacyUiLang || (supportedLangs.includes(browserUiLang) ? browserUiLang : "en"); + applyLanguage(provisionalUiLang); + let settings = null; try { settings = await invoke("get_settings"); @@ -3676,18 +3689,14 @@ if (e.button === 0 && !e.target.closest(".window-control-btn") && !e.target.clos console.error(err); } - let savedUiLang = localStorage.getItem("aura_ui_lang"); - if (savedUiLang === null) { - savedUiLang = localStorage.getItem("ui-language"); - } - - const supportedLangs = ["ru", "en", "de", "es", "fr", "it", "zh", "pt", "tr"]; - if (savedUiLang === null || !supportedLangs.includes(savedUiLang)) { - if (settings && settings.language && supportedLangs.includes(settings.language)) { - savedUiLang = settings.language; - } else { - savedUiLang = "ru"; - } + const backendUiLang = supportedLangs.includes(settings?.ui_language) ? settings.ui_language : null; + const savedUiLang = legacyUiLang || backendUiLang || provisionalUiLang; + localStorage.setItem("aura_ui_lang", savedUiLang); + localStorage.setItem("ui-language", savedUiLang); + if (settings && settings.ui_language !== savedUiLang) { + invoke("set_ui_language", { uiLanguage: savedUiLang }).catch(error => { + console.error("Failed to migrate interface language", error); + }); } // UI Language Selector Setup @@ -3695,11 +3704,16 @@ if (e.button === 0 && !e.target.closest(".window-control-btn") && !e.target.clos if (selectUiLang) { selectUiLang.value = savedUiLang; - selectUiLang.addEventListener("change", (e) => { + selectUiLang.addEventListener("change", async (e) => { const selectedLang = e.target.value; localStorage.setItem("aura_ui_lang", selectedLang); localStorage.setItem("ui-language", selectedLang); applyLanguage(selectedLang); + try { + await invoke("set_ui_language", { uiLanguage: selectedLang }); + } catch (error) { + console.error("Failed to persist interface language", error); + } }); } diff --git a/tests/frontend-static.test.mjs b/tests/frontend-static.test.mjs index 9d3a6f5..33b7d8f 100644 --- a/tests/frontend-static.test.mjs +++ b/tests/frontend-static.test.mjs @@ -133,6 +133,19 @@ test("settings translations cover every supported locale without fallback gaps", assert.match(read("src/main.js"), /getTranslation\("update_installing"\)/); }); +test("interface language persists in native settings and defaults to a supported system locale", () => { + const main = read("src/main.js"); + const rust = read("src-tauri/src/lib.rs"); + const settings = read("src-tauri/src/settings_secure.rs"); + + assert.match(main, /navigator\.language\.toLowerCase\(\)\.split\(\/\[-_\]\/\)\[0\]/); + assert.match(main, /supportedLangs\.includes\(browserUiLang\) \? browserUiLang : "en"/); + assert.match(main, /invoke\("set_ui_language", \{ uiLanguage: selectedLang \}\)/); + assert.match(rust, /fn tray_translations\(language: &str\)/); + assert.match(rust, /show_sync\.set_text\(text\.show\)/); + assert.match(settings, /GetUserDefaultUILanguage/); +}); + test("safe clipboard handoff notice is translated in every overlay locale", () => { const notices = parseOverlayNotices(); const translated = notices["final-copied-after-edit"];