From c8aeae3f83068bfd1b0a260c3c0b5901577acc70 Mon Sep 17 00:00:00 2001 From: Sline Date: Sat, 27 Dec 2025 15:03:19 +0800 Subject: [PATCH] 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> --- Cargo.lock | 11 +- Cargo.toml | 22 +- crates/clash-verge-i18n/Cargo.toml | 11 + .../clash-verge-i18n}/locales/ar.yml | 3 +- .../clash-verge-i18n}/locales/de.yml | 3 +- .../clash-verge-i18n}/locales/en.yml | 5 +- .../clash-verge-i18n}/locales/es.yml | 3 +- .../clash-verge-i18n}/locales/fa.yml | 3 +- .../clash-verge-i18n}/locales/id.yml | 3 +- .../clash-verge-i18n}/locales/jp.yml | 3 +- .../clash-verge-i18n}/locales/ko.yml | 3 +- .../clash-verge-i18n}/locales/ru.yml | 3 +- .../clash-verge-i18n}/locales/tr.yml | 3 +- .../clash-verge-i18n}/locales/tt.yml | 3 +- .../clash-verge-i18n}/locales/zh.yml | 0 .../clash-verge-i18n}/locales/zhtw.yml | 4 +- crates/clash-verge-i18n/src/lib.rs | 103 ++++++ docs/CONTRIBUTING_i18n.md | 20 +- package.json | 3 +- scripts/cleanup-unused-i18n.mjs | 309 ++++++++++++++---- src-tauri/Cargo.toml | 3 +- src-tauri/src/config/config.rs | 3 + src-tauri/src/config/verge.rs | 17 +- src-tauri/src/core/service.rs | 79 ++--- src-tauri/src/core/tray/menu_def.rs | 9 +- src-tauri/src/core/tray/mod.rs | 23 +- src-tauri/src/feat/config.rs | 8 + src-tauri/src/lib.rs | 3 - src-tauri/src/utils/i18n.rs | 96 ------ src-tauri/src/utils/mod.rs | 1 - src-tauri/src/utils/notification.rs | 38 ++- 31 files changed, 486 insertions(+), 312 deletions(-) create mode 100644 crates/clash-verge-i18n/Cargo.toml rename {src-tauri => crates/clash-verge-i18n}/locales/ar.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/de.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/en.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/es.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/fa.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/id.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/jp.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/ko.yml (89%) rename {src-tauri => crates/clash-verge-i18n}/locales/ru.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/tr.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/tt.yml (88%) rename {src-tauri => crates/clash-verge-i18n}/locales/zh.yml (100%) rename {src-tauri => crates/clash-verge-i18n}/locales/zhtw.yml (91%) create mode 100644 crates/clash-verge-i18n/src/lib.rs delete mode 100644 src-tauri/src/utils/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 034158d9d..e9f4e447c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,7 @@ dependencies = [ "boa_engine", "chrono", "clash-verge-draft", + "clash-verge-i18n", "clash-verge-logging", "clash-verge-signal", "clash-verge-types", @@ -1224,14 +1225,12 @@ dependencies = [ "reqwest", "reqwest_dav", "runas", - "rust-i18n", "rust_iso3166", "scopeguard", "serde", "serde_json", "serde_yaml_ng", "smartstring", - "sys-locale", "sysproxy", "tauri", "tauri-build", @@ -1268,6 +1267,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "clash-verge-i18n" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "sys-locale", +] + [[package]] name = "clash-verge-logging" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 812b14fa0..e3b1dc86d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,12 @@ [workspace] members = [ - "src-tauri", - "crates/clash-verge-draft", - "crates/clash-verge-logging", - "crates/clash-verge-signal", - "crates/tauri-plugin-clash-verge-sysinfo", - "crates/clash-verge-types", + "src-tauri", + "crates/clash-verge-draft", + "crates/clash-verge-logging", + "crates/clash-verge-signal", + "crates/tauri-plugin-clash-verge-sysinfo", + "crates/clash-verge-types", + "crates/clash-verge-i18n", ] resolver = "2" @@ -44,6 +45,7 @@ clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-logging = { path = "crates/clash-verge-logging" } clash-verge-signal = { path = "crates/clash-verge-signal" } 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 = { version = "2.9.5" } @@ -52,10 +54,10 @@ parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } anyhow = "1.0.100" criterion = { version = "0.7.0", features = ["async_tokio"] } tokio = { version = "1.48.0", features = [ - "rt-multi-thread", - "macros", - "time", - "sync", + "rt-multi-thread", + "macros", + "time", + "sync", ] } flexi_logger = "0.31.7" log = "0.4.29" diff --git a/crates/clash-verge-i18n/Cargo.toml b/crates/clash-verge-i18n/Cargo.toml new file mode 100644 index 000000000..1823e18c8 --- /dev/null +++ b/crates/clash-verge-i18n/Cargo.toml @@ -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 diff --git a/src-tauri/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml similarity index 88% rename from src-tauri/locales/ar.yml rename to crates/clash-verge-i18n/locales/ar.yml index bd2ef91fd..c9467a742 100644 --- a/src-tauri/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml similarity index 88% rename from src-tauri/locales/de.yml rename to crates/clash-verge-i18n/locales/de.yml index 4bef5d8bd..dce98f8f7 100644 --- a/src-tauri/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml similarity index 88% rename from src-tauri/locales/en.yml rename to crates/clash-verge-i18n/locales/en.yml index fbfee9cb3..c9467a742 100644 --- a/src-tauri/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -25,9 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the service requires administrator privileges. - adminUninstallPrompt: Uninstalling the service requires administrator privileges. - + adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. + adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml similarity index 88% rename from src-tauri/locales/es.yml rename to crates/clash-verge-i18n/locales/es.yml index 6616029e5..104445270 100644 --- a/src-tauri/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml similarity index 88% rename from src-tauri/locales/fa.yml rename to crates/clash-verge-i18n/locales/fa.yml index bd2ef91fd..c9467a742 100644 --- a/src-tauri/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml similarity index 88% rename from src-tauri/locales/id.yml rename to crates/clash-verge-i18n/locales/id.yml index 1a095afc9..a607d62a6 100644 --- a/src-tauri/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml similarity index 88% rename from src-tauri/locales/jp.yml rename to crates/clash-verge-i18n/locales/jp.yml index c8bb59412..96d233cdb 100644 --- a/src-tauri/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml similarity index 89% rename from src-tauri/locales/ko.yml rename to crates/clash-verge-i18n/locales/ko.yml index ce4a45117..e60c787cf 100644 --- a/src-tauri/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -25,7 +25,8 @@ notifications: title: 앱이 숨겨짐 body: Clash Verge가 백그라운드에서 실행 중입니다. service: - adminPrompt: 서비스를 설치하려면 관리자 권한이 필요합니다. + adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. + adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. tray: dashboard: 대시보드 ruleMode: 규칙 모드 diff --git a/src-tauri/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml similarity index 88% rename from src-tauri/locales/ru.yml rename to crates/clash-verge-i18n/locales/ru.yml index 24cf7689b..0d4c6688d 100644 --- a/src-tauri/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml similarity index 88% rename from src-tauri/locales/tr.yml rename to crates/clash-verge-i18n/locales/tr.yml index 3b1df8408..5b238b241 100644 --- a/src-tauri/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml similarity index 88% rename from src-tauri/locales/tt.yml rename to crates/clash-verge-i18n/locales/tt.yml index bd2ef91fd..c9467a742 100644 --- a/src-tauri/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -25,7 +25,8 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. 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: dashboard: Dashboard ruleMode: Rule Mode diff --git a/src-tauri/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml similarity index 100% rename from src-tauri/locales/zh.yml rename to crates/clash-verge-i18n/locales/zh.yml diff --git a/src-tauri/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml similarity index 91% rename from src-tauri/locales/zhtw.yml rename to crates/clash-verge-i18n/locales/zhtw.yml index 2291530f0..1c6cfd0c1 100644 --- a/src-tauri/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -25,8 +25,8 @@ notifications: title: 應用已隱藏 body: Clash Verge 正在背景執行。 service: - adminInstallPrompt: 安裝服務需要管理員權限 - adminUninstallPrompt: 卸载服務需要管理員權限 + adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限 + adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限 tray: dashboard: 儀表板 ruleMode: 規則模式 diff --git a/crates/clash-verge-i18n/src/lib.rs b/crates/clash-verge-i18n/src/lib.rs new file mode 100644 index 000000000..7be0ad271 --- /dev/null +++ b/crates/clash-verge-i18n/src/lib.rs @@ -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); + } +} diff --git a/docs/CONTRIBUTING_i18n.md b/docs/CONTRIBUTING_i18n.md index 2d9e6674f..ed963cf94 100644 --- a/docs/CONTRIBUTING_i18n.md +++ b/docs/CONTRIBUTING_i18n.md @@ -5,8 +5,8 @@ Thanks for helping localize Clash Verge Rev. This guide reflects the current arc ## Quick workflow - Update the language folder under `src/locales//`; 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. -- If you touch backend copy, edit the matching YAML file in `src-tauri/locales/.yml`. +- 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 `crates/clash-verge-i18n/locales/.yml`. - 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. @@ -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. -## 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 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: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 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. - 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 -Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `src-tauri/locales/.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/.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. - 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 1. Duplicate `src/locales/en/` into `src/locales//` 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. 3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`. -4. If the backend should expose the language, create `src-tauri/locales/.yml` and translate the keys used in existing YAML files. -5. Adjust `crowdin.yml` if the locale requires a special mapping for Crowdin. -6. Run `pnpm format:i18n`, `pnpm i18n:types`, and (optionally) `pnpm node scripts/cleanup-unused-i18n.mjs` in dry-run mode to confirm structure. +4. If the backend should expose the language, create `crates/clash-verge-i18n/.yml` and translate the keys used in existing YAML files. +5. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure. ## Authoring guidelines diff --git a/package.json b/package.json index c13970d9b..9211ab0fe 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src", "format": "prettier --write .", "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", "typecheck": "tsc --noEmit" }, diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs index 45e7a9be7..9e64bf4a9 100644 --- a/scripts/cleanup-unused-i18n.mjs +++ b/scripts/cleanup-unused-i18n.mjs @@ -4,18 +4,23 @@ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; +import yaml from "js-yaml"; import ts from "typescript"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const LOCALES_DIR = path.resolve(__dirname, "../src/locales"); -const TAURI_LOCALES_DIR = path.resolve(__dirname, "../src-tauri/locales"); -const DEFAULT_SOURCE_DIRS = [ - path.resolve(__dirname, "../src"), +const FRONTEND_LOCALES_DIR = path.resolve(__dirname, "../src/locales"); +const BACKEND_LOCALES_DIR = path.resolve( + __dirname, + "../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, "../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 IGNORE_DIR_NAMES = new Set([ ".git", @@ -36,7 +41,7 @@ const IGNORE_DIR_NAMES = new Set([ "logs", "__pycache__", ]); -const SUPPORTED_EXTENSIONS = new Set([ +const FRONTEND_EXTENSIONS = new Set([ ".ts", ".tsx", ".js", @@ -46,6 +51,7 @@ const SUPPORTED_EXTENSIONS = new Set([ ".vue", ".json", ]); +const BACKEND_EXTENSIONS = new Set([".rs"]); const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); @@ -86,20 +92,25 @@ const WHITELIST_KEYS = new Set([ "theme.light", "theme.dark", "theme.system", - "Already Using Latest Core Version", + "_version", ]); const MAX_PREVIEW_ENTRIES = 40; const dynamicKeyCache = new Map(); const fileUsageCache = new Map(); +function resetUsageCaches() { + dynamicKeyCache.clear(); + fileUsageCache.clear(); +} + function printUsage() { console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options] Options: --apply Write locale files with unused keys removed (default: report only) --align Align locale structure/order using the baseline locale - --baseline Baseline locale file name (default: ${DEFAULT_BASELINE_LANG}) + --baseline Baseline locale file name for frontend/backend (default: ${DEFAULT_BASELINE_LANG}) --keep-extra Preserve keys that exist only in non-baseline locales when aligning --no-backup Skip creating \`.bak\` backups when applying changes --report Write a JSON report to the given path @@ -148,7 +159,7 @@ function parseArgs(argv) { if (!next) { 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; break; } @@ -211,14 +222,16 @@ function getAllFiles(start, predicate) { return files; } -function collectSourceFiles(sourceDirs) { +function collectSourceFiles(sourceDirs, options = {}) { + const supportedExtensions = + options.supportedExtensions ?? FRONTEND_EXTENSIONS; const seen = new Set(); const files = []; for (const dir of sourceDirs) { const resolved = getAllFiles(dir, (filePath) => { if (seen.has(filePath)) return false; - if (!SUPPORTED_EXTENSIONS.has(path.extname(filePath))) return false; + if (!supportedExtensions.has(path.extname(filePath))) return false; if ( EXCLUDE_USAGE_DIRS.some((excluded) => 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) { const usedKeys = new Set(); const dynamicPrefixes = new Set(); @@ -685,6 +737,13 @@ function collectUsedI18nKeys(sourceFiles, baselineNamespaces) { usedKeys, dynamicPrefixes, ); + } else if (file.extension === ".rs") { + collectUsedKeysFromRustFile( + file, + baselineNamespaces, + usedKeys, + dynamicPrefixes, + ); } else { collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); } @@ -864,12 +923,16 @@ function writeReport(reportPath, data) { fs.writeFileSync(reportPath, `${payload}\n`, "utf8"); } -function loadLocales() { - if (!fs.existsSync(LOCALES_DIR)) { - throw new Error(`Locales directory not found: ${LOCALES_DIR}`); +function isPlainObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + +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 = []; for (const entry of entries) { @@ -879,12 +942,12 @@ function loadLocales() { !entry.name.endsWith(".bak") && !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 raw = fs.readFileSync(localePath, "utf8"); locales.push({ name, - dir: LOCALES_DIR, + dir: FRONTEND_LOCALES_DIR, format: "single-file", files: [ { @@ -901,7 +964,7 @@ function loadLocales() { if (!entry.isDirectory()) 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 .readdirSync(localeDir, { withFileTypes: true }) .filter( @@ -942,6 +1005,51 @@ function loadLocales() { 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) { const backupPath = `${localePath}.bak`; if (fs.existsSync(backupPath)) { @@ -1042,6 +1150,15 @@ function writeLocale(locale, data, options) { let success = false; 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") { const target = locale.files[0].path; backupIfNeeded(target, backups, options); @@ -1097,6 +1214,8 @@ function processLocale( sourceFiles, missingFromSource, options, + groupName, + baselineName, ) { const data = JSON.parse(JSON.stringify(locale.data)); const flattened = flattenLocale(data); @@ -1112,7 +1231,7 @@ function processLocale( } const sourceMissing = - locale.name === options.baseline + locale.name === baselineName ? missingFromSource.filter((key) => !flattened.has(key)) : []; @@ -1165,8 +1284,9 @@ function processLocale( } return { + group: groupName, 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, expectedKeys: expectedTotal, unusedKeys: unused, @@ -1178,34 +1298,42 @@ function processLocale( }; } -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); - } +function summarizeResults(results) { + return results.reduce( + (totals, result) => { + totals.totalUnused += result.unusedKeys.length; + totals.totalMissing += result.missingKeys.length; + totals.totalExtra += result.extraKeys.length; + totals.totalSourceMissing += result.missingSourceKeys.length; + return totals; + }, + { + totalUnused: 0, + totalMissing: 0, + totalExtra: 0, + totalSourceMissing: 0, + }, + ); +} +function processLocaleGroup(group, options) { 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) { console.log(` - ${dir}`); } - const sourceFiles = collectSourceFiles(sourceDirs); - const locales = loadLocales(); + const sourceFiles = collectSourceFiles(sourceDirs, { + supportedExtensions: group.supportedExtensions, + }); + const locales = group.locales; if (locales.length === 0) { - console.log("No locale files found."); - return; + console.log(`[${group.label}] No locale files found.`); + return null; } const baselineLocale = locales.find( @@ -1215,7 +1343,7 @@ function main() { if (!baselineLocale) { const available = locales.map((item) => item.name).join(", "); 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); }); - 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) => processLocale( locale, @@ -1246,35 +1377,85 @@ function main() { sourceFiles, missingFromSource, options, + group.label, + baselineLocale.name, ), ); - const totalUnused = results.reduce( - (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, - ); + const totals = summarizeResults(results); - console.log("\nSummary:"); + console.log(`\n[${group.label}] Summary:`); for (const result of results) { 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}`, ); } 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) { console.log( "Files were updated in-place; review diffs before committing changes.", @@ -1301,11 +1482,17 @@ function main() { apply: options.apply, backup: options.backup, align: options.align, - baseline: baselineLocale.name, + baseline: options.baseline, 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); console.log(`Report written to ${options.reportPath}`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03d5cbdd8..b7aaf30bd 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ clash-verge-draft = { workspace = true } clash-verge-logging = { workspace = true } clash-verge-signal = { workspace = true } clash-verge-types = { workspace = true } +clash-verge-i18n = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true } tauri = { workspace = true, features = [ @@ -81,7 +82,6 @@ aes-gcm = { version = "0.10.3", features = ["std"] } base64 = "0.22.1" getrandom = "0.3.4" futures = "0.3.31" -sys-locale = "0.3.2" gethostname = "1.1.0" scopeguard = "1.2.0" tauri-plugin-notification = "2.3.3" @@ -97,7 +97,6 @@ clash_verge_service_ipc = { version = "2.0.26", features = [ "client", ], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" } arc-swap = "1.7.1" -rust-i18n = "3.1.5" rust_iso3166 = "0.1.14" dark-light = "2.0.0" diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index ca91f2dd0..3dc003e70 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -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); diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 84638660d..66176a9c9 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -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::(&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()), diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 1974473d6..0e1e9defe 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -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) => { diff --git a/src-tauri/src/core/tray/menu_def.rs b/src-tauri/src/core/tray/menu_def.rs index 24a488fdc..df47400fa 100644 --- a/src-tauri/src/core/tray/menu_def.rs +++ b/src-tauri/src/core/tray/menu_def.rs @@ -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 { - match value { +fn to_arc_str(value: S) -> Arc +where + S: Into>, +{ + match value.into() { Cow::Borrowed(s) => Arc::from(s), Cow::Owned(s) => Arc::from(s.into_boxed_str()), } diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 5cc6a01e8..160ba408c 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -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> { 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( diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 2c5b653c1..ee98deeae 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fb522b81..86a3dfff5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 = OnceCell::new(); /// Application initialization helper functions mod app_init { diff --git a/src-tauri/src/utils/i18n.rs b/src-tauri/src/utils/i18n.rs deleted file mode 100644 index dc8c9bf31..000000000 --- a/src-tauri/src/utils/i18n.rs +++ /dev/null @@ -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 { - if language.is_empty() { - return None; - } - - let normalized = language.to_lowercase().replace('_', "-"); - - let mut candidates: Vec = 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 { - 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() -} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 0746fbd3f..5015c099a 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,6 +1,5 @@ pub mod dirs; pub mod help; -pub mod i18n; pub mod init; #[cfg(target_os = "linux")] pub mod linux; diff --git a/src-tauri/src/utils/notification.rs b/src-tauri/src/utils/notification.rs index e35eb0c22..bb9d502dd 100644 --- a/src-tauri/src/utils/notification.rs +++ b/src-tauri/src/utils/notification.rs @@ -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); } }