From 6115771b34b58fdbc12abcf0f81a2cd0afbd6ed4 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Thu, 23 Jul 2026 19:07:10 +0800 Subject: [PATCH] feat(i18n): localize native tray and app menus --- Cargo.lock | 10 ++ apps/desktop/src/i18n/index.ts | 13 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/app_settings.rs | 9 +- src-tauri/src/commands/mcp.rs | 11 +- src-tauri/src/lib.rs | 190 +++++++++++++++++++++++-- 6 files changed, 219 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5452880a84..2a928c26ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1841,6 +1841,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sys-locale", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -7871,6 +7872,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "sysinfo" version = "0.32.1" diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 896cf78f44..1889cebfae 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,6 +1,7 @@ import { createI18n } from "vue-i18n"; import en from "./locales/en"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; +import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; export type Locale = "en" | "es" | "it" | "ja" | "pt-BR" | "zh-CN" | "zh-TW"; type LocaleMessages = Record; @@ -87,14 +88,26 @@ export async function loadLocaleMessages(locale: Locale) { loadedLocales.add(locale); } +async function syncLocaleToBackend(locale: Locale) { + if (!isTauriRuntime()) return; + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("set_app_locale", { locale }); + } catch (error) { + console.warn("[DBX][i18n] failed to sync locale to backend", error); + } +} + export async function loadSavedLocale() { await loadLocaleMessages(initialLocale); + void syncLocaleToBackend(initialLocale); } export async function setLocale(locale: Locale) { await loadLocaleMessages(locale); i18nGlobal.locale.value = locale; safeLocalStorageSet("dbx-locale", locale); + void syncLocaleToBackend(locale); } export function currentLocale(): Locale { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e363bd49de..36d829d69d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,6 +64,7 @@ dbx-core = { path = "../crates/dbx-core", default-features = false } axum = { version = "0.8", features = ["ws"] } font-kit = "0.14.3" tauri-plugin-clipboard-manager = "2.3.2" +sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false, features = ["std"] } diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index a86ade39fd..66db69df54 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -8,7 +8,8 @@ use tauri::{AppHandle, Manager, State, Window}; use super::connection::AppState; use crate::{ - apply_debug_log_level, apply_desktop_settings, hide_main_window_for_close, request_app_close, CloseBehaviorState, + apply_debug_log_level, apply_desktop_settings, hide_main_window_for_close, refresh_native_menus, request_app_close, + AppLocaleState, CloseBehaviorState, }; const DEVELOPMENT_OPEN_TABS_STATE_KEY: &str = "development_open_tabs"; @@ -57,6 +58,12 @@ pub async fn load_max_agent_turns(state: State<'_, Arc>) -> Result, locale: String) -> Result<(), String> { + locale_state.set(locale); + refresh_native_menus(&app).map_err(|err| format!("failed to refresh native menus: {err}")) +} + #[tauri::command] pub async fn save_max_agent_turns(state: State<'_, Arc>, max_agent_turns: u32) -> Result<(), String> { state.storage.save_max_agent_turns(max_agent_turns).await diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index c364deb96e..b39d9e8637 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -906,12 +906,13 @@ mod tests { #[cfg(windows)] use super::first_windows_command_path; #[cfg(not(windows))] - use super::{bash_login_script, canonical_runtime_path, NodeRuntimeCandidate}; + use super::{bash_login_script, NodeRuntimeCandidate}; use super::{ - is_mcp_compatible_node_version, mcp_command_for_runtime, mcp_native_binary_path_for, mcp_package, - normalized_reported_path, npm_cli_candidates, parse_minimum_node_version, parse_node_version, prefer_runtime, - prefixed_output_path, require_managed_mcp_command, resolve_managed_mcp_command, stdout_after_shell_marker, - NodeRuntime, NodeVersion, MCP_MIN_NODE_VERSION_REQUIREMENT, MCP_PACKAGE_NAME, SHELL_COMMAND_MARKER, + canonical_runtime_path, is_mcp_compatible_node_version, mcp_command_for_runtime, mcp_native_binary_path_for, + mcp_package, normalized_reported_path, npm_cli_candidates, parse_minimum_node_version, parse_node_version, + prefer_runtime, prefixed_output_path, require_managed_mcp_command, resolve_managed_mcp_command, + stdout_after_shell_marker, NodeRuntime, NodeVersion, MCP_MIN_NODE_VERSION_REQUIREMENT, MCP_PACKAGE_NAME, + SHELL_COMMAND_MARKER, }; #[cfg(not(windows))] use super::{shell_command_script, shell_quote}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 641047bf90..3c2eec57fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,30 @@ impl CloseBehaviorState { self.frontend_ready.load(Ordering::Acquire) } } + +/// UI language pushed from the frontend i18n layer; native menus follow it and +/// fall back to the OS locale until the first `set_app_locale` call arrives. +pub struct AppLocaleState { + locale: std::sync::Mutex>, +} + +impl AppLocaleState { + fn new() -> Self { + Self { locale: std::sync::Mutex::new(None) } + } + + pub(crate) fn set(&self, locale: String) { + *self.locale.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(locale); + } + + fn get(&self) -> String { + self.locale + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + .unwrap_or_else(|| sys_locale::get_locale().unwrap_or_default()) + } +} #[cfg(target_os = "macos")] const MACOS_TRAY_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/tray-macos-template.png"); #[cfg(target_os = "macos")] @@ -144,9 +168,20 @@ fn build_app_menu(app_handle: &tauri::AppHandle) -> tauri: icon: Some(ABOUT_APP_ICON), ..Default::default() }; - let copy_support_info_item = - MenuItem::with_id(app_handle, APP_MENU_COPY_SUPPORT_INFO_ID, "Copy Support Info", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app_handle, APP_MENU_QUIT_ID, format!("Quit {app_name}"), true, Some("Cmd+Q"))?; + let copy_support_info_item = MenuItem::with_id( + app_handle, + APP_MENU_COPY_SUPPORT_INFO_ID, + app_menu_copy_support_info_label(¤t_app_locale(app_handle)), + true, + None::<&str>, + )?; + let quit_item = MenuItem::with_id( + app_handle, + APP_MENU_QUIT_ID, + app_menu_quit_label(¤t_app_locale(app_handle), &app_name), + true, + Some("Cmd+Q"), + )?; Menu::with_items( app_handle, @@ -484,12 +519,114 @@ fn open_connection_deep_links(app: &tauri::AppHandle, links: Vec) { show_main_window(app); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocaleFamily { + English, + SimplifiedChinese, + TraditionalChinese, + Japanese, + Spanish, + Italian, + Portuguese, +} + +// Mirrors the frontend language mapping in apps/desktop/src/i18n/index.ts +// (localeFromLanguageTag) so native menus agree with the UI language. +fn locale_family(locale: &str) -> LocaleFamily { + let normalized = locale.replace('_', "-").to_ascii_lowercase(); + let is_language = |language: &str| normalized == language || normalized.starts_with(&format!("{language}-")); + if is_language("zh") { + if normalized.contains("hant") + || normalized.starts_with("zh-tw") + || normalized.starts_with("zh-hk") + || normalized.starts_with("zh-mo") + { + LocaleFamily::TraditionalChinese + } else { + LocaleFamily::SimplifiedChinese + } + } else if is_language("ja") { + LocaleFamily::Japanese + } else if is_language("es") { + LocaleFamily::Spanish + } else if is_language("it") { + LocaleFamily::Italian + } else if is_language("pt") { + LocaleFamily::Portuguese + } else { + LocaleFamily::English + } +} + +fn tray_menu_labels_for_locale(locale: &str) -> (&'static str, &'static str) { + match locale_family(locale) { + LocaleFamily::SimplifiedChinese => ("显示 DBX", "退出 DBX"), + LocaleFamily::TraditionalChinese => ("顯示 DBX", "退出 DBX"), + LocaleFamily::Japanese => ("DBXを表示", "DBXを終了"), + LocaleFamily::Spanish => ("Mostrar DBX", "Salir de DBX"), + LocaleFamily::Italian => ("Mostra DBX", "Esci da DBX"), + LocaleFamily::Portuguese => ("Mostrar DBX", "Sair do DBX"), + LocaleFamily::English => ("Show DBX", "Quit DBX"), + } +} + +// Matches the frontend supportInfoCopy translations in apps/desktop/src/i18n/locales/*.ts. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn app_menu_copy_support_info_label(locale: &str) -> &'static str { + match locale_family(locale) { + LocaleFamily::SimplifiedChinese => "复制支持信息", + LocaleFamily::TraditionalChinese => "複製支援資訊", + LocaleFamily::Japanese => "サポート情報をコピー", + LocaleFamily::Spanish => "Copiar información", + LocaleFamily::Italian => "Copia informazioni", + LocaleFamily::Portuguese => "Copiar informações", + LocaleFamily::English => "Copy Support Info", + } +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn app_menu_quit_label(locale: &str, app_name: &str) -> String { + match locale_family(locale) { + LocaleFamily::SimplifiedChinese | LocaleFamily::TraditionalChinese => format!("退出 {app_name}"), + LocaleFamily::Japanese => format!("{app_name}を終了"), + LocaleFamily::Spanish => format!("Salir de {app_name}"), + LocaleFamily::Italian => format!("Esci da {app_name}"), + LocaleFamily::Portuguese => format!("Sair do {app_name}"), + LocaleFamily::English => format!("Quit {app_name}"), + } +} + +fn current_app_locale>(manager: &M) -> String { + match manager.try_state::() { + Some(state) => state.get(), + None => sys_locale::get_locale().unwrap_or_default(), + } +} + +fn build_tray_menu>(manager: &M) -> tauri::Result> { + let (show_label, quit_label) = tray_menu_labels_for_locale(¤t_app_locale(manager)); + MenuBuilder::new(manager).text("show", show_label).separator().text("quit", quit_label).build() +} + +/// Rebuilds the tray menu (and the macOS app menu) so native labels follow the +/// UI language after the frontend reports a locale change. +pub(crate) fn refresh_native_menus(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(tray) = app.tray_by_id(DESKTOP_TRAY_ID) { + tray.set_menu(Some(build_tray_menu(app)?))?; + } + #[cfg(target_os = "macos")] + { + let _ = app.set_menu(build_app_menu(app)?)?; + } + Ok(()) +} + #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] fn setup_desktop_tray>( manager: &M, _icon_theme: DesktopIconTheme, ) -> tauri::Result<()> { - let menu = MenuBuilder::new(manager).text("show", "Show DBX").separator().text("quit", "Quit DBX").build()?; + let menu = build_tray_menu(manager)?; let mut tray = TrayIconBuilder::::with_id(DESKTOP_TRAY_ID).tooltip("DBX").menu(&menu).show_menu_on_left_click(false); #[cfg(target_os = "macos")] @@ -635,17 +772,50 @@ pub(crate) fn apply_desktop_settings(app: &tauri::AppHandle, desktop_settings: & #[allow(clippy::items_after_test_module)] mod tests { use super::{ - linux_appimage_system_gtk_immodules_cache, linux_appimage_wayland_backend_override, - linux_nvidia_driver_from_state, linux_selected_drm_render_device, linux_webkit_rendering_workarounds, - native_window_decorations_override, should_confirm_app_exit_request, should_enable_single_instance, - should_fallback_to_native_quit, should_hide_window_on_close, should_setup_desktop_tray, - should_show_main_window_after_setup, uses_application_level_icon, LinuxDrmRenderDevice, LinuxNvidiaDriver, + app_menu_copy_support_info_label, app_menu_quit_label, linux_appimage_system_gtk_immodules_cache, + linux_appimage_wayland_backend_override, linux_nvidia_driver_from_state, linux_selected_drm_render_device, + linux_webkit_rendering_workarounds, native_window_decorations_override, should_confirm_app_exit_request, + should_enable_single_instance, should_fallback_to_native_quit, should_hide_window_on_close, + should_setup_desktop_tray, should_show_main_window_after_setup, tray_menu_labels_for_locale, + uses_application_level_icon, LinuxDrmRenderDevice, LinuxNvidiaDriver, }; use std::ffi::OsStr; use std::path::{Path, PathBuf}; const TEST_GTK3_IMMODULES_CACHE: &str = "/usr/lib/test/gtk-3.0/3.0.0/immodules.cache"; + #[test] + fn tray_menu_labels_follow_locale() { + assert_eq!(tray_menu_labels_for_locale("zh-CN"), ("显示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("zh_CN"), ("显示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("zh-Hans-CN"), ("显示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("zh"), ("显示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("zh-TW"), ("顯示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("zh-Hant-HK"), ("顯示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("zh-MO"), ("顯示 DBX", "退出 DBX")); + assert_eq!(tray_menu_labels_for_locale("ja-JP"), ("DBXを表示", "DBXを終了")); + assert_eq!(tray_menu_labels_for_locale("es-ES"), ("Mostrar DBX", "Salir de DBX")); + assert_eq!(tray_menu_labels_for_locale("it-IT"), ("Mostra DBX", "Esci da DBX")); + assert_eq!(tray_menu_labels_for_locale("pt-BR"), ("Mostrar DBX", "Sair do DBX")); + assert_eq!(tray_menu_labels_for_locale("en-US"), ("Show DBX", "Quit DBX")); + // Unknown and empty locales fall back to English; "ita" must not match "it". + assert_eq!(tray_menu_labels_for_locale("ko-KR"), ("Show DBX", "Quit DBX")); + assert_eq!(tray_menu_labels_for_locale("ita"), ("Show DBX", "Quit DBX")); + assert_eq!(tray_menu_labels_for_locale(""), ("Show DBX", "Quit DBX")); + } + + #[test] + fn app_menu_labels_follow_locale() { + assert_eq!(app_menu_quit_label("zh-CN", "DBX"), "退出 DBX"); + assert_eq!(app_menu_quit_label("zh-TW", "DBX"), "退出 DBX"); + assert_eq!(app_menu_quit_label("ja-JP", "DBX"), "DBXを終了"); + assert_eq!(app_menu_quit_label("en-US", "DBX"), "Quit DBX"); + assert_eq!(app_menu_quit_label("", "DBX"), "Quit DBX"); + assert_eq!(app_menu_copy_support_info_label("zh-CN"), "复制支持信息"); + assert_eq!(app_menu_copy_support_info_label("zh-TW"), "複製支援資訊"); + assert_eq!(app_menu_copy_support_info_label("en-US"), "Copy Support Info"); + } + #[test] fn hides_window_on_close_for_windows_and_macos() { assert!(should_hide_window_on_close("windows")); @@ -974,6 +1144,7 @@ pub fn run() { builder .manage(CloseBehaviorState::new()) + .manage(AppLocaleState::new()) .on_page_load(|webview, payload| { if payload.event() == PageLoadEvent::Started { if let Some(state) = webview.app_handle().try_state::() { @@ -1128,6 +1299,7 @@ pub fn run() { commands::app_settings::save_desktop_settings, commands::app_settings::load_max_agent_turns, commands::app_settings::save_max_agent_turns, + commands::app_settings::set_app_locale, commands::app_settings::complete_app_close, commands::app_settings::mark_frontend_ready, commands::app_settings::request_app_close_from_window_controls,