crate(i18n): add clash-verge-i18n crate and integrate localization support (#5961)

* crate(i18n): add clash-verge-i18n crate and integrate localization support

* refactor(service): remove redundant reinstall_service functions for Windows, Linux, and macOS

* chore(i18n): align i18n key

* feat(i18n): unify scan roots and add backend Rust/YAML support to cleanup script

* chore(i18n): add scripts to package.json

* fix(tray): initialize i18n locale before setup

* refactor(i18n): move locale initialization into Config::init_config

* fix(i18n): refresh systray tooltip on language change and correct docs reference

* fix(tray): remove unnecessary locale synchronization to improve performance

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
Sline
2025-12-27 15:03:19 +08:00
committed by GitHub
co-authored by Tunglies
parent 1b477ed0b2
commit c8aeae3f83
31 changed files with 486 additions and 312 deletions
+3
View File
@@ -65,6 +65,9 @@ impl Config {
pub async fn init_config() -> Result<()> {
Self::ensure_default_profile_items().await?;
let verge = Self::verge().await.latest_arc();
clash_verge_i18n::sync_locale(verge.language.as_deref());
// init Tun mode
let handle = Handle::app_handle();
let is_admin = is_current_app_handle_admin(handle);
+2 -15
View File
@@ -1,7 +1,7 @@
use crate::config::Config;
use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
utils::{dirs, help, i18n},
utils::{dirs, help},
};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
@@ -346,19 +346,6 @@ impl IVerge {
self.clash_core.clone().unwrap_or_else(|| "verge-mihomo".into())
}
fn get_system_language() -> String {
let sys_lang = sys_locale::get_locale().unwrap_or_else(|| "en".into()).to_lowercase();
let lang_code = sys_lang.split(['_', '-']).next().unwrap_or("en");
let supported_languages = i18n::get_supported_languages();
if supported_languages.contains(&lang_code.into()) {
lang_code.into()
} else {
String::from("en")
}
}
pub async fn new() -> Self {
match dirs::verge_path() {
Ok(path) => match help::read_yaml::<Self>(&path).await {
@@ -388,7 +375,7 @@ impl IVerge {
app_log_max_size: Some(128),
app_log_max_count: Some(8),
clash_core: Some("verge-mihomo".into()),
language: Some(Self::get_system_language()),
language: Some(clash_verge_i18n::system_language().into()),
theme_mode: Some("system".into()),
#[cfg(not(target_os = "windows"))]
env_type: Some("bash".into()),
+19 -60
View File
@@ -30,9 +30,8 @@ pub enum ServiceStatus {
#[derive(Clone)]
pub struct ServiceManager(ServiceStatus);
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token};
@@ -63,9 +62,8 @@ async fn uninstall_service() -> Result<()> {
Ok(())
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
use deelevate::{PrivilegeLevel, Token};
@@ -93,27 +91,8 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
async fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
}
}
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "linux")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall");
@@ -169,8 +148,7 @@ async fn uninstall_service() -> Result<()> {
}
#[cfg(target_os = "linux")]
#[allow(clippy::unused_async)]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
let install_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install");
@@ -222,24 +200,6 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
}
}
}
#[cfg(target_os = "linux")]
fn linux_running_as_root() -> bool {
use crate::core::handle;
@@ -249,7 +209,7 @@ fn linux_running_as_root() -> bool {
}
#[cfg(target_os = "macos")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let binary_path = dirs::service_path()?;
@@ -261,9 +221,9 @@ async fn uninstall_service() -> Result<()> {
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
crate::utils::i18n::sync_locale().await;
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = rust_i18n::t!("service.adminUninstallPrompt").to_string();
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
let command =
format!(r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#);
@@ -282,7 +242,7 @@ async fn uninstall_service() -> Result<()> {
}
#[cfg(target_os = "macos")]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
let binary_path = dirs::service_path()?;
@@ -294,9 +254,9 @@ async fn install_service() -> Result<()> {
let install_shell: String = install_path.to_string_lossy().into_owned();
crate::utils::i18n::sync_locale().await;
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = rust_i18n::t!("service.adminInstallPrompt").to_string();
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
let command =
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
@@ -309,17 +269,16 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
async fn reinstall_service() -> Result<()> {
fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
if let Err(err) = uninstall_service() {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
match install_service() {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
@@ -328,9 +287,9 @@ async fn reinstall_service() -> Result<()> {
}
/// 强制重装服务(UI修复按钮)
async fn force_reinstall_service() -> Result<()> {
fn force_reinstall_service() -> Result<()> {
logging!(info, Type::Service, "用户请求强制重装服务");
reinstall_service().await.map_err(|err| {
reinstall_service().map_err(|err| {
logging!(error, Type::Service, "强制重装服务失败: {}", err);
err
})
@@ -550,22 +509,22 @@ impl ServiceManager {
}
ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => {
logging!(info, Type::Service, "服务需要重装,执行重装流程");
reinstall_service().await?;
reinstall_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::ForceReinstallRequired => {
logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程");
force_reinstall_service().await?;
force_reinstall_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::InstallRequired => {
logging!(info, Type::Service, "需要安装服务,执行安装流程");
install_service().await?;
install_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::UninstallRequired => {
logging!(info, Type::Service, "服务需要卸载,执行卸载流程");
uninstall_service().await?;
uninstall_service()?;
self.0 = ServiceStatus::Unavailable("Service Uninstalled".into());
}
ServiceStatus::Unavailable(reason) => {
+6 -3
View File
@@ -1,8 +1,11 @@
use rust_i18n::t;
use clash_verge_i18n::t;
use std::{borrow::Cow, sync::Arc};
fn to_arc_str(value: Cow<'static, str>) -> Arc<str> {
match value {
fn to_arc_str<S>(value: S) -> Arc<str>
where
S: Into<Cow<'static, str>>,
{
match value.into() {
Cow::Borrowed(s) => Arc::from(s),
Cow::Owned(s) => Arc::from(s.into_boxed_str()),
}
+8 -15
View File
@@ -12,11 +12,8 @@ use crate::process::AsyncHandler;
use crate::singleton;
use crate::utils::window_manager::WindowManager;
use crate::{
Type, cmd,
config::Config,
feat, logging,
module::lightweight::is_in_lightweight_mode,
utils::{dirs::find_target_icons, i18n},
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
utils::dirs::find_target_icons,
};
use super::handle;
@@ -389,8 +386,6 @@ 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);
@@ -417,9 +412,9 @@ impl Tray {
}
// Get localized strings before using them
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 sys_proxy_text = clash_verge_i18n::t!("tray.tooltip.systemProxy");
let tun_text = clash_verge_i18n::t!("tray.tooltip.tun");
let profile_text = clash_verge_i18n::t!("tray.tooltip.profile");
let v = env!("CARGO_PKG_VERSION");
let reassembled_version = v.split_once('+').map_or_else(
@@ -724,8 +719,6 @@ async fn create_tray_menu(
) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = mode.unwrap_or("");
i18n::sync_locale().await;
// TODO: should update tray menu again when it was timeout error
let proxy_nodes_data = tokio::time::timeout(
Duration::from_millis(1000),
@@ -829,9 +822,9 @@ async fn create_tray_menu(
None
} else {
let current_mode_text = match current_proxy_mode {
"global" => rust_i18n::t!("tray.global"),
"direct" => rust_i18n::t!("tray.direct"),
_ => rust_i18n::t!("tray.rule"),
"global" => clash_verge_i18n::t!("tray.global"),
"direct" => clash_verge_i18n::t!("tray.direct"),
_ => clash_verge_i18n::t!("tray.rule"),
};
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
Some(Submenu::with_id_and_items(
+8
View File
@@ -63,6 +63,7 @@ enum UpdateFlags {
SystrayTooltip = 1 << 8,
SystrayClickBehavior = 1 << 9,
LighteWeight = 1 << 10,
Language = 1 << 11,
}
fn determine_update_flags(patch: &IVerge) -> i32 {
@@ -153,7 +154,9 @@ fn determine_update_flags(patch: &IVerge) -> i32 {
}
if language.is_some() {
update_flags |= UpdateFlags::Language as i32;
update_flags |= UpdateFlags::SystrayMenu as i32;
update_flags |= UpdateFlags::SystrayTooltip as i32;
}
if common_tray_icon.is_some()
|| sysproxy_tray_icon.is_some()
@@ -212,6 +215,11 @@ async fn process_terminated_flags(update_flags: i32, patch: &IVerge) -> Result<(
if (update_flags & (UpdateFlags::Launch as i32)) != 0 {
sysopt::Sysopt::global().update_launch().await?;
}
if (update_flags & (UpdateFlags::Language as i32)) != 0
&& let Some(language) = &patch.language
{
clash_verge_i18n::set_locale(language.as_str());
}
if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 {
sysopt::Sysopt::global().update_sysproxy().await?;
sysopt::Sysopt::global().refresh_guard().await;
-3
View File
@@ -19,7 +19,6 @@ use crate::{
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use rust_i18n::i18n;
use std::time::Duration;
use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")]
@@ -27,8 +26,6 @@ use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
use tauri_plugin_mihomo::RejectPolicy;
i18n!("locales", fallback = "zh");
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
mod app_init {
-96
View File
@@ -1,96 +0,0 @@
use crate::config::Config;
use sys_locale;
const DEFAULT_LANGUAGE: &str = "zh";
fn supported_languages_internal() -> Vec<&'static str> {
rust_i18n::available_locales!()
}
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> {
supported_languages_internal()
.into_iter()
.map(|lang| lang.to_string())
.collect()
}
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 {
Config::verge()
.await
.latest_arc()
.language
.as_ref()
.filter(|lang| !lang.is_empty())
.and_then(|lang| resolve_supported_language(lang))
.unwrap_or_else(system_language)
}
pub async fn sync_locale() -> String {
let language = current_language().await;
set_locale(&language);
language
}
pub const fn default_language() -> &'static str {
fallback_language()
}
-1
View File
@@ -1,6 +1,5 @@
pub mod dirs;
pub mod help;
pub mod i18n;
pub mod init;
#[cfg(target_os = "linux")]
pub mod linux;
+20 -18
View File
@@ -1,4 +1,5 @@
use crate::{core::handle, utils::i18n};
use crate::core::handle;
use clash_verge_i18n;
use tauri_plugin_notification::NotificationExt as _;
pub enum NotificationEvent<'a> {
@@ -21,48 +22,49 @@ fn notify(title: &str, body: &str) {
}
pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
i18n::sync_locale().await;
// It cause to sync set-local everytime notify, so move it outside
// i18n::sync_locale().await;
match event {
NotificationEvent::DashboardToggled => {
let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string();
let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string();
let title = clash_verge_i18n::t!("notifications.dashboardToggled.title");
let body = clash_verge_i18n::t!("notifications.dashboardToggled.body");
notify(&title, &body);
}
NotificationEvent::ClashModeChanged { mode } => {
let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string();
let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
let title = clash_verge_i18n::t!("notifications.clashModeChanged.title");
let body = clash_verge_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
notify(&title, &body);
}
NotificationEvent::SystemProxyToggled => {
let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string();
let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string();
let title = clash_verge_i18n::t!("notifications.systemProxyToggled.title");
let body = clash_verge_i18n::t!("notifications.systemProxyToggled.body");
notify(&title, &body);
}
NotificationEvent::TunModeToggled => {
let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string();
let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string();
let title = clash_verge_i18n::t!("notifications.tunModeToggled.title");
let body = clash_verge_i18n::t!("notifications.tunModeToggled.body");
notify(&title, &body);
}
NotificationEvent::LightweightModeEntered => {
let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string();
let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string();
let title = clash_verge_i18n::t!("notifications.lightweightModeEntered.title");
let body = clash_verge_i18n::t!("notifications.lightweightModeEntered.body");
notify(&title, &body);
}
NotificationEvent::ProfilesReactivated => {
let title = rust_i18n::t!("notifications.profilesReactivated.title").to_string();
let body = rust_i18n::t!("notifications.profilesReactivated.body").to_string();
let title = clash_verge_i18n::t!("notifications.profilesReactivated.title");
let body = clash_verge_i18n::t!("notifications.profilesReactivated.body");
notify(&title, &body);
}
NotificationEvent::AppQuit => {
let title = rust_i18n::t!("notifications.appQuit.title").to_string();
let body = rust_i18n::t!("notifications.appQuit.body").to_string();
let title = clash_verge_i18n::t!("notifications.appQuit.title");
let body = clash_verge_i18n::t!("notifications.appQuit.body");
notify(&title, &body);
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
let title = rust_i18n::t!("notifications.appHidden.title").to_string();
let body = rust_i18n::t!("notifications.appHidden.body").to_string();
let title = clash_verge_i18n::t!("notifications.appHidden.title");
let body = clash_verge_i18n::t!("notifications.appHidden.body");
notify(&title, &body);
}
}