Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -51,4 +51,3 @@ objc = "0.2.7"
core-foundation = "0.9"
core-graphics = "0.23"


1 change: 1 addition & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/permissions/settings_commands.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[[permission]]
identifier = "allow-set-ui-language"
description = "Enables updating the persisted interface language."
commands.allow = ["set_ui_language"]
111 changes: 106 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
if model_name.len() > 64 {
return Err("Invalid model name".to_string());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions src-tauri/src/settings_secure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -520,6 +557,17 @@ pub fn save_settings<R: tauri::Runtime>(
save_config(&get_settings_path(app_handle)?, &normalized)
}

pub fn set_ui_language<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
provider: &str,
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 27 additions & 13 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -3669,37 +3670,50 @@ 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");
} catch (err) {
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
const selectUiLang = document.getElementById("select-ui-lang");
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);
}
});
}

Expand Down
13 changes: 13 additions & 0 deletions tests/frontend-static.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down