mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
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:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -1197,6 +1197,7 @@ dependencies = [
|
|||||||
"boa_engine",
|
"boa_engine",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clash-verge-draft",
|
"clash-verge-draft",
|
||||||
|
"clash-verge-i18n",
|
||||||
"clash-verge-logging",
|
"clash-verge-logging",
|
||||||
"clash-verge-signal",
|
"clash-verge-signal",
|
||||||
"clash-verge-types",
|
"clash-verge-types",
|
||||||
@@ -1224,14 +1225,12 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"reqwest_dav",
|
"reqwest_dav",
|
||||||
"runas",
|
"runas",
|
||||||
"rust-i18n",
|
|
||||||
"rust_iso3166",
|
"rust_iso3166",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml_ng",
|
"serde_yaml_ng",
|
||||||
"smartstring",
|
"smartstring",
|
||||||
"sys-locale",
|
|
||||||
"sysproxy",
|
"sysproxy",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -1268,6 +1267,14 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clash-verge-i18n"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rust-i18n",
|
||||||
|
"sys-locale",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clash-verge-logging"
|
name = "clash-verge-logging"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
22
Cargo.toml
22
Cargo.toml
@@ -1,11 +1,12 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"src-tauri",
|
"src-tauri",
|
||||||
"crates/clash-verge-draft",
|
"crates/clash-verge-draft",
|
||||||
"crates/clash-verge-logging",
|
"crates/clash-verge-logging",
|
||||||
"crates/clash-verge-signal",
|
"crates/clash-verge-signal",
|
||||||
"crates/tauri-plugin-clash-verge-sysinfo",
|
"crates/tauri-plugin-clash-verge-sysinfo",
|
||||||
"crates/clash-verge-types",
|
"crates/clash-verge-types",
|
||||||
|
"crates/clash-verge-i18n",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ clash-verge-draft = { path = "crates/clash-verge-draft" }
|
|||||||
clash-verge-logging = { path = "crates/clash-verge-logging" }
|
clash-verge-logging = { path = "crates/clash-verge-logging" }
|
||||||
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
||||||
clash-verge-types = { path = "crates/clash-verge-types" }
|
clash-verge-types = { path = "crates/clash-verge-types" }
|
||||||
|
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||||
|
|
||||||
tauri = { version = "2.9.5" }
|
tauri = { version = "2.9.5" }
|
||||||
@@ -52,10 +54,10 @@ parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
|||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
criterion = { version = "0.7.0", features = ["async_tokio"] }
|
criterion = { version = "0.7.0", features = ["async_tokio"] }
|
||||||
tokio = { version = "1.48.0", features = [
|
tokio = { version = "1.48.0", features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"macros",
|
"macros",
|
||||||
"time",
|
"time",
|
||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
flexi_logger = "0.31.7"
|
flexi_logger = "0.31.7"
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
|
|||||||
11
crates/clash-verge-i18n/Cargo.toml
Normal file
11
crates/clash-verge-i18n/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "clash-verge-i18n"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rust-i18n = "3.1.5"
|
||||||
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,9 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Uninstalling the service requires administrator privileges.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
|
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: 앱이 숨겨짐
|
title: 앱이 숨겨짐
|
||||||
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
||||||
service:
|
service:
|
||||||
adminPrompt: 서비스를 설치하려면 관리자 권한이 필요합니다.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: 대시보드
|
dashboard: 대시보드
|
||||||
ruleMode: 규칙 모드
|
ruleMode: 규칙 모드
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,7 +25,8 @@ notifications:
|
|||||||
title: Application Hidden
|
title: Application Hidden
|
||||||
body: Clash Verge is running in the background.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminPrompt: Installing the service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ruleMode: Rule Mode
|
ruleMode: Rule Mode
|
||||||
@@ -25,8 +25,8 @@ notifications:
|
|||||||
title: 應用已隱藏
|
title: 應用已隱藏
|
||||||
body: Clash Verge 正在背景執行。
|
body: Clash Verge 正在背景執行。
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: 安裝服務需要管理員權限
|
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||||
adminUninstallPrompt: 卸载服務需要管理員權限
|
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
|
||||||
tray:
|
tray:
|
||||||
dashboard: 儀表板
|
dashboard: 儀表板
|
||||||
ruleMode: 規則模式
|
ruleMode: 規則模式
|
||||||
103
crates/clash-verge-i18n/src/lib.rs
Normal file
103
crates/clash-verge-i18n/src/lib.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use rust_i18n::i18n;
|
||||||
|
|
||||||
|
const DEFAULT_LANGUAGE: &str = "zh";
|
||||||
|
i18n!("locales", fallback = "zh");
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn resolve_supported_language(language: &str) -> Option<&'static str> {
|
||||||
|
if language.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let normalized = language.to_lowercase().replace('_', "-");
|
||||||
|
let segments: Vec<&str> = normalized.split('-').collect();
|
||||||
|
let supported = rust_i18n::available_locales!();
|
||||||
|
for i in (1..=segments.len()).rev() {
|
||||||
|
let prefix = segments[..i].join("-");
|
||||||
|
if let Some(alias) = locale_alias(&prefix)
|
||||||
|
&& let Some(&found) = supported.iter().find(|&&l| l.eq_ignore_ascii_case(alias))
|
||||||
|
{
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
if let Some(&found) = supported.iter().find(|&&l| l.eq_ignore_ascii_case(&prefix)) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn current_language(language: Option<&str>) -> &str {
|
||||||
|
language
|
||||||
|
.as_ref()
|
||||||
|
.filter(|lang| !lang.is_empty())
|
||||||
|
.and_then(|lang| resolve_supported_language(lang))
|
||||||
|
.unwrap_or_else(system_language)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn system_language() -> &'static str {
|
||||||
|
sys_locale::get_locale()
|
||||||
|
.as_deref()
|
||||||
|
.and_then(resolve_supported_language)
|
||||||
|
.unwrap_or(DEFAULT_LANGUAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn sync_locale(language: Option<&str>) {
|
||||||
|
let language = current_language(language);
|
||||||
|
set_locale(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn set_locale(language: &str) {
|
||||||
|
let lang = resolve_supported_language(language).unwrap_or(DEFAULT_LANGUAGE);
|
||||||
|
rust_i18n::set_locale(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn translate(key: &str) -> String {
|
||||||
|
rust_i18n::t!(key).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! t {
|
||||||
|
($key:expr) => {
|
||||||
|
$crate::translate(&$key)
|
||||||
|
};
|
||||||
|
($key:expr, $($arg_name:ident = $arg_value:expr),*) => {
|
||||||
|
{
|
||||||
|
let mut _text = $crate::translate(&$key);
|
||||||
|
$(
|
||||||
|
_text = _text.replace(&format!("{{{}}}", stringify!($arg_name)), &$arg_value.to_string());
|
||||||
|
)*
|
||||||
|
_text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::resolve_supported_language;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_supported_language() {
|
||||||
|
assert_eq!(resolve_supported_language("en"), Some("en"));
|
||||||
|
assert_eq!(resolve_supported_language("en-US"), Some("en"));
|
||||||
|
assert_eq!(resolve_supported_language("zh"), Some("zh"));
|
||||||
|
assert_eq!(resolve_supported_language("zh-CN"), Some("zh"));
|
||||||
|
assert_eq!(resolve_supported_language("zh-Hant"), Some("zhtw"));
|
||||||
|
assert_eq!(resolve_supported_language("jp"), Some("jp"));
|
||||||
|
assert_eq!(resolve_supported_language("ja-JP"), Some("jp"));
|
||||||
|
assert_eq!(resolve_supported_language("fr"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ Thanks for helping localize Clash Verge Rev. This guide reflects the current arc
|
|||||||
## Quick workflow
|
## Quick workflow
|
||||||
|
|
||||||
- Update the language folder under `src/locales/<lang>/`; use `src/locales/en/` as the canonical reference for keys and intent.
|
- Update the language folder under `src/locales/<lang>/`; use `src/locales/en/` as the canonical reference for keys and intent.
|
||||||
- Run `pnpm format:i18n` to align structure and `pnpm i18n:types` to refresh generated typings.
|
- Run `pnpm i18n:format` to align structure (frontend JSON + backend YAML) and `pnpm i18n:types` to refresh generated typings.
|
||||||
- If you touch backend copy, edit the matching YAML file in `src-tauri/locales/<lang>.yml`.
|
- If you touch backend copy, edit the matching YAML file in `crates/clash-verge-i18n/locales/<lang>.yml`.
|
||||||
- Preview UI changes with `pnpm dev` (desktop shell) or `pnpm web:dev` (web only).
|
- Preview UI changes with `pnpm dev` (desktop shell) or `pnpm web:dev` (web only).
|
||||||
- Keep PRs focused and add screenshots whenever layout could be affected by text length.
|
- Keep PRs focused and add screenshots whenever layout could be affected by text length.
|
||||||
|
|
||||||
@@ -33,29 +33,29 @@ src/locales/
|
|||||||
|
|
||||||
Because backend translations now live in their own directory, you no longer need to run `pnpm prebuild` just to sync locales—the frontend folder is the sole source of truth for web bundles.
|
Because backend translations now live in their own directory, you no longer need to run `pnpm prebuild` just to sync locales—the frontend folder is the sole source of truth for web bundles.
|
||||||
|
|
||||||
## Tooling for frontend contributors
|
## Tooling for i18n contributors
|
||||||
|
|
||||||
- `pnpm format:i18n` → `node scripts/cleanup-unused-i18n.mjs --align --apply`. It aligns key ordering, removes unused entries, and keeps all locales in lock-step with English.
|
- `pnpm i18n:format` → `node scripts/cleanup-unused-i18n.mjs --align --apply`. It aligns key ordering, removes unused entries, and keeps all locales in lock-step with English across both JSON and YAML bundles.
|
||||||
- `pnpm node scripts/cleanup-unused-i18n.mjs` (without flags) performs a dry-run audit. Use it to inspect missing or extra keys before committing.
|
- `pnpm i18n:check` performs a dry-run audit of frontend and backend keys. It scans TS/TSX usage plus Rust `t!(...)` calls in `src-tauri/` and `crates/` to spot missing or extra entries.
|
||||||
- `pnpm i18n:types` regenerates `src/types/generated/i18n-keys.ts` and `src/types/generated/i18n-resources.ts`, ensuring TypeScript catches invalid key usage.
|
- `pnpm i18n:types` regenerates `src/types/generated/i18n-keys.ts` and `src/types/generated/i18n-resources.ts`, ensuring TypeScript catches invalid key usage.
|
||||||
- For dynamic keys that the analyzer cannot statically detect, add explicit references in code or update the script whitelist to avoid false positives.
|
- For dynamic keys that the analyzer cannot statically detect, add explicit references in code or update the script whitelist to avoid false positives.
|
||||||
|
|
||||||
## Backend (Tauri) locale bundles
|
## Backend (Tauri) locale bundles
|
||||||
|
|
||||||
Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `src-tauri/locales/<lang>.yml`. These files are completely independent from the frontend JSON modules.
|
Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `crates/clash-verge-i18n/locales/<lang>.yml`. These files are completely independent from the frontend JSON modules.
|
||||||
|
|
||||||
- Keep `en.yml` semantically aligned with the Simplified Chinese baseline (`zh.yml`). Other locales may temporarily copy English if no translation is available yet.
|
- Keep `en.yml` semantically aligned with the Simplified Chinese baseline (`zh.yml`). Other locales may temporarily copy English if no translation is available yet.
|
||||||
- When a backend feature introduces new strings, update every YAML file to keep the key set consistent. Missing keys fall back to the default language (`zh`), so catching gaps early avoids mixed-language output.
|
- When a backend feature introduces new strings, update every YAML file to keep the key set consistent. Missing keys fall back to the default language (`zh`), so catching gaps early avoids mixed-language output.
|
||||||
- Rust code resolves the active language through `src-tauri/src/utils/i18n.rs`. No additional build step is required after editing YAML files; `tauri dev` and `tauri build` pick them up automatically.
|
- The same `pnpm i18n:check` / `pnpm i18n:format` tooling now validates backend YAML keys against Rust usage, so run it after backend i18n edits.
|
||||||
|
- Rust code resolves the active language through the `clash-verge-i18n` crate (`crates/clash-verge-i18n/src/lib.rs`). No additional build step is required after editing YAML files; `tauri dev` and `tauri build` pick them up automatically.
|
||||||
|
|
||||||
## Adding a new language
|
## Adding a new language
|
||||||
|
|
||||||
1. Duplicate `src/locales/en/` into `src/locales/<new-lang>/` and translate the JSON files while preserving key structure.
|
1. Duplicate `src/locales/en/` into `src/locales/<new-lang>/` and translate the JSON files while preserving key structure.
|
||||||
2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports.
|
2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports.
|
||||||
3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
|
3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
|
||||||
4. If the backend should expose the language, create `src-tauri/locales/<new-lang>.yml` and translate the keys used in existing YAML files.
|
4. If the backend should expose the language, create `crates/clash-verge-i18n/<new-lang>.yml` and translate the keys used in existing YAML files.
|
||||||
5. Adjust `crowdin.yml` if the locale requires a special mapping for Crowdin.
|
5. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure.
|
||||||
6. Run `pnpm format:i18n`, `pnpm i18n:types`, and (optionally) `pnpm node scripts/cleanup-unused-i18n.mjs` in dry-run mode to confirm structure.
|
|
||||||
|
|
||||||
## Authoring guidelines
|
## Authoring guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
|
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
||||||
|
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
||||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,18 +4,23 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
import yaml from "js-yaml";
|
||||||
import ts from "typescript";
|
import ts from "typescript";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const LOCALES_DIR = path.resolve(__dirname, "../src/locales");
|
const FRONTEND_LOCALES_DIR = path.resolve(__dirname, "../src/locales");
|
||||||
const TAURI_LOCALES_DIR = path.resolve(__dirname, "../src-tauri/locales");
|
const BACKEND_LOCALES_DIR = path.resolve(
|
||||||
const DEFAULT_SOURCE_DIRS = [
|
__dirname,
|
||||||
path.resolve(__dirname, "../src"),
|
"../crates/clash-verge-i18n/locales",
|
||||||
|
);
|
||||||
|
const DEFAULT_FRONTEND_SOURCE_DIRS = [path.resolve(__dirname, "../src")];
|
||||||
|
const DEFAULT_BACKEND_SOURCE_DIRS = [
|
||||||
path.resolve(__dirname, "../src-tauri"),
|
path.resolve(__dirname, "../src-tauri"),
|
||||||
|
path.resolve(__dirname, "../crates"),
|
||||||
];
|
];
|
||||||
const EXCLUDE_USAGE_DIRS = [LOCALES_DIR, TAURI_LOCALES_DIR];
|
const EXCLUDE_USAGE_DIRS = [FRONTEND_LOCALES_DIR, BACKEND_LOCALES_DIR];
|
||||||
const DEFAULT_BASELINE_LANG = "en";
|
const DEFAULT_BASELINE_LANG = "en";
|
||||||
const IGNORE_DIR_NAMES = new Set([
|
const IGNORE_DIR_NAMES = new Set([
|
||||||
".git",
|
".git",
|
||||||
@@ -36,7 +41,7 @@ const IGNORE_DIR_NAMES = new Set([
|
|||||||
"logs",
|
"logs",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
]);
|
]);
|
||||||
const SUPPORTED_EXTENSIONS = new Set([
|
const FRONTEND_EXTENSIONS = new Set([
|
||||||
".ts",
|
".ts",
|
||||||
".tsx",
|
".tsx",
|
||||||
".js",
|
".js",
|
||||||
@@ -46,6 +51,7 @@ const SUPPORTED_EXTENSIONS = new Set([
|
|||||||
".vue",
|
".vue",
|
||||||
".json",
|
".json",
|
||||||
]);
|
]);
|
||||||
|
const BACKEND_EXTENSIONS = new Set([".rs"]);
|
||||||
|
|
||||||
const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
||||||
|
|
||||||
@@ -86,20 +92,25 @@ const WHITELIST_KEYS = new Set([
|
|||||||
"theme.light",
|
"theme.light",
|
||||||
"theme.dark",
|
"theme.dark",
|
||||||
"theme.system",
|
"theme.system",
|
||||||
"Already Using Latest Core Version",
|
"_version",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MAX_PREVIEW_ENTRIES = 40;
|
const MAX_PREVIEW_ENTRIES = 40;
|
||||||
const dynamicKeyCache = new Map();
|
const dynamicKeyCache = new Map();
|
||||||
const fileUsageCache = new Map();
|
const fileUsageCache = new Map();
|
||||||
|
|
||||||
|
function resetUsageCaches() {
|
||||||
|
dynamicKeyCache.clear();
|
||||||
|
fileUsageCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function printUsage() {
|
function printUsage() {
|
||||||
console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options]
|
console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--apply Write locale files with unused keys removed (default: report only)
|
--apply Write locale files with unused keys removed (default: report only)
|
||||||
--align Align locale structure/order using the baseline locale
|
--align Align locale structure/order using the baseline locale
|
||||||
--baseline <lang> Baseline locale file name (default: ${DEFAULT_BASELINE_LANG})
|
--baseline <lang> Baseline locale file name for frontend/backend (default: ${DEFAULT_BASELINE_LANG})
|
||||||
--keep-extra Preserve keys that exist only in non-baseline locales when aligning
|
--keep-extra Preserve keys that exist only in non-baseline locales when aligning
|
||||||
--no-backup Skip creating \`.bak\` backups when applying changes
|
--no-backup Skip creating \`.bak\` backups when applying changes
|
||||||
--report <path> Write a JSON report to the given path
|
--report <path> Write a JSON report to the given path
|
||||||
@@ -148,7 +159,7 @@ function parseArgs(argv) {
|
|||||||
if (!next) {
|
if (!next) {
|
||||||
throw new Error("--baseline requires a locale name (e.g. en)");
|
throw new Error("--baseline requires a locale name (e.g. en)");
|
||||||
}
|
}
|
||||||
options.baseline = next.replace(/\.json$/, "");
|
options.baseline = next.replace(/\.(json|ya?ml)$/i, "");
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -211,14 +222,16 @@ function getAllFiles(start, predicate) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectSourceFiles(sourceDirs) {
|
function collectSourceFiles(sourceDirs, options = {}) {
|
||||||
|
const supportedExtensions =
|
||||||
|
options.supportedExtensions ?? FRONTEND_EXTENSIONS;
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
for (const dir of sourceDirs) {
|
for (const dir of sourceDirs) {
|
||||||
const resolved = getAllFiles(dir, (filePath) => {
|
const resolved = getAllFiles(dir, (filePath) => {
|
||||||
if (seen.has(filePath)) return false;
|
if (seen.has(filePath)) return false;
|
||||||
if (!SUPPORTED_EXTENSIONS.has(path.extname(filePath))) return false;
|
if (!supportedExtensions.has(path.extname(filePath))) return false;
|
||||||
if (
|
if (
|
||||||
EXCLUDE_USAGE_DIRS.some((excluded) =>
|
EXCLUDE_USAGE_DIRS.some((excluded) =>
|
||||||
filePath.startsWith(`${excluded}${path.sep}`),
|
filePath.startsWith(`${excluded}${path.sep}`),
|
||||||
@@ -673,6 +686,45 @@ function collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readRustStringLiteral(source, startIndex) {
|
||||||
|
const slice = source.slice(startIndex);
|
||||||
|
if (slice.startsWith('"')) {
|
||||||
|
const match = slice.match(/^"(?:\\.|[^"\\])*"/);
|
||||||
|
if (!match) return null;
|
||||||
|
return match[0].slice(1, -1);
|
||||||
|
}
|
||||||
|
if (slice.startsWith("r")) {
|
||||||
|
const match = slice.match(/^r(#+)?"([\s\S]*?)"\1/);
|
||||||
|
if (!match) return null;
|
||||||
|
return match[2];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUsedKeysFromRustFile(
|
||||||
|
file,
|
||||||
|
baselineNamespaces,
|
||||||
|
usedKeys,
|
||||||
|
_dynamicPrefixes,
|
||||||
|
) {
|
||||||
|
const pattern = /\b(?:[A-Za-z_][\w:]*::)?t!\s*\(/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(file.content))) {
|
||||||
|
let index = match.index + match[0].length;
|
||||||
|
while (index < file.content.length && /\s/.test(file.content[index])) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
const key = readRustStringLiteral(file.content, index);
|
||||||
|
if (key) {
|
||||||
|
addKeyIfValid(key, usedKeys, baselineNamespaces, {
|
||||||
|
forceNamespace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
function collectUsedI18nKeys(sourceFiles, baselineNamespaces) {
|
function collectUsedI18nKeys(sourceFiles, baselineNamespaces) {
|
||||||
const usedKeys = new Set();
|
const usedKeys = new Set();
|
||||||
const dynamicPrefixes = new Set();
|
const dynamicPrefixes = new Set();
|
||||||
@@ -685,6 +737,13 @@ function collectUsedI18nKeys(sourceFiles, baselineNamespaces) {
|
|||||||
usedKeys,
|
usedKeys,
|
||||||
dynamicPrefixes,
|
dynamicPrefixes,
|
||||||
);
|
);
|
||||||
|
} else if (file.extension === ".rs") {
|
||||||
|
collectUsedKeysFromRustFile(
|
||||||
|
file,
|
||||||
|
baselineNamespaces,
|
||||||
|
usedKeys,
|
||||||
|
dynamicPrefixes,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys);
|
collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys);
|
||||||
}
|
}
|
||||||
@@ -864,12 +923,16 @@ function writeReport(reportPath, data) {
|
|||||||
fs.writeFileSync(reportPath, `${payload}\n`, "utf8");
|
fs.writeFileSync(reportPath, `${payload}\n`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLocales() {
|
function isPlainObject(value) {
|
||||||
if (!fs.existsSync(LOCALES_DIR)) {
|
return value && typeof value === "object" && !Array.isArray(value);
|
||||||
throw new Error(`Locales directory not found: ${LOCALES_DIR}`);
|
}
|
||||||
|
|
||||||
|
function loadFrontendLocales() {
|
||||||
|
if (!fs.existsSync(FRONTEND_LOCALES_DIR)) {
|
||||||
|
throw new Error(`Locales directory not found: ${FRONTEND_LOCALES_DIR}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true });
|
const entries = fs.readdirSync(FRONTEND_LOCALES_DIR, { withFileTypes: true });
|
||||||
const locales = [];
|
const locales = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -879,12 +942,12 @@ function loadLocales() {
|
|||||||
!entry.name.endsWith(".bak") &&
|
!entry.name.endsWith(".bak") &&
|
||||||
!entry.name.endsWith(".old")
|
!entry.name.endsWith(".old")
|
||||||
) {
|
) {
|
||||||
const localePath = path.join(LOCALES_DIR, entry.name);
|
const localePath = path.join(FRONTEND_LOCALES_DIR, entry.name);
|
||||||
const name = path.basename(entry.name, ".json");
|
const name = path.basename(entry.name, ".json");
|
||||||
const raw = fs.readFileSync(localePath, "utf8");
|
const raw = fs.readFileSync(localePath, "utf8");
|
||||||
locales.push({
|
locales.push({
|
||||||
name,
|
name,
|
||||||
dir: LOCALES_DIR,
|
dir: FRONTEND_LOCALES_DIR,
|
||||||
format: "single-file",
|
format: "single-file",
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
@@ -901,7 +964,7 @@ function loadLocales() {
|
|||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
if (entry.name.startsWith(".")) continue;
|
if (entry.name.startsWith(".")) continue;
|
||||||
|
|
||||||
const localeDir = path.join(LOCALES_DIR, entry.name);
|
const localeDir = path.join(FRONTEND_LOCALES_DIR, entry.name);
|
||||||
const namespaceEntries = fs
|
const namespaceEntries = fs
|
||||||
.readdirSync(localeDir, { withFileTypes: true })
|
.readdirSync(localeDir, { withFileTypes: true })
|
||||||
.filter(
|
.filter(
|
||||||
@@ -942,6 +1005,51 @@ function loadLocales() {
|
|||||||
return locales;
|
return locales;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadBackendLocales() {
|
||||||
|
if (!fs.existsSync(BACKEND_LOCALES_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(BACKEND_LOCALES_DIR, { withFileTypes: true });
|
||||||
|
const locales = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
if (entry.name.endsWith(".bak") || entry.name.endsWith(".old")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/\.(ya?ml)$/i.test(entry.name)) continue;
|
||||||
|
|
||||||
|
const localePath = path.join(BACKEND_LOCALES_DIR, entry.name);
|
||||||
|
const name = entry.name.replace(/\.(ya?ml)$/i, "");
|
||||||
|
const raw = fs.readFileSync(localePath, "utf8");
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
const parsed = yaml.load(raw);
|
||||||
|
data = isPlainObject(parsed) ? parsed : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Warning: failed to parse ${localePath}: ${error.message}`);
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
locales.push({
|
||||||
|
name,
|
||||||
|
dir: BACKEND_LOCALES_DIR,
|
||||||
|
format: "yaml-file",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
namespace: "translation",
|
||||||
|
path: localePath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
locales.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return locales;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureBackup(localePath) {
|
function ensureBackup(localePath) {
|
||||||
const backupPath = `${localePath}.bak`;
|
const backupPath = `${localePath}.bak`;
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(backupPath)) {
|
||||||
@@ -1042,6 +1150,15 @@ function writeLocale(locale, data, options) {
|
|||||||
let success = false;
|
let success = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (locale.format === "yaml-file") {
|
||||||
|
const target = locale.files[0].path;
|
||||||
|
backupIfNeeded(target, backups, options);
|
||||||
|
const serialized = yaml.dump(data ?? {}, { lineWidth: -1, noRefs: true });
|
||||||
|
fs.writeFileSync(target, `${serialized.trimEnd()}\n`, "utf8");
|
||||||
|
success = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (locale.format === "single-file") {
|
if (locale.format === "single-file") {
|
||||||
const target = locale.files[0].path;
|
const target = locale.files[0].path;
|
||||||
backupIfNeeded(target, backups, options);
|
backupIfNeeded(target, backups, options);
|
||||||
@@ -1097,6 +1214,8 @@ function processLocale(
|
|||||||
sourceFiles,
|
sourceFiles,
|
||||||
missingFromSource,
|
missingFromSource,
|
||||||
options,
|
options,
|
||||||
|
groupName,
|
||||||
|
baselineName,
|
||||||
) {
|
) {
|
||||||
const data = JSON.parse(JSON.stringify(locale.data));
|
const data = JSON.parse(JSON.stringify(locale.data));
|
||||||
const flattened = flattenLocale(data);
|
const flattened = flattenLocale(data);
|
||||||
@@ -1112,7 +1231,7 @@ function processLocale(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sourceMissing =
|
const sourceMissing =
|
||||||
locale.name === options.baseline
|
locale.name === baselineName
|
||||||
? missingFromSource.filter((key) => !flattened.has(key))
|
? missingFromSource.filter((key) => !flattened.has(key))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -1165,8 +1284,9 @@ function processLocale(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
group: groupName,
|
||||||
locale: locale.name,
|
locale: locale.name,
|
||||||
file: locale.format === "single-file" ? locale.files[0].path : locale.dir,
|
file: locale.format === "multi-file" ? locale.dir : locale.files[0].path,
|
||||||
totalKeys: flattened.size,
|
totalKeys: flattened.size,
|
||||||
expectedKeys: expectedTotal,
|
expectedKeys: expectedTotal,
|
||||||
unusedKeys: unused,
|
unusedKeys: unused,
|
||||||
@@ -1178,34 +1298,42 @@ function processLocale(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function summarizeResults(results) {
|
||||||
const argv = process.argv.slice(2);
|
return results.reduce(
|
||||||
|
(totals, result) => {
|
||||||
let options;
|
totals.totalUnused += result.unusedKeys.length;
|
||||||
try {
|
totals.totalMissing += result.missingKeys.length;
|
||||||
options = parseArgs(argv);
|
totals.totalExtra += result.extraKeys.length;
|
||||||
} catch (error) {
|
totals.totalSourceMissing += result.missingSourceKeys.length;
|
||||||
console.error(`Error: ${error.message}`);
|
return totals;
|
||||||
console.log();
|
},
|
||||||
printUsage();
|
{
|
||||||
process.exit(1);
|
totalUnused: 0,
|
||||||
}
|
totalMissing: 0,
|
||||||
|
totalExtra: 0,
|
||||||
|
totalSourceMissing: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processLocaleGroup(group, options) {
|
||||||
const sourceDirs = [
|
const sourceDirs = [
|
||||||
...new Set([...DEFAULT_SOURCE_DIRS, ...options.extraSources]),
|
...new Set([...group.sourceDirs, ...options.extraSources]),
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log("Scanning source directories:");
|
console.log(`\n[${group.label}] Scanning source directories:`);
|
||||||
for (const dir of sourceDirs) {
|
for (const dir of sourceDirs) {
|
||||||
console.log(` - ${dir}`);
|
console.log(` - ${dir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceFiles = collectSourceFiles(sourceDirs);
|
const sourceFiles = collectSourceFiles(sourceDirs, {
|
||||||
const locales = loadLocales();
|
supportedExtensions: group.supportedExtensions,
|
||||||
|
});
|
||||||
|
const locales = group.locales;
|
||||||
|
|
||||||
if (locales.length === 0) {
|
if (locales.length === 0) {
|
||||||
console.log("No locale files found.");
|
console.log(`[${group.label}] No locale files found.`);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baselineLocale = locales.find(
|
const baselineLocale = locales.find(
|
||||||
@@ -1215,7 +1343,7 @@ function main() {
|
|||||||
if (!baselineLocale) {
|
if (!baselineLocale) {
|
||||||
const available = locales.map((item) => item.name).join(", ");
|
const available = locales.map((item) => item.name).join(", ");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Baseline locale "${options.baseline}" not found. Available locales: ${available}`,
|
`[${group.label}] Baseline locale "${options.baseline}" not found. Available locales: ${available}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,8 +1363,11 @@ function main() {
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\nChecking ${locales.length} locale files...\n`);
|
console.log(
|
||||||
|
`\n[${group.label}] Checking ${locales.length} locale files...\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
resetUsageCaches();
|
||||||
const results = locales.map((locale) =>
|
const results = locales.map((locale) =>
|
||||||
processLocale(
|
processLocale(
|
||||||
locale,
|
locale,
|
||||||
@@ -1246,35 +1377,85 @@ function main() {
|
|||||||
sourceFiles,
|
sourceFiles,
|
||||||
missingFromSource,
|
missingFromSource,
|
||||||
options,
|
options,
|
||||||
|
group.label,
|
||||||
|
baselineLocale.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalUnused = results.reduce(
|
const totals = summarizeResults(results);
|
||||||
(count, result) => count + result.unusedKeys.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const totalMissing = results.reduce(
|
|
||||||
(count, result) => count + result.missingKeys.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const totalExtra = results.reduce(
|
|
||||||
(count, result) => count + result.extraKeys.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const totalSourceMissing = results.reduce(
|
|
||||||
(count, result) => count + result.missingSourceKeys.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("\nSummary:");
|
console.log(`\n[${group.label}] Summary:`);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
console.log(
|
console.log(
|
||||||
` • ${result.locale}: unused=${result.unusedKeys.length}, missing=${result.missingKeys.length}, extra=${result.extraKeys.length}, missingSource=${result.missingSourceKeys.length}, total=${result.totalKeys}, expected=${result.expectedKeys}`,
|
` • ${result.locale}: unused=${result.unusedKeys.length}, missing=${result.missingKeys.length}, extra=${result.extraKeys.length}, missingSource=${result.missingSourceKeys.length}, total=${result.totalKeys}, expected=${result.expectedKeys}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`\nTotals → unused: ${totalUnused}, missing: ${totalMissing}, extra: ${totalExtra}, missingSource: ${totalSourceMissing}`,
|
`\n[${group.label}] Totals → unused: ${totals.totalUnused}, missing: ${totals.totalMissing}, extra: ${totals.totalExtra}, missingSource: ${totals.totalSourceMissing}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
group: group.label,
|
||||||
|
baseline: baselineLocale.name,
|
||||||
|
sourceDirs,
|
||||||
|
totals,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
|
||||||
|
let options;
|
||||||
|
try {
|
||||||
|
options = parseArgs(argv);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
console.log();
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeGroups = [
|
||||||
|
{
|
||||||
|
label: "frontend",
|
||||||
|
locales: loadFrontendLocales(),
|
||||||
|
sourceDirs: DEFAULT_FRONTEND_SOURCE_DIRS,
|
||||||
|
supportedExtensions: FRONTEND_EXTENSIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "backend",
|
||||||
|
locales: loadBackendLocales(),
|
||||||
|
sourceDirs: DEFAULT_BACKEND_SOURCE_DIRS,
|
||||||
|
supportedExtensions: BACKEND_EXTENSIONS,
|
||||||
|
},
|
||||||
|
].filter((group) => group.locales.length > 0);
|
||||||
|
|
||||||
|
if (localeGroups.length === 0) {
|
||||||
|
console.log("No locale files found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupReports = [];
|
||||||
|
const allResults = [];
|
||||||
|
|
||||||
|
for (const group of localeGroups) {
|
||||||
|
const report = processLocaleGroup(group, options);
|
||||||
|
if (!report) continue;
|
||||||
|
groupReports.push(report);
|
||||||
|
allResults.push(...report.results);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupReports.length > 1) {
|
||||||
|
const overallTotals = summarizeResults(allResults);
|
||||||
|
console.log(
|
||||||
|
`\nOverall totals → unused: ${overallTotals.totalUnused}, missing: ${overallTotals.totalMissing}, extra: ${overallTotals.totalExtra}, missingSource: ${overallTotals.totalSourceMissing}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allResults.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (options.apply) {
|
if (options.apply) {
|
||||||
console.log(
|
console.log(
|
||||||
"Files were updated in-place; review diffs before committing changes.",
|
"Files were updated in-place; review diffs before committing changes.",
|
||||||
@@ -1301,11 +1482,17 @@ function main() {
|
|||||||
apply: options.apply,
|
apply: options.apply,
|
||||||
backup: options.backup,
|
backup: options.backup,
|
||||||
align: options.align,
|
align: options.align,
|
||||||
baseline: baselineLocale.name,
|
baseline: options.baseline,
|
||||||
keepExtra: options.keepExtra,
|
keepExtra: options.keepExtra,
|
||||||
sourceDirs,
|
|
||||||
},
|
},
|
||||||
results,
|
groups: groupReports.map((report) => ({
|
||||||
|
group: report.group,
|
||||||
|
baseline: report.baseline,
|
||||||
|
sourceDirs: report.sourceDirs,
|
||||||
|
totals: report.totals,
|
||||||
|
locales: report.results.map((result) => result.locale),
|
||||||
|
})),
|
||||||
|
results: allResults,
|
||||||
};
|
};
|
||||||
writeReport(options.reportPath, payload);
|
writeReport(options.reportPath, payload);
|
||||||
console.log(`Report written to ${options.reportPath}`);
|
console.log(`Report written to ${options.reportPath}`);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ clash-verge-draft = { workspace = true }
|
|||||||
clash-verge-logging = { workspace = true }
|
clash-verge-logging = { workspace = true }
|
||||||
clash-verge-signal = { workspace = true }
|
clash-verge-signal = { workspace = true }
|
||||||
clash-verge-types = { workspace = true }
|
clash-verge-types = { workspace = true }
|
||||||
|
clash-verge-i18n = { workspace = true }
|
||||||
tauri-plugin-clash-verge-sysinfo = { workspace = true }
|
tauri-plugin-clash-verge-sysinfo = { workspace = true }
|
||||||
tauri-plugin-clipboard-manager = { workspace = true }
|
tauri-plugin-clipboard-manager = { workspace = true }
|
||||||
tauri = { workspace = true, features = [
|
tauri = { workspace = true, features = [
|
||||||
@@ -81,7 +82,6 @@ aes-gcm = { version = "0.10.3", features = ["std"] }
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
getrandom = "0.3.4"
|
getrandom = "0.3.4"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
sys-locale = "0.3.2"
|
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
@@ -97,7 +97,6 @@ clash_verge_service_ipc = { version = "2.0.26", features = [
|
|||||||
"client",
|
"client",
|
||||||
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
|
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
|
||||||
arc-swap = "1.7.1"
|
arc-swap = "1.7.1"
|
||||||
rust-i18n = "3.1.5"
|
|
||||||
rust_iso3166 = "0.1.14"
|
rust_iso3166 = "0.1.14"
|
||||||
dark-light = "2.0.0"
|
dark-light = "2.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ impl Config {
|
|||||||
pub async fn init_config() -> Result<()> {
|
pub async fn init_config() -> Result<()> {
|
||||||
Self::ensure_default_profile_items().await?;
|
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
|
// init Tun mode
|
||||||
let handle = Handle::app_handle();
|
let handle = Handle::app_handle();
|
||||||
let is_admin = is_current_app_handle_admin(handle);
|
let is_admin = is_current_app_handle_admin(handle);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
|
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
|
||||||
utils::{dirs, help, i18n},
|
utils::{dirs, help},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
@@ -346,19 +346,6 @@ impl IVerge {
|
|||||||
self.clash_core.clone().unwrap_or_else(|| "verge-mihomo".into())
|
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 {
|
pub async fn new() -> Self {
|
||||||
match dirs::verge_path() {
|
match dirs::verge_path() {
|
||||||
Ok(path) => match help::read_yaml::<Self>(&path).await {
|
Ok(path) => match help::read_yaml::<Self>(&path).await {
|
||||||
@@ -388,7 +375,7 @@ impl IVerge {
|
|||||||
app_log_max_size: Some(128),
|
app_log_max_size: Some(128),
|
||||||
app_log_max_count: Some(8),
|
app_log_max_count: Some(8),
|
||||||
clash_core: Some("verge-mihomo".into()),
|
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()),
|
theme_mode: Some("system".into()),
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
env_type: Some("bash".into()),
|
env_type: Some("bash".into()),
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ pub enum ServiceStatus {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ServiceManager(ServiceStatus);
|
pub struct ServiceManager(ServiceStatus);
|
||||||
|
|
||||||
#[allow(clippy::unused_async)]
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
async fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
logging!(info, Type::Service, "uninstall service");
|
||||||
|
|
||||||
use deelevate::{PrivilegeLevel, Token};
|
use deelevate::{PrivilegeLevel, Token};
|
||||||
@@ -63,9 +62,8 @@ async fn uninstall_service() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::unused_async)]
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
async fn install_service() -> Result<()> {
|
fn install_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "install service");
|
logging!(info, Type::Service, "install service");
|
||||||
|
|
||||||
use deelevate::{PrivilegeLevel, Token};
|
use deelevate::{PrivilegeLevel, Token};
|
||||||
@@ -93,27 +91,8 @@ async fn install_service() -> Result<()> {
|
|||||||
Ok(())
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
async fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
logging!(info, Type::Service, "uninstall service");
|
||||||
|
|
||||||
let uninstall_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall");
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
#[allow(clippy::unused_async)]
|
fn install_service() -> Result<()> {
|
||||||
async fn install_service() -> Result<()> {
|
|
||||||
logging!(info, Type::Service, "install service");
|
logging!(info, Type::Service, "install service");
|
||||||
|
|
||||||
let install_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install");
|
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(())
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn linux_running_as_root() -> bool {
|
fn linux_running_as_root() -> bool {
|
||||||
use crate::core::handle;
|
use crate::core::handle;
|
||||||
@@ -249,7 +209,7 @@ fn linux_running_as_root() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
async fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
logging!(info, Type::Service, "uninstall service");
|
||||||
|
|
||||||
let binary_path = dirs::service_path()?;
|
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();
|
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 =
|
let command =
|
||||||
format!(r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#);
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
async fn install_service() -> Result<()> {
|
fn install_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "install service");
|
logging!(info, Type::Service, "install service");
|
||||||
|
|
||||||
let binary_path = dirs::service_path()?;
|
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();
|
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 =
|
let command =
|
||||||
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
|
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
|
||||||
|
|
||||||
@@ -309,17 +269,16 @@ async fn install_service() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
fn reinstall_service() -> Result<()> {
|
||||||
async fn reinstall_service() -> Result<()> {
|
|
||||||
logging!(info, Type::Service, "reinstall service");
|
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);
|
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再安装服务
|
// 再安装服务
|
||||||
match install_service().await {
|
match install_service() {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
bail!(format!("failed to install service: {err}"))
|
bail!(format!("failed to install service: {err}"))
|
||||||
@@ -328,9 +287,9 @@ async fn reinstall_service() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 强制重装服务(UI修复按钮)
|
/// 强制重装服务(UI修复按钮)
|
||||||
async fn force_reinstall_service() -> Result<()> {
|
fn force_reinstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "用户请求强制重装服务");
|
logging!(info, Type::Service, "用户请求强制重装服务");
|
||||||
reinstall_service().await.map_err(|err| {
|
reinstall_service().map_err(|err| {
|
||||||
logging!(error, Type::Service, "强制重装服务失败: {}", err);
|
logging!(error, Type::Service, "强制重装服务失败: {}", err);
|
||||||
err
|
err
|
||||||
})
|
})
|
||||||
@@ -550,22 +509,22 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => {
|
ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => {
|
||||||
logging!(info, Type::Service, "服务需要重装,执行重装流程");
|
logging!(info, Type::Service, "服务需要重装,执行重装流程");
|
||||||
reinstall_service().await?;
|
reinstall_service()?;
|
||||||
wait_and_check_service_available(self).await?;
|
wait_and_check_service_available(self).await?;
|
||||||
}
|
}
|
||||||
ServiceStatus::ForceReinstallRequired => {
|
ServiceStatus::ForceReinstallRequired => {
|
||||||
logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程");
|
logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程");
|
||||||
force_reinstall_service().await?;
|
force_reinstall_service()?;
|
||||||
wait_and_check_service_available(self).await?;
|
wait_and_check_service_available(self).await?;
|
||||||
}
|
}
|
||||||
ServiceStatus::InstallRequired => {
|
ServiceStatus::InstallRequired => {
|
||||||
logging!(info, Type::Service, "需要安装服务,执行安装流程");
|
logging!(info, Type::Service, "需要安装服务,执行安装流程");
|
||||||
install_service().await?;
|
install_service()?;
|
||||||
wait_and_check_service_available(self).await?;
|
wait_and_check_service_available(self).await?;
|
||||||
}
|
}
|
||||||
ServiceStatus::UninstallRequired => {
|
ServiceStatus::UninstallRequired => {
|
||||||
logging!(info, Type::Service, "服务需要卸载,执行卸载流程");
|
logging!(info, Type::Service, "服务需要卸载,执行卸载流程");
|
||||||
uninstall_service().await?;
|
uninstall_service()?;
|
||||||
self.0 = ServiceStatus::Unavailable("Service Uninstalled".into());
|
self.0 = ServiceStatus::Unavailable("Service Uninstalled".into());
|
||||||
}
|
}
|
||||||
ServiceStatus::Unavailable(reason) => {
|
ServiceStatus::Unavailable(reason) => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use rust_i18n::t;
|
use clash_verge_i18n::t;
|
||||||
use std::{borrow::Cow, sync::Arc};
|
use std::{borrow::Cow, sync::Arc};
|
||||||
|
|
||||||
fn to_arc_str(value: Cow<'static, str>) -> Arc<str> {
|
fn to_arc_str<S>(value: S) -> Arc<str>
|
||||||
match value {
|
where
|
||||||
|
S: Into<Cow<'static, str>>,
|
||||||
|
{
|
||||||
|
match value.into() {
|
||||||
Cow::Borrowed(s) => Arc::from(s),
|
Cow::Borrowed(s) => Arc::from(s),
|
||||||
Cow::Owned(s) => Arc::from(s.into_boxed_str()),
|
Cow::Owned(s) => Arc::from(s.into_boxed_str()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ use crate::process::AsyncHandler;
|
|||||||
use crate::singleton;
|
use crate::singleton;
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
Type, cmd,
|
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
|
||||||
config::Config,
|
utils::dirs::find_target_icons,
|
||||||
feat, logging,
|
|
||||||
module::lightweight::is_in_lightweight_mode,
|
|
||||||
utils::{dirs::find_target_icons, i18n},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::handle;
|
use super::handle;
|
||||||
@@ -389,8 +386,6 @@ impl Tray {
|
|||||||
|
|
||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
|
|
||||||
i18n::sync_locale().await;
|
|
||||||
|
|
||||||
let verge = Config::verge().await.latest_arc();
|
let verge = Config::verge().await.latest_arc();
|
||||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||||
let tun_mode = verge.enable_tun_mode.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
|
// Get localized strings before using them
|
||||||
let sys_proxy_text = rust_i18n::t!("tray.tooltip.systemProxy");
|
let sys_proxy_text = clash_verge_i18n::t!("tray.tooltip.systemProxy");
|
||||||
let tun_text = rust_i18n::t!("tray.tooltip.tun");
|
let tun_text = clash_verge_i18n::t!("tray.tooltip.tun");
|
||||||
let profile_text = rust_i18n::t!("tray.tooltip.profile");
|
let profile_text = clash_verge_i18n::t!("tray.tooltip.profile");
|
||||||
|
|
||||||
let v = env!("CARGO_PKG_VERSION");
|
let v = env!("CARGO_PKG_VERSION");
|
||||||
let reassembled_version = v.split_once('+').map_or_else(
|
let reassembled_version = v.split_once('+').map_or_else(
|
||||||
@@ -724,8 +719,6 @@ async fn create_tray_menu(
|
|||||||
) -> Result<tauri::menu::Menu<Wry>> {
|
) -> Result<tauri::menu::Menu<Wry>> {
|
||||||
let current_proxy_mode = mode.unwrap_or("");
|
let current_proxy_mode = mode.unwrap_or("");
|
||||||
|
|
||||||
i18n::sync_locale().await;
|
|
||||||
|
|
||||||
// TODO: should update tray menu again when it was timeout error
|
// TODO: should update tray menu again when it was timeout error
|
||||||
let proxy_nodes_data = tokio::time::timeout(
|
let proxy_nodes_data = tokio::time::timeout(
|
||||||
Duration::from_millis(1000),
|
Duration::from_millis(1000),
|
||||||
@@ -829,9 +822,9 @@ async fn create_tray_menu(
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let current_mode_text = match current_proxy_mode {
|
let current_mode_text = match current_proxy_mode {
|
||||||
"global" => rust_i18n::t!("tray.global"),
|
"global" => clash_verge_i18n::t!("tray.global"),
|
||||||
"direct" => rust_i18n::t!("tray.direct"),
|
"direct" => clash_verge_i18n::t!("tray.direct"),
|
||||||
_ => rust_i18n::t!("tray.rule"),
|
_ => clash_verge_i18n::t!("tray.rule"),
|
||||||
};
|
};
|
||||||
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
|
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
|
||||||
Some(Submenu::with_id_and_items(
|
Some(Submenu::with_id_and_items(
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ enum UpdateFlags {
|
|||||||
SystrayTooltip = 1 << 8,
|
SystrayTooltip = 1 << 8,
|
||||||
SystrayClickBehavior = 1 << 9,
|
SystrayClickBehavior = 1 << 9,
|
||||||
LighteWeight = 1 << 10,
|
LighteWeight = 1 << 10,
|
||||||
|
Language = 1 << 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn determine_update_flags(patch: &IVerge) -> i32 {
|
fn determine_update_flags(patch: &IVerge) -> i32 {
|
||||||
@@ -153,7 +154,9 @@ fn determine_update_flags(patch: &IVerge) -> i32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if language.is_some() {
|
if language.is_some() {
|
||||||
|
update_flags |= UpdateFlags::Language as i32;
|
||||||
update_flags |= UpdateFlags::SystrayMenu as i32;
|
update_flags |= UpdateFlags::SystrayMenu as i32;
|
||||||
|
update_flags |= UpdateFlags::SystrayTooltip as i32;
|
||||||
}
|
}
|
||||||
if common_tray_icon.is_some()
|
if common_tray_icon.is_some()
|
||||||
|| sysproxy_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 {
|
if (update_flags & (UpdateFlags::Launch as i32)) != 0 {
|
||||||
sysopt::Sysopt::global().update_launch().await?;
|
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 {
|
if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 {
|
||||||
sysopt::Sysopt::global().update_sysproxy().await?;
|
sysopt::Sysopt::global().update_sysproxy().await?;
|
||||||
sysopt::Sysopt::global().refresh_guard().await;
|
sysopt::Sysopt::global().refresh_guard().await;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use crate::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use rust_i18n::i18n;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Manager as _};
|
use tauri::{AppHandle, Manager as _};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -27,8 +26,6 @@ use tauri_plugin_autostart::MacosLauncher;
|
|||||||
use tauri_plugin_deep_link::DeepLinkExt as _;
|
use tauri_plugin_deep_link::DeepLinkExt as _;
|
||||||
use tauri_plugin_mihomo::RejectPolicy;
|
use tauri_plugin_mihomo::RejectPolicy;
|
||||||
|
|
||||||
i18n!("locales", fallback = "zh");
|
|
||||||
|
|
||||||
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
|
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
|
||||||
/// Application initialization helper functions
|
/// Application initialization helper functions
|
||||||
mod app_init {
|
mod app_init {
|
||||||
|
|||||||
@@ -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,6 +1,5 @@
|
|||||||
pub mod dirs;
|
pub mod dirs;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod i18n;
|
|
||||||
pub mod init;
|
pub mod init;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod linux;
|
pub mod linux;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::{core::handle, utils::i18n};
|
use crate::core::handle;
|
||||||
|
use clash_verge_i18n;
|
||||||
use tauri_plugin_notification::NotificationExt as _;
|
use tauri_plugin_notification::NotificationExt as _;
|
||||||
|
|
||||||
pub enum NotificationEvent<'a> {
|
pub enum NotificationEvent<'a> {
|
||||||
@@ -21,48 +22,49 @@ fn notify(title: &str, body: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
|
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 {
|
match event {
|
||||||
NotificationEvent::DashboardToggled => {
|
NotificationEvent::DashboardToggled => {
|
||||||
let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.dashboardToggled.title");
|
||||||
let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.dashboardToggled.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
NotificationEvent::ClashModeChanged { mode } => {
|
NotificationEvent::ClashModeChanged { mode } => {
|
||||||
let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.clashModeChanged.title");
|
||||||
let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
|
let body = clash_verge_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
NotificationEvent::SystemProxyToggled => {
|
NotificationEvent::SystemProxyToggled => {
|
||||||
let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.systemProxyToggled.title");
|
||||||
let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.systemProxyToggled.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
NotificationEvent::TunModeToggled => {
|
NotificationEvent::TunModeToggled => {
|
||||||
let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.tunModeToggled.title");
|
||||||
let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.tunModeToggled.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
NotificationEvent::LightweightModeEntered => {
|
NotificationEvent::LightweightModeEntered => {
|
||||||
let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.lightweightModeEntered.title");
|
||||||
let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.lightweightModeEntered.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
NotificationEvent::ProfilesReactivated => {
|
NotificationEvent::ProfilesReactivated => {
|
||||||
let title = rust_i18n::t!("notifications.profilesReactivated.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.profilesReactivated.title");
|
||||||
let body = rust_i18n::t!("notifications.profilesReactivated.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.profilesReactivated.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
NotificationEvent::AppQuit => {
|
NotificationEvent::AppQuit => {
|
||||||
let title = rust_i18n::t!("notifications.appQuit.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.appQuit.title");
|
||||||
let body = rust_i18n::t!("notifications.appQuit.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.appQuit.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
NotificationEvent::AppHidden => {
|
NotificationEvent::AppHidden => {
|
||||||
let title = rust_i18n::t!("notifications.appHidden.title").to_string();
|
let title = clash_verge_i18n::t!("notifications.appHidden.title");
|
||||||
let body = rust_i18n::t!("notifications.appHidden.body").to_string();
|
let body = clash_verge_i18n::t!("notifications.appHidden.body");
|
||||||
notify(&title, &body);
|
notify(&title, &body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user