chore: i18n (#5276)

* chore: notice i18n

* feat: add script to clean up unused i18n keys

* chore: cleanup i18n keys

* refactor(i18n/proxies): migrate proxies UI to structured locale keys

* chore: i18n for rule module

* chore: i18n for profile module

* chore: i18n for connections module

* chore: i18n for settings module

* chore: i18n for verge settings

* chore: i18n for theme settings

* chore: i18n for theme

* chore(i18n): components.home.*

* chore(i18n): remove unused i18n keys

* chore(i18n): components.profile.*

* chore(i18n): components.connection

* chore(i18n): pages.logs.*

* chore(i18n): pages.*.provider

* chore(i18n): components.settings.externalCors.*

* chore(i18n): components.settings.clash.*

* chore(i18n): components.settings.liteMode.*

* chore(i18n): components.settings.backup.*

* chore(i18n): components.settings.clash.port.*

* chore(i18n): components.settings.misc.*

* chore(i18n): components.settings.update.*

* chore(i18n): components.settings.sysproxy.*

* chore(i18n): components.settings.sysproxy.*

* chore(i18n): pages.profiles.notices/components.providers.notices

* refactor(notice): unify showNotice usage

* refactor(notice): add typed showNotice shortcuts, centralize defaults, and simplify subscriptions

* refactor: unify showNotice usage

* refactor(notice): unify showNotice API

* refactor(notice): unify showNotice usage

* chore(i18n): components.test.*

* chore(i18n): components.settings.dns.*

* chore(i18n): components.home.clashInfo.*

* chore(i18n): components.home.systemInfo.*

* chore(i18n): components.home.ipInfo/traffic.*

* chore(i18n): navigation.*

* refactor(i18n): remove pages.* namespace and migrate route texts under module-level page keys

* chore(i18n): common.*

* chore(i18n): common.*

* fix: change error handling in patch_profiles_config to return false when a switch is in progress

* fix: improve error handling in patch_profiles_config to prevent requests during profile switching

* fix: change error handling in patch_profiles_config to return false when a switch is in progress

fix: ensure CURRENT_SWITCHING_PROFILE is reset after config updates in perform_config_update and patch_profiles_config

* chore(i18n): restructure root-level locale keys into namespaces

* chore(i18n): add missing i18n keys

* docs: i18n guide

* chore: adjust i18n

* refactor(i18n): align UI actions and status labels with common keys

* refactor(i18n): unify two-name locale namespaces

* refactor(i18n/components): unify locale keys and update component references

* chore(i18n): add shared and entities namespaces to all locale files

* refactor(i18n): consolidate shared and entity namespaces across features

* chore(deps): update npm dependencies to ^7.3.5 (#5310)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* refactor(i18n): migrate shared editor modes and consolidate entities namespaces

* tmp

* refactor(i18n): flatten locales and move theme/validation strings

* docs: CONTRIBUTING_i18n.md

* refactor(i18n): restructure feedback and profile namespaces for better organization

* refactor(i18n): unify settings locale structure and update references

* refactor(i18n): reorganize locale keys for home, proxies, rules, connections, logs, unlock, and tests

* refactor(i18n/feedback/layout): unify shared toasts & normalize layout namespace

* refactor(i18n): centralize common UI strings in shared

* refactor(i18n): flatten headers and unify locale schema

* refactor(i18n): consolidate duplicate per-feature translations into shared namespace

* refactor(i18n): split locales into per-namespace files

* style: lint

* refactor(i18n): unify unlock UI translations under tests namespace

* feat(i18n): add type-checked translation keys

* style: eslint import order

* feat(i18n): replace ad-hoc loader with rust-i18n backend bundles

* chore(prebuild): remove locale-copy step

* fix(i18n, notice): propagate runtime params and update cleanup script path

* fix(i18n,notice): make locale formatting idempotent and guard early notice translations

* fix(i18n): resolve locale aliases and match OS codes correctly

* fix(unlock): use i18next-compatible double-brace interpolation in failure notice

* fix(i18n): route unlock error notices through translation keys

* fix(i18n): i18n types

* feat(i18n): localize upgrade notice for Clash core viewer

* fix(notice): ensure runtime overrides apply to prefix translations

* chore(i18n): replace literal notices with translation keys

* chore(i18n): types

* chore(i18n): regen typings before formatting to keep keys in sync

* chore(i18n): simply labels

* chore(i18n): adjust translation

* chore: remove eslint-plugin-i18next

* chore(i18n): add/refine Korean translations across frontend scopes and Rust backend (#5341)

* chore(i18n): translate settings.json (missed in previous pass) (#5343)

* chore(i18n): add/refine Korean translations across frontend scopes and Rust backend

* chore(i18n): add/refine Korean translations across frontend scopes and Rust backend

* fix(i18n-tauri): quote placeholder-leading value in ko.yml to prevent rust_i18n parse panic

* chore(i18n): translate settings.json (forgot to include previously)

---------

Co-authored-by: rozan <34974262+thelojan@users.noreply.github.com>
This commit is contained in:
Sline
2025-11-08 19:40:38 +08:00
committed by GitHub
parent c1a2e99f63
commit c8aa72186e
286 changed files with 24243 additions and 8867 deletions

View File

@@ -1,8 +1,5 @@
use super::{CmdResult, StringifyErr};
use crate::{
core::service::{self, SERVICE_MANAGER, ServiceStatus},
utils::i18n::t,
};
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
use smartstring::SmartString;
async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult {
@@ -13,7 +10,7 @@ async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) ->
.await
{
let emsg = format!("{} Service failed: {}", op_type, e);
return Err(SmartString::from(&*t(emsg.as_str()).await));
return Err(SmartString::from(emsg));
}
Ok(())
}

View File

@@ -227,8 +227,6 @@ fn linux_running_as_root() -> bool {
#[cfg(target_os = "macos")]
async fn uninstall_service() -> Result<()> {
use crate::utils::i18n::t;
logging!(info, Type::Service, "uninstall service");
let binary_path = dirs::service_path()?;
@@ -240,7 +238,9 @@ async fn uninstall_service() -> Result<()> {
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
let prompt = t("Service Administrator Prompt").await;
crate::utils::i18n::sync_locale().await;
let prompt = rust_i18n::t!("service.adminPrompt").to_string();
let command = format!(
r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#
);
@@ -263,8 +263,6 @@ async fn uninstall_service() -> Result<()> {
#[cfg(target_os = "macos")]
async fn install_service() -> Result<()> {
use crate::utils::i18n::t;
logging!(info, Type::Service, "install service");
let binary_path = dirs::service_path()?;
@@ -276,7 +274,9 @@ async fn install_service() -> Result<()> {
let install_shell: String = install_path.to_string_lossy().into_owned();
let prompt = t("Service Administrator Prompt").await;
crate::utils::i18n::sync_locale().await;
let prompt = rust_i18n::t!("service.adminPrompt").to_string();
let command = format!(
r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#
);

View File

@@ -1,5 +1,12 @@
use crate::utils::i18n::t;
use std::sync::Arc;
use rust_i18n::t;
use std::{borrow::Cow, sync::Arc};
fn to_arc_str(value: Cow<'static, str>) -> Arc<str> {
match value {
Cow::Borrowed(s) => Arc::from(s),
Cow::Owned(s) => Arc::from(s.into_boxed_str()),
}
}
macro_rules! define_menu {
($($field:ident => $const_name:ident, $id:expr, $text:expr),+ $(,)?) => {
@@ -11,9 +18,10 @@ macro_rules! define_menu {
pub struct MenuIds;
impl MenuTexts {
pub async fn new() -> Self {
let ($($field,)+) = futures::join!($(t($text),)+);
Self { $($field,)+ }
pub fn new() -> Self {
Self {
$($field: to_arc_str(t!($text)),)+
}
}
}
@@ -24,26 +32,26 @@ macro_rules! define_menu {
}
define_menu! {
dashboard => DASHBOARD, "tray_dashboard", "Dashboard",
rule_mode => RULE_MODE, "tray_rule_mode", "Rule Mode",
global_mode => GLOBAL_MODE, "tray_global_mode", "Global Mode",
direct_mode => DIRECT_MODE, "tray_direct_mode", "Direct Mode",
profiles => PROFILES, "tray_profiles", "Profiles",
proxies => PROXIES, "tray_proxies", "Proxies",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "System Proxy",
tun_mode => TUN_MODE, "tray_tun_mode", "TUN Mode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "Close All Connections",
lightweight_mode => LIGHTWEIGHT_MODE, "tray_lightweight_mode", "LightWeight Mode",
copy_env => COPY_ENV, "tray_copy_env", "Copy Env",
conf_dir => CONF_DIR, "tray_conf_dir", "Conf Dir",
core_dir => CORE_DIR, "tray_core_dir", "Core Dir",
logs_dir => LOGS_DIR, "tray_logs_dir", "Logs Dir",
open_dir => OPEN_DIR, "tray_open_dir", "Open Dir",
app_log => APP_LOG, "tray_app_log", "Open App Log",
core_log => CORE_LOG, "tray_core_log", "Open Core Log",
restart_clash => RESTART_CLASH, "tray_restart_clash", "Restart Clash Core",
restart_app => RESTART_APP, "tray_restart_app", "Restart App",
verge_version => VERGE_VERSION, "tray_verge_version", "Verge Version",
more => MORE, "tray_more", "More",
exit => EXIT, "tray_exit", "Exit",
dashboard => DASHBOARD, "tray_dashboard", "tray.dashboard",
rule_mode => RULE_MODE, "tray_rule_mode", "tray.ruleMode",
global_mode => GLOBAL_MODE, "tray_global_mode", "tray.globalMode",
direct_mode => DIRECT_MODE, "tray_direct_mode", "tray.directMode",
profiles => PROFILES, "tray_profiles", "tray.profiles",
proxies => PROXIES, "tray_proxies", "tray.proxies",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
lightweight_mode => LIGHTWEIGHT_MODE, "tray_lightweight_mode", "tray.lightweightMode",
copy_env => COPY_ENV, "tray_copy_env", "tray.copyEnv",
conf_dir => CONF_DIR, "tray_conf_dir", "tray.confDir",
core_dir => CORE_DIR, "tray_core_dir", "tray.coreDir",
logs_dir => LOGS_DIR, "tray_logs_dir", "tray.logsDir",
open_dir => OPEN_DIR, "tray_open_dir", "tray.openDir",
app_log => APP_LOG, "tray_app_log", "tray.appLog",
core_log => CORE_LOG, "tray_core_log", "tray.coreLog",
restart_clash => RESTART_CLASH, "tray_restart_clash", "tray.restartClash",
restart_app => RESTART_APP, "tray_restart_app", "tray.restartApp",
verge_version => VERGE_VERSION, "tray_verge_version", "tray.vergeVersion",
more => MORE, "tray_more", "tray.more",
exit => EXIT, "tray_exit", "tray.exit",
}

View File

@@ -15,7 +15,7 @@ use crate::{
feat, logging,
module::lightweight::is_in_lightweight_mode,
singleton_lazy,
utils::{dirs::find_target_icons, i18n::t},
utils::{dirs::find_target_icons, i18n},
};
use super::handle;
@@ -438,6 +438,8 @@ impl Tray {
let app_handle = handle::Handle::app_handle();
i18n::sync_locale().await;
let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
@@ -464,9 +466,9 @@ impl Tray {
}
// Get localized strings before using them
let sys_proxy_text = t("SysProxy").await;
let tun_text = t("TUN").await;
let profile_text = t("Profile").await;
let sys_proxy_text = rust_i18n::t!("tray.tooltip.systemProxy");
let tun_text = rust_i18n::t!("tray.tooltip.tun");
let profile_text = rust_i18n::t!("tray.tooltip.profile");
let v = env!("CARGO_PKG_VERSION");
let reassembled_version = v.split_once('+').map_or_else(
@@ -622,7 +624,7 @@ async fn create_profile_menu_item(
CheckMenuItem::with_id(
&app_handle,
format!("profiles_{profile_uid}"),
t(profile_name).await,
profile_name.as_str(),
true,
is_current_profile,
None::<&str>,
@@ -817,6 +819,8 @@ async fn create_tray_menu(
) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = mode.unwrap_or("");
i18n::sync_locale().await;
// 获取当前配置文件的选中代理组信息
let current_profile_selected = {
let profiles_config = Config::profiles().await;
@@ -877,7 +881,7 @@ async fn create_tray_menu(
create_profile_menu_item(app_handle, profile_uid_and_name).await?;
// Pre-fetch all localized strings
let texts = &MenuTexts::new().await;
let texts = MenuTexts::new();
// Convert to references only when needed
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
.iter()

View File

@@ -25,12 +25,15 @@ use crate::{
use anyhow::Result;
use config::Config;
use once_cell::sync::OnceCell;
use rust_i18n::i18n;
use tauri::{AppHandle, Manager};
#[cfg(target_os = "macos")]
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt;
use utils::logging::Type;
i18n!("locales", fallback = "zh");
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
mod app_init {

View File

@@ -1,43 +1,80 @@
use crate::{config::Config, utils::dirs};
use once_cell::sync::Lazy;
use smartstring::alias::String;
use std::{
collections::HashMap,
fs,
path::PathBuf,
sync::{Arc, RwLock},
};
use crate::config::Config;
use sys_locale;
const DEFAULT_LANGUAGE: &str = "zh";
type TranslationMap = (String, HashMap<String, Arc<str>>);
fn supported_languages_internal() -> Vec<&'static str> {
rust_i18n::available_locales!()
}
fn get_locales_dir() -> Option<PathBuf> {
dirs::app_resources_dir()
.map(|resource_path| resource_path.join("locales"))
.ok()
const fn fallback_language() -> &'static str {
DEFAULT_LANGUAGE
}
fn locale_alias(locale: &str) -> Option<&'static str> {
match locale {
"ja" | "ja-jp" | "jp" => Some("jp"),
"zh" | "zh-cn" | "zh-hans" | "zh-sg" | "zh-my" | "zh-chs" => Some("zh"),
"zh-tw" | "zh-hk" | "zh-hant" | "zh-mo" | "zh-cht" => Some("zhtw"),
_ => None,
}
}
fn resolve_supported_language(language: &str) -> Option<String> {
if language.is_empty() {
return None;
}
let normalized = language.to_lowercase().replace('_', "-");
let mut candidates: Vec<String> = Vec::new();
let mut push_candidate = |candidate: String| {
if !candidate.is_empty()
&& !candidates
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&candidate))
{
candidates.push(candidate);
}
};
let segments: Vec<&str> = normalized.split('-').collect();
for i in (1..=segments.len()).rev() {
let prefix = segments[..i].join("-");
if let Some(alias) = locale_alias(&prefix) {
push_candidate(alias.to_string());
}
push_candidate(prefix);
}
let supported = supported_languages_internal();
candidates.into_iter().find(|candidate| {
supported
.iter()
.any(|&lang| lang.eq_ignore_ascii_case(candidate))
})
}
fn system_language() -> String {
sys_locale::get_locale()
.as_deref()
.and_then(resolve_supported_language)
.unwrap_or_else(|| fallback_language().to_string())
}
pub fn get_supported_languages() -> Vec<String> {
let mut languages = Vec::new();
supported_languages_internal()
.into_iter()
.map(|lang| lang.to_string())
.collect()
}
if let Some(locales_dir) = get_locales_dir()
&& let Ok(entries) = fs::read_dir(locales_dir)
{
for entry in entries.flatten() {
if let Some(file_name) = entry.file_name().to_str()
&& let Some(lang) = file_name.strip_suffix(".json")
{
languages.push(lang.into());
}
}
}
if languages.is_empty() {
languages.push(DEFAULT_LANGUAGE.into());
}
languages
pub fn set_locale(language: &str) {
let lang =
resolve_supported_language(language).unwrap_or_else(|| fallback_language().to_string());
rust_i18n::set_locale(&lang);
}
pub async fn current_language() -> String {
@@ -45,70 +82,18 @@ pub async fn current_language() -> String {
.await
.latest_arc()
.language
.as_deref()
.map(String::from)
.unwrap_or_else(get_system_language)
.clone()
.filter(|lang| !lang.is_empty())
.and_then(|lang| resolve_supported_language(&lang))
.unwrap_or_else(system_language)
}
static TRANSLATIONS: Lazy<RwLock<TranslationMap>> = Lazy::new(|| {
let lang = get_system_language();
let map = load_lang_file(&lang).unwrap_or_default();
RwLock::new((lang, map))
});
fn load_lang_file(lang: &str) -> Option<HashMap<String, Arc<str>>> {
let locales_dir = get_locales_dir()?;
let file_path = locales_dir.join(format!("{lang}.json"));
fs::read_to_string(file_path)
.ok()
.and_then(|content| serde_json::from_str::<HashMap<String, String>>(&content).ok())
.map(|map| {
map.into_iter()
.map(|(k, v)| (k, Arc::from(v.as_str())))
.collect()
})
pub async fn sync_locale() -> String {
let language = current_language().await;
set_locale(&language);
language
}
fn get_system_language() -> String {
sys_locale::get_locale()
.map(|locale| locale.to_lowercase())
.and_then(|locale| locale.split(['_', '-']).next().map(String::from))
.filter(|lang| get_supported_languages().contains(lang))
.unwrap_or_else(|| DEFAULT_LANGUAGE.into())
}
pub async fn t(key: &str) -> Arc<str> {
let current_lang = current_language().await;
{
if let Ok(cache) = TRANSLATIONS.read()
&& cache.0 == current_lang
&& let Some(text) = cache.1.get(key)
{
return Arc::clone(text);
}
}
if let Some(new_map) = load_lang_file(&current_lang)
&& let Ok(mut cache) = TRANSLATIONS.write()
{
*cache = (current_lang.clone(), new_map);
if let Some(text) = cache.1.get(key) {
return Arc::clone(text);
}
}
if current_lang != DEFAULT_LANGUAGE
&& let Some(default_map) = load_lang_file(DEFAULT_LANGUAGE)
&& let Ok(mut cache) = TRANSLATIONS.write()
{
*cache = (DEFAULT_LANGUAGE.into(), default_map);
if let Some(text) = cache.1.get(key) {
return Arc::clone(text);
}
}
Arc::from(key)
pub const fn default_language() -> &'static str {
fallback_language()
}

View File

@@ -1,5 +1,4 @@
use crate::{core::handle, utils::i18n::t};
use crate::{core::handle, utils::i18n};
use tauri_plugin_notification::NotificationExt;
pub enum NotificationEvent<'a> {
@@ -27,48 +26,44 @@ fn notify(title: &str, body: &str) {
}
pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
i18n::sync_locale().await;
match event {
NotificationEvent::DashboardToggled => {
notify(
&t("DashboardToggledTitle").await,
&t("DashboardToggledBody").await,
);
let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string();
let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string();
notify(&title, &body);
}
NotificationEvent::ClashModeChanged { mode } => {
notify(
&t("ClashModeChangedTitle").await,
&t_with_args("ClashModeChangedBody", mode).await,
);
let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string();
let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
notify(&title, &body);
}
NotificationEvent::SystemProxyToggled => {
notify(
&t("SystemProxyToggledTitle").await,
&t("SystemProxyToggledBody").await,
);
let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string();
let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string();
notify(&title, &body);
}
NotificationEvent::TunModeToggled => {
notify(
&t("TunModeToggledTitle").await,
&t("TunModeToggledBody").await,
);
let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string();
let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string();
notify(&title, &body);
}
NotificationEvent::LightweightModeEntered => {
notify(
&t("LightweightModeEnteredTitle").await,
&t("LightweightModeEnteredBody").await,
);
let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string();
let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string();
notify(&title, &body);
}
NotificationEvent::AppQuit => {
notify(&t("AppQuitTitle").await, &t("AppQuitBody").await);
let title = rust_i18n::t!("notifications.appQuit.title").to_string();
let body = rust_i18n::t!("notifications.appQuit.body").to_string();
notify(&title, &body);
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
notify(&t("AppHiddenTitle").await, &t("AppHiddenBody").await);
let title = rust_i18n::t!("notifications.appHidden.title").to_string();
let body = rust_i18n::t!("notifications.appHidden.body").to_string();
notify(&title, &body);
}
}
}
// 辅助函数带参数的i18n
async fn t_with_args(key: &str, mode: &str) -> String {
t(key).await.replace("{mode}", mode)
}