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

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

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

* chore(i18n): align i18n key

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

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

* fix(tray): initialize i18n locale before setup

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

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

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

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
Sline
2025-12-27 15:03:19 +08:00
committed by GitHub
parent 1b477ed0b2
commit c8aeae3f83
31 changed files with 486 additions and 312 deletions

11
Cargo.lock generated
View File

@@ -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"

View File

@@ -6,6 +6,7 @@ members = [
"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" }

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: 규칙 모드

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -25,8 +25,8 @@ notifications:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
service:
adminInstallPrompt: 安裝服務需要管理員權限
adminUninstallPrompt: 卸载服務需要管理員權限
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
tray:
dashboard: 儀表板
ruleMode: 規則模式

View 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);
}
}

View File

@@ -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/<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.
- If you touch backend copy, edit the matching YAML file in `src-tauri/locales/<lang>.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/<lang>.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/<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.
- 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/<new-lang>/` and translate the JSON files while preserving key structure.
2. Update the locales `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/<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.
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/<new-lang>.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

View File

@@ -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"
},

View File

@@ -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 <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
--no-backup Skip creating \`.bak\` backups when applying changes
--report <path> 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}`);

View File

@@ -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"

View File

@@ -65,6 +65,9 @@ impl Config {
pub async fn init_config() -> Result<()> {
Self::ensure_default_profile_items().await?;
let verge = Self::verge().await.latest_arc();
clash_verge_i18n::sync_locale(verge.language.as_deref());
// init Tun mode
let handle = Handle::app_handle();
let is_admin = is_current_app_handle_admin(handle);

View File

@@ -1,7 +1,7 @@
use crate::config::Config;
use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
utils::{dirs, help, i18n},
utils::{dirs, help},
};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
@@ -346,19 +346,6 @@ impl IVerge {
self.clash_core.clone().unwrap_or_else(|| "verge-mihomo".into())
}
fn get_system_language() -> String {
let sys_lang = sys_locale::get_locale().unwrap_or_else(|| "en".into()).to_lowercase();
let lang_code = sys_lang.split(['_', '-']).next().unwrap_or("en");
let supported_languages = i18n::get_supported_languages();
if supported_languages.contains(&lang_code.into()) {
lang_code.into()
} else {
String::from("en")
}
}
pub async fn new() -> Self {
match dirs::verge_path() {
Ok(path) => match help::read_yaml::<Self>(&path).await {
@@ -388,7 +375,7 @@ impl IVerge {
app_log_max_size: Some(128),
app_log_max_count: Some(8),
clash_core: Some("verge-mihomo".into()),
language: Some(Self::get_system_language()),
language: Some(clash_verge_i18n::system_language().into()),
theme_mode: Some("system".into()),
#[cfg(not(target_os = "windows"))]
env_type: Some("bash".into()),

View File

@@ -30,9 +30,8 @@ pub enum ServiceStatus {
#[derive(Clone)]
pub struct ServiceManager(ServiceStatus);
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token};
@@ -63,9 +62,8 @@ async fn uninstall_service() -> Result<()> {
Ok(())
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
use deelevate::{PrivilegeLevel, Token};
@@ -93,27 +91,8 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
async fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
}
}
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "linux")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall");
@@ -169,8 +148,7 @@ async fn uninstall_service() -> Result<()> {
}
#[cfg(target_os = "linux")]
#[allow(clippy::unused_async)]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
let install_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install");
@@ -222,24 +200,6 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
}
}
}
#[cfg(target_os = "linux")]
fn linux_running_as_root() -> bool {
use crate::core::handle;
@@ -249,7 +209,7 @@ fn linux_running_as_root() -> bool {
}
#[cfg(target_os = "macos")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let binary_path = dirs::service_path()?;
@@ -261,9 +221,9 @@ async fn uninstall_service() -> Result<()> {
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
crate::utils::i18n::sync_locale().await;
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = rust_i18n::t!("service.adminUninstallPrompt").to_string();
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
let command =
format!(r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#);
@@ -282,7 +242,7 @@ async fn uninstall_service() -> Result<()> {
}
#[cfg(target_os = "macos")]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
let binary_path = dirs::service_path()?;
@@ -294,9 +254,9 @@ async fn install_service() -> Result<()> {
let install_shell: String = install_path.to_string_lossy().into_owned();
crate::utils::i18n::sync_locale().await;
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = rust_i18n::t!("service.adminInstallPrompt").to_string();
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
let command =
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
@@ -309,17 +269,16 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
async fn reinstall_service() -> Result<()> {
fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
if let Err(err) = uninstall_service() {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
match install_service() {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
@@ -328,9 +287,9 @@ async fn reinstall_service() -> Result<()> {
}
/// 强制重装服务UI修复按钮
async fn force_reinstall_service() -> Result<()> {
fn force_reinstall_service() -> Result<()> {
logging!(info, Type::Service, "用户请求强制重装服务");
reinstall_service().await.map_err(|err| {
reinstall_service().map_err(|err| {
logging!(error, Type::Service, "强制重装服务失败: {}", err);
err
})
@@ -550,22 +509,22 @@ impl ServiceManager {
}
ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => {
logging!(info, Type::Service, "服务需要重装,执行重装流程");
reinstall_service().await?;
reinstall_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::ForceReinstallRequired => {
logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程");
force_reinstall_service().await?;
force_reinstall_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::InstallRequired => {
logging!(info, Type::Service, "需要安装服务,执行安装流程");
install_service().await?;
install_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::UninstallRequired => {
logging!(info, Type::Service, "服务需要卸载,执行卸载流程");
uninstall_service().await?;
uninstall_service()?;
self.0 = ServiceStatus::Unavailable("Service Uninstalled".into());
}
ServiceStatus::Unavailable(reason) => {

View File

@@ -1,8 +1,11 @@
use rust_i18n::t;
use clash_verge_i18n::t;
use std::{borrow::Cow, sync::Arc};
fn to_arc_str(value: Cow<'static, str>) -> Arc<str> {
match value {
fn to_arc_str<S>(value: S) -> Arc<str>
where
S: Into<Cow<'static, str>>,
{
match value.into() {
Cow::Borrowed(s) => Arc::from(s),
Cow::Owned(s) => Arc::from(s.into_boxed_str()),
}

View File

@@ -12,11 +12,8 @@ use crate::process::AsyncHandler;
use crate::singleton;
use crate::utils::window_manager::WindowManager;
use crate::{
Type, cmd,
config::Config,
feat, logging,
module::lightweight::is_in_lightweight_mode,
utils::{dirs::find_target_icons, i18n},
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
utils::dirs::find_target_icons,
};
use super::handle;
@@ -389,8 +386,6 @@ impl Tray {
let app_handle = handle::Handle::app_handle();
i18n::sync_locale().await;
let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
@@ -417,9 +412,9 @@ impl Tray {
}
// Get localized strings before using them
let sys_proxy_text = rust_i18n::t!("tray.tooltip.systemProxy");
let tun_text = rust_i18n::t!("tray.tooltip.tun");
let profile_text = rust_i18n::t!("tray.tooltip.profile");
let sys_proxy_text = clash_verge_i18n::t!("tray.tooltip.systemProxy");
let tun_text = clash_verge_i18n::t!("tray.tooltip.tun");
let profile_text = clash_verge_i18n::t!("tray.tooltip.profile");
let v = env!("CARGO_PKG_VERSION");
let reassembled_version = v.split_once('+').map_or_else(
@@ -724,8 +719,6 @@ async fn create_tray_menu(
) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = mode.unwrap_or("");
i18n::sync_locale().await;
// TODO: should update tray menu again when it was timeout error
let proxy_nodes_data = tokio::time::timeout(
Duration::from_millis(1000),
@@ -829,9 +822,9 @@ async fn create_tray_menu(
None
} else {
let current_mode_text = match current_proxy_mode {
"global" => rust_i18n::t!("tray.global"),
"direct" => rust_i18n::t!("tray.direct"),
_ => rust_i18n::t!("tray.rule"),
"global" => clash_verge_i18n::t!("tray.global"),
"direct" => clash_verge_i18n::t!("tray.direct"),
_ => clash_verge_i18n::t!("tray.rule"),
};
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
Some(Submenu::with_id_and_items(

View File

@@ -63,6 +63,7 @@ enum UpdateFlags {
SystrayTooltip = 1 << 8,
SystrayClickBehavior = 1 << 9,
LighteWeight = 1 << 10,
Language = 1 << 11,
}
fn determine_update_flags(patch: &IVerge) -> i32 {
@@ -153,7 +154,9 @@ fn determine_update_flags(patch: &IVerge) -> i32 {
}
if language.is_some() {
update_flags |= UpdateFlags::Language as i32;
update_flags |= UpdateFlags::SystrayMenu as i32;
update_flags |= UpdateFlags::SystrayTooltip as i32;
}
if common_tray_icon.is_some()
|| sysproxy_tray_icon.is_some()
@@ -212,6 +215,11 @@ async fn process_terminated_flags(update_flags: i32, patch: &IVerge) -> Result<(
if (update_flags & (UpdateFlags::Launch as i32)) != 0 {
sysopt::Sysopt::global().update_launch().await?;
}
if (update_flags & (UpdateFlags::Language as i32)) != 0
&& let Some(language) = &patch.language
{
clash_verge_i18n::set_locale(language.as_str());
}
if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 {
sysopt::Sysopt::global().update_sysproxy().await?;
sysopt::Sysopt::global().refresh_guard().await;

View File

@@ -19,7 +19,6 @@ use crate::{
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use rust_i18n::i18n;
use std::time::Duration;
use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")]
@@ -27,8 +26,6 @@ use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
use tauri_plugin_mihomo::RejectPolicy;
i18n!("locales", fallback = "zh");
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
mod app_init {

View File

@@ -1,96 +0,0 @@
use crate::config::Config;
use sys_locale;
const DEFAULT_LANGUAGE: &str = "zh";
fn supported_languages_internal() -> Vec<&'static str> {
rust_i18n::available_locales!()
}
const fn fallback_language() -> &'static str {
DEFAULT_LANGUAGE
}
fn locale_alias(locale: &str) -> Option<&'static str> {
match locale {
"ja" | "ja-jp" | "jp" => Some("jp"),
"zh" | "zh-cn" | "zh-hans" | "zh-sg" | "zh-my" | "zh-chs" => Some("zh"),
"zh-tw" | "zh-hk" | "zh-hant" | "zh-mo" | "zh-cht" => Some("zhtw"),
_ => None,
}
}
fn resolve_supported_language(language: &str) -> Option<String> {
if language.is_empty() {
return None;
}
let normalized = language.to_lowercase().replace('_', "-");
let mut candidates: Vec<String> = Vec::new();
let mut push_candidate = |candidate: String| {
if !candidate.is_empty()
&& !candidates
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&candidate))
{
candidates.push(candidate);
}
};
let segments: Vec<&str> = normalized.split('-').collect();
for i in (1..=segments.len()).rev() {
let prefix = segments[..i].join("-");
if let Some(alias) = locale_alias(&prefix) {
push_candidate(alias.to_string());
}
push_candidate(prefix);
}
let supported = supported_languages_internal();
candidates
.into_iter()
.find(|candidate| supported.iter().any(|&lang| lang.eq_ignore_ascii_case(candidate)))
}
fn system_language() -> String {
sys_locale::get_locale()
.as_deref()
.and_then(resolve_supported_language)
.unwrap_or_else(|| fallback_language().to_string())
}
pub fn get_supported_languages() -> Vec<String> {
supported_languages_internal()
.into_iter()
.map(|lang| lang.to_string())
.collect()
}
pub fn set_locale(language: &str) {
let lang = resolve_supported_language(language).unwrap_or_else(|| fallback_language().to_string());
rust_i18n::set_locale(&lang);
}
pub async fn current_language() -> String {
Config::verge()
.await
.latest_arc()
.language
.as_ref()
.filter(|lang| !lang.is_empty())
.and_then(|lang| resolve_supported_language(lang))
.unwrap_or_else(system_language)
}
pub async fn sync_locale() -> String {
let language = current_language().await;
set_locale(&language);
language
}
pub const fn default_language() -> &'static str {
fallback_language()
}

View File

@@ -1,6 +1,5 @@
pub mod dirs;
pub mod help;
pub mod i18n;
pub mod init;
#[cfg(target_os = "linux")]
pub mod linux;

View File

@@ -1,4 +1,5 @@
use crate::{core::handle, utils::i18n};
use crate::core::handle;
use clash_verge_i18n;
use tauri_plugin_notification::NotificationExt as _;
pub enum NotificationEvent<'a> {
@@ -21,48 +22,49 @@ fn notify(title: &str, body: &str) {
}
pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
i18n::sync_locale().await;
// It cause to sync set-local everytime notify, so move it outside
// i18n::sync_locale().await;
match event {
NotificationEvent::DashboardToggled => {
let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string();
let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string();
let title = clash_verge_i18n::t!("notifications.dashboardToggled.title");
let body = clash_verge_i18n::t!("notifications.dashboardToggled.body");
notify(&title, &body);
}
NotificationEvent::ClashModeChanged { mode } => {
let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string();
let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
let title = clash_verge_i18n::t!("notifications.clashModeChanged.title");
let body = clash_verge_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
notify(&title, &body);
}
NotificationEvent::SystemProxyToggled => {
let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string();
let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string();
let title = clash_verge_i18n::t!("notifications.systemProxyToggled.title");
let body = clash_verge_i18n::t!("notifications.systemProxyToggled.body");
notify(&title, &body);
}
NotificationEvent::TunModeToggled => {
let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string();
let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string();
let title = clash_verge_i18n::t!("notifications.tunModeToggled.title");
let body = clash_verge_i18n::t!("notifications.tunModeToggled.body");
notify(&title, &body);
}
NotificationEvent::LightweightModeEntered => {
let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string();
let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string();
let title = clash_verge_i18n::t!("notifications.lightweightModeEntered.title");
let body = clash_verge_i18n::t!("notifications.lightweightModeEntered.body");
notify(&title, &body);
}
NotificationEvent::ProfilesReactivated => {
let title = rust_i18n::t!("notifications.profilesReactivated.title").to_string();
let body = rust_i18n::t!("notifications.profilesReactivated.body").to_string();
let title = clash_verge_i18n::t!("notifications.profilesReactivated.title");
let body = clash_verge_i18n::t!("notifications.profilesReactivated.body");
notify(&title, &body);
}
NotificationEvent::AppQuit => {
let title = rust_i18n::t!("notifications.appQuit.title").to_string();
let body = rust_i18n::t!("notifications.appQuit.body").to_string();
let title = clash_verge_i18n::t!("notifications.appQuit.title");
let body = clash_verge_i18n::t!("notifications.appQuit.body");
notify(&title, &body);
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
let title = rust_i18n::t!("notifications.appHidden.title").to_string();
let body = rust_i18n::t!("notifications.appHidden.body").to_string();
let title = clash_verge_i18n::t!("notifications.appHidden.title");
let body = clash_verge_i18n::t!("notifications.appHidden.body");
notify(&title, &body);
}
}