Revert "crate(i18n): add clash-verge-i18n crate and integrate localization support (#5959)"

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