diff --git a/Cargo.lock b/Cargo.lock index e9f4e447c..034158d9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,7 +1197,6 @@ dependencies = [ "boa_engine", "chrono", "clash-verge-draft", - "clash-verge-i18n", "clash-verge-logging", "clash-verge-signal", "clash-verge-types", @@ -1225,12 +1224,14 @@ dependencies = [ "reqwest", "reqwest_dav", "runas", + "rust-i18n", "rust_iso3166", "scopeguard", "serde", "serde_json", "serde_yaml_ng", "smartstring", + "sys-locale", "sysproxy", "tauri", "tauri-build", @@ -1267,14 +1268,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "clash-verge-i18n" -version = "0.1.0" -dependencies = [ - "rust-i18n", - "sys-locale", -] - [[package]] name = "clash-verge-logging" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e3b1dc86d..812b14fa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,11 @@ [workspace] members = [ - "src-tauri", - "crates/clash-verge-draft", - "crates/clash-verge-logging", - "crates/clash-verge-signal", - "crates/tauri-plugin-clash-verge-sysinfo", - "crates/clash-verge-types", - "crates/clash-verge-i18n", + "src-tauri", + "crates/clash-verge-draft", + "crates/clash-verge-logging", + "crates/clash-verge-signal", + "crates/tauri-plugin-clash-verge-sysinfo", + "crates/clash-verge-types", ] resolver = "2" @@ -45,7 +44,6 @@ 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" } @@ -54,10 +52,10 @@ parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } anyhow = "1.0.100" criterion = { version = "0.7.0", features = ["async_tokio"] } tokio = { version = "1.48.0", features = [ - "rt-multi-thread", - "macros", - "time", - "sync", + "rt-multi-thread", + "macros", + "time", + "sync", ] } flexi_logger = "0.31.7" log = "0.4.29" diff --git a/crates/clash-verge-i18n/Cargo.toml b/crates/clash-verge-i18n/Cargo.toml deleted file mode 100644 index 1823e18c8..000000000 --- a/crates/clash-verge-i18n/Cargo.toml +++ /dev/null @@ -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 diff --git a/crates/clash-verge-i18n/src/lib.rs b/crates/clash-verge-i18n/src/lib.rs deleted file mode 100644 index 7be0ad271..000000000 --- a/crates/clash-verge-i18n/src/lib.rs +++ /dev/null @@ -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); - } -} diff --git a/docs/CONTRIBUTING_i18n.md b/docs/CONTRIBUTING_i18n.md index 0ed352571..2d9e6674f 100644 --- a/docs/CONTRIBUTING_i18n.md +++ b/docs/CONTRIBUTING_i18n.md @@ -5,8 +5,8 @@ Thanks for helping localize Clash Verge Rev. This guide reflects the current arc ## Quick workflow - Update the language folder under `src/locales//`; use `src/locales/en/` as the canonical reference for keys and intent. -- Run `pnpm i18n:format` to align structure (frontend JSON + backend YAML) and `pnpm i18n:types` to refresh generated typings. -- If you touch backend copy, edit the matching YAML file in `crates/clash-verge-i18n/locales/.yml`. +- Run `pnpm format:i18n` to align structure and `pnpm i18n:types` to refresh generated typings. +- If you touch backend copy, edit the matching YAML file in `src-tauri/locales/.yml`. - 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,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. -## 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 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 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: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 `crates/clash-verge-i18n/locales/.yml`. These files are completely independent from the frontend JSON modules. +Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `src-tauri/locales/.yml`. These files are completely independent from the frontend JSON modules. - Keep `en.yml` semantically aligned with the Simplified Chinese baseline (`zh.yml`). Other locales may temporarily copy English if no translation is available yet. - When a backend feature introduces new strings, update every YAML file to keep the key set consistent. Missing keys fall back to the default language (`zh`), so catching gaps early avoids mixed-language output. -- 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. ## 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//` and translate the JSON files while preserving key structure. 2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports. 3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`. -4. If the backend should expose the language, create `crates/clash-verge-i18n/.yml` and translate the keys used in existing YAML files. -5. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure. +4. If the backend should expose the language, create `src-tauri/locales/.yml` and translate the keys used in existing YAML files. +5. Adjust `crowdin.yml` if the locale requires a special mapping for Crowdin. +6. Run `pnpm format:i18n`, `pnpm i18n:types`, and (optionally) `pnpm node scripts/cleanup-unused-i18n.mjs` in dry-run mode to confirm structure. ## Authoring guidelines diff --git a/package.json b/package.json index 9211ab0fe..c13970d9b 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src", "format": "prettier --write .", "format:check": "prettier --check .", - "i18n:check": "node scripts/cleanup-unused-i18n.mjs", - "i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply", + "format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply", "i18n:types": "node scripts/generate-i18n-keys.mjs", "typecheck": "tsc --noEmit" }, diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs index 9e64bf4a9..45e7a9be7 100644 --- a/scripts/cleanup-unused-i18n.mjs +++ b/scripts/cleanup-unused-i18n.mjs @@ -4,23 +4,18 @@ 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 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 = [ +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"), 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 IGNORE_DIR_NAMES = new Set([ ".git", @@ -41,7 +36,7 @@ const IGNORE_DIR_NAMES = new Set([ "logs", "__pycache__", ]); -const FRONTEND_EXTENSIONS = new Set([ +const SUPPORTED_EXTENSIONS = new Set([ ".ts", ".tsx", ".js", @@ -51,7 +46,6 @@ const FRONTEND_EXTENSIONS = new Set([ ".vue", ".json", ]); -const BACKEND_EXTENSIONS = new Set([".rs"]); const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); @@ -92,25 +86,20 @@ const WHITELIST_KEYS = new Set([ "theme.light", "theme.dark", "theme.system", - "_version", + "Already Using Latest Core Version", ]); const MAX_PREVIEW_ENTRIES = 40; const dynamicKeyCache = new Map(); const fileUsageCache = new Map(); -function resetUsageCaches() { - dynamicKeyCache.clear(); - fileUsageCache.clear(); -} - function printUsage() { console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options] Options: --apply Write locale files with unused keys removed (default: report only) --align Align locale structure/order using the baseline locale - --baseline Baseline locale file name for frontend/backend (default: ${DEFAULT_BASELINE_LANG}) + --baseline Baseline locale file name (default: ${DEFAULT_BASELINE_LANG}) --keep-extra Preserve keys that exist only in non-baseline locales when aligning --no-backup Skip creating \`.bak\` backups when applying changes --report Write a JSON report to the given path @@ -159,7 +148,7 @@ function parseArgs(argv) { if (!next) { 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; break; } @@ -222,16 +211,14 @@ function getAllFiles(start, predicate) { return files; } -function collectSourceFiles(sourceDirs, options = {}) { - const supportedExtensions = - options.supportedExtensions ?? FRONTEND_EXTENSIONS; +function collectSourceFiles(sourceDirs) { const seen = new Set(); const files = []; for (const dir of sourceDirs) { const resolved = getAllFiles(dir, (filePath) => { if (seen.has(filePath)) return false; - if (!supportedExtensions.has(path.extname(filePath))) return false; + if (!SUPPORTED_EXTENSIONS.has(path.extname(filePath))) return false; if ( EXCLUDE_USAGE_DIRS.some((excluded) => 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) { const usedKeys = new Set(); const dynamicPrefixes = new Set(); @@ -737,13 +685,6 @@ function collectUsedI18nKeys(sourceFiles, baselineNamespaces) { usedKeys, dynamicPrefixes, ); - } else if (file.extension === ".rs") { - collectUsedKeysFromRustFile( - file, - baselineNamespaces, - usedKeys, - dynamicPrefixes, - ); } else { collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); } @@ -923,16 +864,12 @@ function writeReport(reportPath, data) { fs.writeFileSync(reportPath, `${payload}\n`, "utf8"); } -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}`); +function loadLocales() { + if (!fs.existsSync(LOCALES_DIR)) { + throw new Error(`Locales directory not found: ${LOCALES_DIR}`); } - const entries = fs.readdirSync(FRONTEND_LOCALES_DIR, { withFileTypes: true }); + const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }); const locales = []; for (const entry of entries) { @@ -942,12 +879,12 @@ function loadFrontendLocales() { !entry.name.endsWith(".bak") && !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 raw = fs.readFileSync(localePath, "utf8"); locales.push({ name, - dir: FRONTEND_LOCALES_DIR, + dir: LOCALES_DIR, format: "single-file", files: [ { @@ -964,7 +901,7 @@ function loadFrontendLocales() { if (!entry.isDirectory()) 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 .readdirSync(localeDir, { withFileTypes: true }) .filter( @@ -1005,51 +942,6 @@ function loadFrontendLocales() { 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)) { @@ -1150,15 +1042,6 @@ 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); @@ -1214,8 +1097,6 @@ function processLocale( sourceFiles, missingFromSource, options, - groupName, - baselineName, ) { const data = JSON.parse(JSON.stringify(locale.data)); const flattened = flattenLocale(data); @@ -1231,7 +1112,7 @@ function processLocale( } const sourceMissing = - locale.name === baselineName + locale.name === options.baseline ? missingFromSource.filter((key) => !flattened.has(key)) : []; @@ -1284,9 +1165,8 @@ function processLocale( } return { - group: groupName, 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, expectedKeys: expectedTotal, unusedKeys: unused, @@ -1298,42 +1178,34 @@ function processLocale( }; } -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 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 processLocaleGroup(group, options) { 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) { console.log(` - ${dir}`); } - const sourceFiles = collectSourceFiles(sourceDirs, { - supportedExtensions: group.supportedExtensions, - }); - const locales = group.locales; + const sourceFiles = collectSourceFiles(sourceDirs); + const locales = loadLocales(); if (locales.length === 0) { - console.log(`[${group.label}] No locale files found.`); - return null; + console.log("No locale files found."); + return; } const baselineLocale = locales.find( @@ -1343,7 +1215,7 @@ function processLocaleGroup(group, options) { if (!baselineLocale) { const available = locales.map((item) => item.name).join(", "); 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); }); - console.log( - `\n[${group.label}] Checking ${locales.length} locale files...\n`, - ); + console.log(`\nChecking ${locales.length} locale files...\n`); - resetUsageCaches(); const results = locales.map((locale) => processLocale( locale, @@ -1377,85 +1246,35 @@ function processLocaleGroup(group, options) { sourceFiles, missingFromSource, 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) { 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( - `\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) { console.log( "Files were updated in-place; review diffs before committing changes.", @@ -1482,17 +1301,11 @@ function main() { apply: options.apply, backup: options.backup, align: options.align, - baseline: options.baseline, + baseline: baselineLocale.name, keepExtra: options.keepExtra, + sourceDirs, }, - 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, + results, }; writeReport(options.reportPath, payload); console.log(`Report written to ${options.reportPath}`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b7aaf30bd..03d5cbdd8 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,7 +35,6 @@ 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 = [ @@ -82,6 +81,7 @@ 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,6 +97,7 @@ clash_verge_service_ipc = { version = "2.0.26", features = [ "client", ], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" } arc-swap = "1.7.1" +rust-i18n = "3.1.5" rust_iso3166 = "0.1.14" dark-light = "2.0.0" diff --git a/crates/clash-verge-i18n/locales/ar.yml b/src-tauri/locales/ar.yml similarity index 88% rename from crates/clash-verge-i18n/locales/ar.yml rename to src-tauri/locales/ar.yml index c9467a742..bd2ef91fd 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/src-tauri/locales/ar.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/de.yml b/src-tauri/locales/de.yml similarity index 88% rename from crates/clash-verge-i18n/locales/de.yml rename to src-tauri/locales/de.yml index dce98f8f7..4bef5d8bd 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/src-tauri/locales/de.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/en.yml b/src-tauri/locales/en.yml similarity index 88% rename from crates/clash-verge-i18n/locales/en.yml rename to src-tauri/locales/en.yml index c9467a742..fbfee9cb3 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/src-tauri/locales/en.yml @@ -25,8 +25,9 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminInstallPrompt: Installing the service requires administrator privileges. + adminUninstallPrompt: Uninstalling the service requires administrator privileges. + tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/es.yml b/src-tauri/locales/es.yml similarity index 88% rename from crates/clash-verge-i18n/locales/es.yml rename to src-tauri/locales/es.yml index 104445270..6616029e5 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/src-tauri/locales/es.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/fa.yml b/src-tauri/locales/fa.yml similarity index 88% rename from crates/clash-verge-i18n/locales/fa.yml rename to src-tauri/locales/fa.yml index c9467a742..bd2ef91fd 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/src-tauri/locales/fa.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/id.yml b/src-tauri/locales/id.yml similarity index 88% rename from crates/clash-verge-i18n/locales/id.yml rename to src-tauri/locales/id.yml index a607d62a6..1a095afc9 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/src-tauri/locales/id.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/jp.yml b/src-tauri/locales/jp.yml similarity index 88% rename from crates/clash-verge-i18n/locales/jp.yml rename to src-tauri/locales/jp.yml index 96d233cdb..c8bb59412 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/src-tauri/locales/jp.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/ko.yml b/src-tauri/locales/ko.yml similarity index 89% rename from crates/clash-verge-i18n/locales/ko.yml rename to src-tauri/locales/ko.yml index e60c787cf..ce4a45117 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/src-tauri/locales/ko.yml @@ -25,8 +25,7 @@ notifications: title: 앱이 숨겨짐 body: Clash Verge가 백그라운드에서 실행 중입니다. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: 서비스를 설치하려면 관리자 권한이 필요합니다. tray: dashboard: 대시보드 ruleMode: 규칙 모드 diff --git a/crates/clash-verge-i18n/locales/ru.yml b/src-tauri/locales/ru.yml similarity index 88% rename from crates/clash-verge-i18n/locales/ru.yml rename to src-tauri/locales/ru.yml index 0d4c6688d..24cf7689b 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/src-tauri/locales/ru.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/tr.yml b/src-tauri/locales/tr.yml similarity index 88% rename from crates/clash-verge-i18n/locales/tr.yml rename to src-tauri/locales/tr.yml index 5b238b241..3b1df8408 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/src-tauri/locales/tr.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/tt.yml b/src-tauri/locales/tt.yml similarity index 88% rename from crates/clash-verge-i18n/locales/tt.yml rename to src-tauri/locales/tt.yml index c9467a742..bd2ef91fd 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/src-tauri/locales/tt.yml @@ -25,8 +25,7 @@ notifications: title: Application Hidden body: Clash Verge is running in the background. service: - adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. - adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. + adminPrompt: Installing the service requires administrator privileges. tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/zh.yml b/src-tauri/locales/zh.yml similarity index 100% rename from crates/clash-verge-i18n/locales/zh.yml rename to src-tauri/locales/zh.yml diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/src-tauri/locales/zhtw.yml similarity index 91% rename from crates/clash-verge-i18n/locales/zhtw.yml rename to src-tauri/locales/zhtw.yml index 1c6cfd0c1..2291530f0 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/src-tauri/locales/zhtw.yml @@ -25,8 +25,8 @@ notifications: title: 應用已隱藏 body: Clash Verge 正在背景執行。 service: - adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限 - adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限 + adminInstallPrompt: 安裝服務需要管理員權限 + adminUninstallPrompt: 卸载服務需要管理員權限 tray: dashboard: 儀表板 ruleMode: 規則模式 diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 66176a9c9..84638660d 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -1,7 +1,7 @@ use crate::config::Config; use crate::{ config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted}, - utils::{dirs, help}, + utils::{dirs, help, i18n}, }; use anyhow::Result; use clash_verge_logging::{Type, logging}; @@ -346,6 +346,19 @@ impl IVerge { self.clash_core.clone().unwrap_or_else(|| "verge-mihomo".into()) } + fn get_system_language() -> String { + let sys_lang = sys_locale::get_locale().unwrap_or_else(|| "en".into()).to_lowercase(); + + let lang_code = sys_lang.split(['_', '-']).next().unwrap_or("en"); + let supported_languages = i18n::get_supported_languages(); + + if supported_languages.contains(&lang_code.into()) { + lang_code.into() + } else { + String::from("en") + } + } + pub async fn new() -> Self { match dirs::verge_path() { Ok(path) => match help::read_yaml::(&path).await { @@ -375,7 +388,7 @@ impl IVerge { app_log_max_size: Some(128), app_log_max_count: Some(8), 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()), #[cfg(not(target_os = "windows"))] env_type: Some("bash".into()), diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 0e1e9defe..1974473d6 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -30,8 +30,9 @@ pub enum ServiceStatus { #[derive(Clone)] pub struct ServiceManager(ServiceStatus); +#[allow(clippy::unused_async)] #[cfg(target_os = "windows")] -fn uninstall_service() -> Result<()> { +async fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); use deelevate::{PrivilegeLevel, Token}; @@ -62,8 +63,9 @@ fn uninstall_service() -> Result<()> { Ok(()) } +#[allow(clippy::unused_async)] #[cfg(target_os = "windows")] -fn install_service() -> Result<()> { +async fn install_service() -> Result<()> { logging!(info, Type::Service, "install service"); use deelevate::{PrivilegeLevel, Token}; @@ -91,8 +93,27 @@ 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")] -fn uninstall_service() -> Result<()> { +async 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"); @@ -148,7 +169,8 @@ fn uninstall_service() -> Result<()> { } #[cfg(target_os = "linux")] -fn install_service() -> Result<()> { +#[allow(clippy::unused_async)] +async 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"); @@ -200,6 +222,24 @@ 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; @@ -209,7 +249,7 @@ fn linux_running_as_root() -> bool { } #[cfg(target_os = "macos")] -fn uninstall_service() -> Result<()> { +async fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); 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(); - // 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 = 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")] -fn install_service() -> Result<()> { +async fn install_service() -> Result<()> { logging!(info, Type::Service, "install service"); 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(); - // 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 = format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#); @@ -269,16 +309,17 @@ fn install_service() -> Result<()> { Ok(()) } -fn reinstall_service() -> Result<()> { +#[cfg(target_os = "macos")] +async fn reinstall_service() -> Result<()> { 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); } // 再安装服务 - match install_service() { + match install_service().await { Ok(_) => Ok(()), Err(err) => { bail!(format!("failed to install service: {err}")) @@ -287,9 +328,9 @@ fn reinstall_service() -> Result<()> { } /// 强制重装服务(UI修复按钮) -fn force_reinstall_service() -> Result<()> { +async fn force_reinstall_service() -> Result<()> { logging!(info, Type::Service, "用户请求强制重装服务"); - reinstall_service().map_err(|err| { + reinstall_service().await.map_err(|err| { logging!(error, Type::Service, "强制重装服务失败: {}", err); err }) @@ -509,22 +550,22 @@ impl ServiceManager { } ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => { logging!(info, Type::Service, "服务需要重装,执行重装流程"); - reinstall_service()?; + reinstall_service().await?; wait_and_check_service_available(self).await?; } ServiceStatus::ForceReinstallRequired => { logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程"); - force_reinstall_service()?; + force_reinstall_service().await?; wait_and_check_service_available(self).await?; } ServiceStatus::InstallRequired => { logging!(info, Type::Service, "需要安装服务,执行安装流程"); - install_service()?; + install_service().await?; wait_and_check_service_available(self).await?; } ServiceStatus::UninstallRequired => { logging!(info, Type::Service, "服务需要卸载,执行卸载流程"); - uninstall_service()?; + uninstall_service().await?; self.0 = ServiceStatus::Unavailable("Service Uninstalled".into()); } ServiceStatus::Unavailable(reason) => { diff --git a/src-tauri/src/core/tray/menu_def.rs b/src-tauri/src/core/tray/menu_def.rs index df47400fa..24a488fdc 100644 --- a/src-tauri/src/core/tray/menu_def.rs +++ b/src-tauri/src/core/tray/menu_def.rs @@ -1,11 +1,8 @@ -use clash_verge_i18n::t; +use rust_i18n::t; use std::{borrow::Cow, sync::Arc}; -fn to_arc_str(value: S) -> Arc -where - S: Into>, -{ - match value.into() { +fn to_arc_str(value: Cow<'static, str>) -> Arc { + match value { Cow::Borrowed(s) => Arc::from(s), Cow::Owned(s) => Arc::from(s.into_boxed_str()), } diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 4a5dab1f9..5cc6a01e8 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -12,8 +12,11 @@ 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, + Type, cmd, + config::Config, + feat, logging, + module::lightweight::is_in_lightweight_mode, + utils::{dirs::find_target_icons, i18n}, }; use super::handle; @@ -386,8 +389,7 @@ impl Tray { let app_handle = handle::Handle::app_handle(); - // We do not want to always synchronize locale settings to cause unnecessary performance cost - // clash_verge_i18n::sync_locale().await; + i18n::sync_locale().await; let verge = Config::verge().await.latest_arc(); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); @@ -415,9 +417,9 @@ impl Tray { } // Get localized strings before using them - 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 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 v = env!("CARGO_PKG_VERSION"); let reassembled_version = v.split_once('+').map_or_else( @@ -722,8 +724,7 @@ async fn create_tray_menu( ) -> Result> { 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 let proxy_nodes_data = tokio::time::timeout( @@ -828,9 +829,9 @@ async fn create_tray_menu( None } else { let current_mode_text = match current_proxy_mode { - "global" => clash_verge_i18n::t!("tray.global"), - "direct" => clash_verge_i18n::t!("tray.direct"), - _ => clash_verge_i18n::t!("tray.rule"), + "global" => rust_i18n::t!("tray.global"), + "direct" => rust_i18n::t!("tray.direct"), + _ => rust_i18n::t!("tray.rule"), }; let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text); Some(Submenu::with_id_and_items( diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 3fc39d3aa..2c5b653c1 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -63,7 +63,6 @@ enum UpdateFlags { SystrayTooltip = 1 << 8, SystrayClickBehavior = 1 << 9, LighteWeight = 1 << 10, - Language = 1 << 11, } fn determine_update_flags(patch: &IVerge) -> i32 { @@ -154,7 +153,6 @@ fn determine_update_flags(patch: &IVerge) -> i32 { } if language.is_some() { - update_flags |= UpdateFlags::Language as i32; update_flags |= UpdateFlags::SystrayMenu as i32; } 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 { sysopt::Sysopt::global().update_launch().await?; } - if (update_flags & (UpdateFlags::Language as i32)) != 0 - && let Some(language) = &patch.language - { - clash_verge_i18n::set_locale(language.as_str()); - } if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 { sysopt::Sysopt::global().update_sysproxy().await?; sysopt::Sysopt::global().refresh_guard().await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86a3dfff5..6fb522b81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ 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")] @@ -26,6 +27,8 @@ use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_deep_link::DeepLinkExt as _; use tauri_plugin_mihomo::RejectPolicy; +i18n!("locales", fallback = "zh"); + pub static APP_HANDLE: OnceCell = OnceCell::new(); /// Application initialization helper functions mod app_init { diff --git a/src-tauri/src/utils/i18n.rs b/src-tauri/src/utils/i18n.rs new file mode 100644 index 000000000..dc8c9bf31 --- /dev/null +++ b/src-tauri/src/utils/i18n.rs @@ -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 { + if language.is_empty() { + return None; + } + + let normalized = language.to_lowercase().replace('_', "-"); + + let mut candidates: Vec = Vec::new(); + let mut push_candidate = |candidate: String| { + if !candidate.is_empty() + && !candidates + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&candidate)) + { + candidates.push(candidate); + } + }; + + let segments: Vec<&str> = normalized.split('-').collect(); + + for i in (1..=segments.len()).rev() { + let prefix = segments[..i].join("-"); + if let Some(alias) = locale_alias(&prefix) { + push_candidate(alias.to_string()); + } + push_candidate(prefix); + } + + let supported = supported_languages_internal(); + + candidates + .into_iter() + .find(|candidate| supported.iter().any(|&lang| lang.eq_ignore_ascii_case(candidate))) +} + +fn system_language() -> String { + sys_locale::get_locale() + .as_deref() + .and_then(resolve_supported_language) + .unwrap_or_else(|| fallback_language().to_string()) +} + +pub fn get_supported_languages() -> Vec { + supported_languages_internal() + .into_iter() + .map(|lang| lang.to_string()) + .collect() +} + +pub fn set_locale(language: &str) { + let lang = resolve_supported_language(language).unwrap_or_else(|| fallback_language().to_string()); + rust_i18n::set_locale(&lang); +} + +pub async fn current_language() -> String { + Config::verge() + .await + .latest_arc() + .language + .as_ref() + .filter(|lang| !lang.is_empty()) + .and_then(|lang| resolve_supported_language(lang)) + .unwrap_or_else(system_language) +} + +pub async fn sync_locale() -> String { + let language = current_language().await; + set_locale(&language); + language +} + +pub const fn default_language() -> &'static str { + fallback_language() +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 5015c099a..0746fbd3f 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod dirs; pub mod help; +pub mod i18n; pub mod init; #[cfg(target_os = "linux")] pub mod linux; diff --git a/src-tauri/src/utils/notification.rs b/src-tauri/src/utils/notification.rs index bb9d502dd..e35eb0c22 100644 --- a/src-tauri/src/utils/notification.rs +++ b/src-tauri/src/utils/notification.rs @@ -1,5 +1,4 @@ -use crate::core::handle; -use clash_verge_i18n; +use crate::{core::handle, utils::i18n}; use tauri_plugin_notification::NotificationExt as _; pub enum NotificationEvent<'a> { @@ -22,49 +21,48 @@ fn notify(title: &str, body: &str) { } 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 { NotificationEvent::DashboardToggled => { - let title = clash_verge_i18n::t!("notifications.dashboardToggled.title"); - let body = clash_verge_i18n::t!("notifications.dashboardToggled.body"); + let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string(); + let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string(); notify(&title, &body); } NotificationEvent::ClashModeChanged { mode } => { - let title = clash_verge_i18n::t!("notifications.clashModeChanged.title"); - let body = clash_verge_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode); + let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string(); + let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode); notify(&title, &body); } NotificationEvent::SystemProxyToggled => { - let title = clash_verge_i18n::t!("notifications.systemProxyToggled.title"); - let body = clash_verge_i18n::t!("notifications.systemProxyToggled.body"); + let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string(); + let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string(); notify(&title, &body); } NotificationEvent::TunModeToggled => { - let title = clash_verge_i18n::t!("notifications.tunModeToggled.title"); - let body = clash_verge_i18n::t!("notifications.tunModeToggled.body"); + let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string(); + let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string(); notify(&title, &body); } NotificationEvent::LightweightModeEntered => { - let title = clash_verge_i18n::t!("notifications.lightweightModeEntered.title"); - let body = clash_verge_i18n::t!("notifications.lightweightModeEntered.body"); + let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string(); + let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string(); notify(&title, &body); } NotificationEvent::ProfilesReactivated => { - let title = clash_verge_i18n::t!("notifications.profilesReactivated.title"); - let body = clash_verge_i18n::t!("notifications.profilesReactivated.body"); + let title = rust_i18n::t!("notifications.profilesReactivated.title").to_string(); + let body = rust_i18n::t!("notifications.profilesReactivated.body").to_string(); notify(&title, &body); } NotificationEvent::AppQuit => { - let title = clash_verge_i18n::t!("notifications.appQuit.title"); - let body = clash_verge_i18n::t!("notifications.appQuit.body"); + let title = rust_i18n::t!("notifications.appQuit.title").to_string(); + let body = rust_i18n::t!("notifications.appQuit.body").to_string(); notify(&title, &body); } #[cfg(target_os = "macos")] NotificationEvent::AppHidden => { - let title = clash_verge_i18n::t!("notifications.appHidden.title"); - let body = clash_verge_i18n::t!("notifications.appHidden.body"); + let title = rust_i18n::t!("notifications.appHidden.title").to_string(); + let body = rust_i18n::t!("notifications.appHidden.body").to_string(); notify(&title, &body); } }