diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e58151f9a..27de2822c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "features": { "ghcr.io/devcontainers/features/node:2": { "version": "24.21.0", - "pnpmVersion": "12.10.0" + "pnpmVersion": "12.11.2" }, "ghcr.io/devcontainers/features/rust:1": { "version": "1.99.0", diff --git a/.github/workflows/pr-ai-slop-review.lock.yml b/.github/workflows/pr-ai-slop-review.lock.yml index 54f62e8e7..f6ea859e3 100644 --- a/.github/workflows/pr-ai-slop-review.lock.yml +++ b/.github/workflows/pr-ai-slop-review.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ff92739239d50701d56d10eb77fff5083a556fe471496674aa066c4f73b6b6d2","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8296ef86aad04ad225d29c7fe51dd18dfa50f258c46e8bd24e443193cc6f80fc","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"924af5fdc64061cfbf66fb584c8b07e2ac230c60","version":"v0.89.21"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23","digest":"sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23","digest":"sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23","digest":"sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.25","digest":"sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.12.2","digest":"sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6","pinned_image":"ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","issue_read","list_issue_types","list_issues","list_pull_requests","pull_request_read","search_issues","search_pull_requests"]},{"name":"safeoutputs","tools":["add_comment","add_labels","missing_data","missing_tool","noop","remove_labels"]}]} # This file was automatically generated by gh-aw (v0.89.21). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -541,7 +541,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Make Copilot quota exhaustion non-blocking - run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' return { action: \"stop\", exitCode: 0 };',\n ' }',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE" + run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' let quotaErrorMsg = null;',\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' quotaErrorMsg = { action: \"stop\" };',\n ' }',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' quotaErrorMsg = { action: \"stop\", exitCode: 0 };',\n ' }',\n ' if (quotaErrorMsg) {',\n ' return quotaErrorMsg;',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64 ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0 ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086 ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6 diff --git a/.github/workflows/pr-ai-slop-review.md b/.github/workflows/pr-ai-slop-review.md index d23fd17cd..63c8799c7 100644 --- a/.github/workflows/pr-ai-slop-review.md +++ b/.github/workflows/pr-ai-slop-review.md @@ -41,13 +41,17 @@ pre-agent-steps: ' }', ].join('\n'); const nonBlockingQuotaBranch = [ + ' let quotaErrorMsg = null;', ' if (isQuotaExceeded) {', ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);', - ' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {', - ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);', - ' return { action: "stop", exitCode: 0 };', - ' }', - ' return { action: "stop" };', + ' quotaErrorMsg = { action: "stop" };', + ' }', + ' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {', + ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);', + ' quotaErrorMsg = { action: "stop", exitCode: 0 };', + ' }', + ' if (quotaErrorMsg) {', + ' return quotaErrorMsg;', ' }', ].join('\n'); diff --git a/.github/workflows/signpath-test.yml b/.github/workflows/signpath-test.yml new file mode 100644 index 000000000..6d8c0c864 --- /dev/null +++ b/.github/workflows/signpath-test.yml @@ -0,0 +1,90 @@ +name: SignPath Test + +on: + workflow_dispatch: + +permissions: + contents: read + actions: read + +env: + CARGO_INCREMENTAL: 0 + HUSKY: 0 + +jobs: + sign-windows: + runs-on: windows-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.99.0' + targets: x86_64-pc-windows-msvc + + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: '24.21.0' + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare build resources + run: pnpm run prebuild x86_64-pc-windows-msvc + + - name: Build test installer + shell: pwsh + env: + NODE_OPTIONS: '--max-old-space-size=4096' + run: | + $configPath = Join-Path $env:RUNNER_TEMP 'signpath-test.json' + '{"bundle":{"createUpdaterArtifacts":false}}' | + Set-Content -LiteralPath $configPath -Encoding utf8 + pnpm exec tauri build --target x86_64-pc-windows-msvc --bundles nsis --config $configPath + + - name: Upload unsigned installer + id: unsigned + uses: actions/upload-artifact@v7 + with: + name: windows-x64-unsigned + path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe + if-no-files-found: error + + - name: Sign with SignPath test certificate + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: '5b298f4d-010d-4bae-b665-70551a58839c' + project-slug: 'clash-verge-rev' + signing-policy-slug: 'test-signing' + artifact-configuration-slug: 'initial' + github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: signed + + - name: Inspect installer signature + shell: pwsh + run: | + Get-ChildItem -Path signed/*-setup.exe | ForEach-Object { + $signature = Get-AuthenticodeSignature -LiteralPath $_.FullName + if (-not $signature.SignerCertificate) { + throw "No Authenticode signer certificate found for $($_.Name)" + } + $signature | Format-List Path, Status, StatusMessage, SignerCertificate + } + + - name: Upload signed test installer + uses: actions/upload-artifact@v7 + with: + name: windows-x64-test-signed + path: signed/*-setup.exe + if-no-files-found: error diff --git a/.tool-versions b/.tool-versions index f1b1d92d7..25be9ef6d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ nodejs 24.21.0 -pnpm 12.10.0 +pnpm 12.11.2 rust 1.99.0 diff --git a/Cargo.lock b/Cargo.lock index b8517d3e2..32c453c88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clash-verge" -version = "2.5.8" +version = "2.5.9" dependencies = [ "aes-gcm", "anyhow", @@ -1175,7 +1175,7 @@ dependencies = [ "windows-service", "windows-sys 0.61.2", "winreg 0.56.0", - "zip 8.6.0", + "zip 9.0.1", ] [[package]] @@ -4048,9 +4048,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.16.5" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" dependencies = [ "sha2 0.11.0", ] @@ -4223,8 +4223,7 @@ dependencies = [ [[package]] name = "muda" version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +source = "git+https://github.com/wonfen/muda.git?rev=547de49352791653fbdfa785f20f3c7ece8468e2#547de49352791653fbdfa785f20f3c7ece8468e2" dependencies = [ "crossbeam-channel", "dpi", @@ -10151,9 +10150,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" dependencies = [ "aes", "bzip2", @@ -10166,6 +10165,7 @@ dependencies = [ "indexmap 2.14.0", "lzma-rust2", "memchr", + "nix", "pbkdf2", "ppmd-rust", "sha1 0.11.0", @@ -10283,8 +10283,3 @@ dependencies = [ "syn 3.0.5", "winnow 1.0.4", ] - -[[patch.unused]] -name = "tray-icon" -version = "0.24.1" -source = "git+https://github.com/Tunglies/tray-icon.git?branch=fix%2Fmacos-27-click-reentrancy#5750c67d02d32f2af100e120ad665959d4268cef" diff --git a/Cargo.toml b/Cargo.toml index faa6e77b9..3dffbca78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,10 +47,8 @@ codegen-units = 1 split-debuginfo = "unpacked" [patch.crates-io] -# macOS Golden Gate fix for tray-icon click reentrancy issue -# Remove the following patch once the fix is merged upstream(tauri) -# https://github.com/tauri-apps/tray-icon/pull/365 -tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" } +# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch. +muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" } [workspace.dependencies] clash-verge-draft = { path = "crates/clash-verge-draft" } diff --git a/Changelog.md b/Changelog.md index f2033f2d2..ee0c8c66c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,24 +1,9 @@ -## v2.5.8 +## v2.5.9
🐞 修复问题 -- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 -- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 -- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 -- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 -- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 -- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题 -- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题 - -**🖥️ Windows** - -- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 -- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 - -**🍎 macOS** - -- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 +- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
@@ -31,11 +16,9 @@
🚀 优化改进 -- 优化端口设置窗口的实现,精简重复代码 -- 优化侧边栏流量图表的 CPU 占用 **🖥️ Windows** -- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法 +- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
diff --git a/README.md b/README.md index 2a65c2e6b..551997718 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,14 @@ A Clash Meta GUI based on Tauri Languages: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Preview diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 0441ea1be..81dec23dc 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول. adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول. +core: + alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.' launchGuard: currentExeError: 'تعذر تحديد موقع التطبيق: {error}' homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}' @@ -68,6 +70,8 @@ tray: global: عام profiles: الملفات التعريفية proxies: وكلاء + testLatency: اختبار التأخير + testingLatency: جارٍ اختبار التأخير… systemProxy: وكيل النظام tunMode: وضع TUN closeAllConnections: إغلاق كل الاتصالات diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index 6bd24bd95..058f3b3d3 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. +core: + alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.' launchGuard: currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}' homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}' @@ -68,6 +70,8 @@ tray: global: Global profiles: Profile proxies: Proxy + testLatency: Latenz testen + testingLatency: Latenz wird getestet… systemProxy: Systemproxy tunMode: TUN-Modus closeAllConnections: Alle Verbindungen schließen diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 4a32fdc65..9db8ff3cd 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. +core: + alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.' launchGuard: currentExeError: 'Unable to determine the application location: {error}' homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}' @@ -68,6 +70,8 @@ tray: global: Global profiles: Profiles proxies: Proxies + testLatency: Test Latency + testingLatency: Testing latency… systemProxy: System Proxy tunMode: TUN Mode closeAllConnections: Close All Connections diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index 87f115333..85ad7d95d 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador. adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador. +core: + alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.' launchGuard: currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}' homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}' @@ -68,6 +70,8 @@ tray: global: Global profiles: Perfiles proxies: Proxies + testLatency: Probar latencia + testingLatency: Probando latencia… systemProxy: Proxy del sistema tunMode: Modo TUN closeAllConnections: Cerrar todas las conexiones diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index 8c538a4a7..2a3453405 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد. adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد. +core: + alreadyRunning: 'هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید.' launchGuard: currentExeError: 'مکان برنامه مشخص نشد: {error}' homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}' @@ -68,6 +70,8 @@ tray: global: سراسری profiles: پروفایل‌ها proxies: پروکسی‌ها + testLatency: آزمایش تأخیر + testingLatency: در حال آزمایش تأخیر… systemProxy: پروکسی سیستم tunMode: حالت TUN closeAllConnections: بستن همه اتصال‌ها diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 00c4788be..9c037b90d 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator. adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator. +core: + alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.' launchGuard: currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}' homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}' @@ -68,6 +70,8 @@ tray: global: Global profiles: Profil proxies: Proksi + testLatency: Uji Latensi + testingLatency: Menguji latensi… systemProxy: Proksi Sistem tunMode: Mode TUN closeAllConnections: Tutup Semua Koneksi diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index eea0c41d1..327383e8b 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。 adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。 +core: + alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。 launchGuard: currentExeError: アプリケーションの場所を確認できませんでした:{error} homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error} @@ -68,6 +70,8 @@ tray: global: グローバル profiles: プロファイル proxies: プロキシ + testLatency: 遅延テスト + testingLatency: 遅延をテスト中… systemProxy: システムプロキシ tunMode: TUN モード closeAllConnections: すべての接続を閉じる diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index 7166e8c72..9fcaa6a79 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다. adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다. +core: + alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요. launchGuard: currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}' homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}' @@ -68,6 +70,8 @@ tray: global: 글로벌 profiles: 프로필 proxies: 프록시 + testLatency: 지연 테스트 + testingLatency: 지연 테스트 중… systemProxy: 시스템 프록시 tunMode: TUN 모드 closeAllConnections: 모든 연결 닫기 diff --git a/crates/clash-verge-i18n/locales/pt.yml b/crates/clash-verge-i18n/locales/pt.yml new file mode 100644 index 000000000..5999d1b91 --- /dev/null +++ b/crates/clash-verge-i18n/locales/pt.yml @@ -0,0 +1,94 @@ +_version: 1 +notifications: + dashboardToggled: + title: Painel + body: A visibilidade do painel foi atualizada. + clashModeChanged: + title: Troca de modo + body: Alterado para {mode}. + systemProxyToggled: + title: Proxy do sistema + 'on': O proxy do sistema foi ativado. + 'off': O proxy do sistema foi desativado. + systemProxyFailed: + title: Proxy do sistema + body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo. + systemProxyGuardStopped: + title: Proteção do proxy do sistema + body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo. + tunModeToggled: + title: Modo TUN + 'on': O modo TUN foi ativado. + 'off': O modo TUN foi desativado. + lightweightModeEntered: + title: Modo leve + body: Modo leve ativado. + profilesReactivated: + title: Perfis + body: Perfil reativado. + appQuit: + title: Prestes a sair + body: O Clash Verge está prestes a sair. + updateReady: + title: Atualização do Clash Verge + body: Uma nova versão (v{version}) foi baixada e está pronta para instalar. + installNow: Instalar agora + later: Depois +service: + adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador. + adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador. +core: + alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.' +launchGuard: + currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}' + homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}' + translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente. + rejected: |- + Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos. + + {reason} + notInBundle: O executável não está dentro de um pacote de aplicativo. + canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}' + bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado. + replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos? + movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora? + createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}' + moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente. + replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}' + relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos. + cancel: Cancelar + move: Mover + ok: OK +tray: + dashboard: Painel + ruleMode: Modo regra + globalMode: Modo global + directMode: Modo direto + outboundModes: Modos de saída + rule: Regra + direct: Direto + global: Global + profiles: Perfis + proxies: Proxies + testLatency: Test Latency + testingLatency: Testing latency… + systemProxy: Proxy do sistema + tunMode: Modo TUN + closeAllConnections: Fechar todas as conexões + lightweightMode: Modo leve + copyEnv: Copiar variáveis de ambiente + confDir: Diretório de configuração + coreDir: Diretório do núcleo + logsDir: Diretório de logs + openDir: Abrir diretório + appLog: Log do aplicativo + coreLog: Log do núcleo + restartClash: Reiniciar o núcleo do Clash + restartApp: Reiniciar o aplicativo + vergeVersion: Versão do Verge + more: Mais + exit: Sair + tooltip: + systemProxy: Proxy do sistema + tun: TUN + profile: Perfil diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 2c931fa1d..630366a8f 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Для установки службы Clash Verge требуются права администратора. adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора. +core: + alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.' launchGuard: currentExeError: 'Не удалось определить расположение приложения: {error}' homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}' @@ -68,6 +70,8 @@ tray: global: Глобальный profiles: Профили proxies: Прокси + testLatency: Проверить задержку + testingLatency: Проверка задержки… systemProxy: Системный прокси tunMode: Режим TUN closeAllConnections: Закрыть все соединения diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index cf3c666a4..3ced8c82d 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir. adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir. +core: + alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.' launchGuard: currentExeError: 'Uygulamanın konumu belirlenemedi: {error}' homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}' @@ -68,6 +70,8 @@ tray: global: Küresel profiles: Profiller proxies: Vekil'ler + testLatency: Gecikmeyi Test Et + testingLatency: Gecikme test ediliyor… systemProxy: Sistem Vekil'i tunMode: TUN Modu closeAllConnections: Tüm Bağlantıları Kapat diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 4067dfdd7..2f067cc07 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк. adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк. +core: + alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.' launchGuard: currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}' homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}' @@ -68,6 +70,8 @@ tray: global: Глобаль profiles: Профильләр proxies: Проксилар + testLatency: Кичектерүне тикшерү + testingLatency: Кичектерү тикшерелә… systemProxy: Системалы прокси tunMode: TUN режимы closeAllConnections: Барлык тоташуларны ябу diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index 8774cbefb..dbb40569b 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限 adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限 +core: + alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。 launchGuard: currentExeError: 无法确定应用位置:{error} homeDirError: 无法确定当前用户主目录,应用将退出:{error} @@ -68,6 +70,8 @@ tray: global: 全局 profiles: 订阅 proxies: 代理 + testLatency: 测试延迟 + testingLatency: 正在测试延迟… systemProxy: 系统代理 tunMode: TUN 模式 closeAllConnections: 关闭所有连接 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index da73ba929..ccf2ab2ac 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限 adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限 +core: + alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。 launchGuard: currentExeError: 無法判斷應用程式位置:{error} homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error} @@ -68,6 +70,8 @@ tray: global: 全域 profiles: 訂閱 proxies: 代理 + testLatency: 測試延遲 + testingLatency: 正在測試延遲… systemProxy: 系統代理 tunMode: 虛擬網路介面卡模式 closeAllConnections: 關閉所有連線 diff --git a/docs/Changelog.history.md b/docs/Changelog.history.md index 33b61d646..c50784676 100644 --- a/docs/Changelog.history.md +++ b/docs/Changelog.history.md @@ -1,3 +1,55 @@ +## v2.5.8 + +
+ 🐞 修复问题 + +- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 +- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 +- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 +- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题 +- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 +- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 +- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 +- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 +- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题 +- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题 +- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题 + +**🖥️ Windows** + +- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 +- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 +- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题 + +**🍎 macOS** + +- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 + +**🖥️/🍎 Windows/macOS** + +- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题 + +
+ +
+ ✨ 新增功能 + +- 新增系统托盘代理组中的「测试延迟」菜单项 + +
+ +
+ 🚀 优化改进 + +- 优化端口设置窗口的实现,精简重复代码 +- 优化侧边栏流量图表的 CPU 占用 + +**🖥️ Windows** + +- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法 + +
+ ## v2.5.7
diff --git a/docs/README_en.md b/docs/README_en.md index 414a75366..6652cc35b 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -11,13 +11,14 @@ A Clash Meta GUI built with Tauri<

Languages: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Preview diff --git a/docs/README_es.md b/docs/README_es.md index 2df191696..de797c82e 100644 --- a/docs/README_es.md +++ b/docs/README_es.md @@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con Idiomas: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Vista previa diff --git a/docs/README_fa.md b/docs/README_fa.md index 0c94631b8..b8ee7383f 100644 --- a/docs/README_fa.md +++ b/docs/README_fa.md @@ -11,13 +11,14 @@

زبان‌ها: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## پیش‌نمایش diff --git a/docs/README_ja.md b/docs/README_ja.md index 6a06023ca..186a5f8d3 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -11,13 +11,14 @@

言語: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## プレビュー diff --git a/docs/README_ko.md b/docs/README_ko.md index a0dee6abb..d0ffbb3a5 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -11,13 +11,14 @@

언어: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## 미리보기 diff --git a/docs/README_pt.md b/docs/README_pt.md new file mode 100644 index 000000000..2b8b7bb50 --- /dev/null +++ b/docs/README_pt.md @@ -0,0 +1,125 @@ +

+ Clash +
+ Continuação do Clash Verge +
+

+ +

+Uma interface gráfica para o Clash Meta construída com Tauri. +

+ +

+ Idiomas: + English · + Español · + فارسی · + 日本語 · + 한국어 · + Português · + Русский · + 简体中文 +

+ +## Pré-visualização + +| Escuro | Claro | +| -------------------------------------- | -------------------------------------- | +| ![Visual escuro](./preview_dark.png) | ![Visual claro](./preview_light.png) | + +## Instalação + +Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.
+Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple). + +#### Como escolher o canal de lançamento + +| Canal | Descrição | Link | +| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### Guias de instalação e perguntas frequentes + +Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes. + +### Canal do Telegram + +Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades. + +--- + +## Promoções + +#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6) + +- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria. +- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6) +- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos) +- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual +- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada +- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos +- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge) +- Desbloqueia serviços de streaming e acesso ao ChatGPT +- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) + +### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com) + +- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas. +- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem. +- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados. +- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente. +- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com) +- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai) + +--- + +## Funcionalidades + +- Baseado em Rust de alto desempenho e no framework Tauri 2 +- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha` +- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection` +- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações +- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual) +- Editores visuais para nós e regras +- Backup e sincronização via WebDAV + +### Perguntas frequentes + +Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma. + +### Doações + +[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev) + +## Desenvolvimento + +Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição. + +Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## Contribuições + +Issues e pull requests são bem-vindos. + +## Agradecimentos + +O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles: + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux. +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web. +- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go. +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go. +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS. +- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida. + +## Licença + +Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes. diff --git a/docs/README_ru.md b/docs/README_ru.md index 81fd112bb..e0de81b24 100644 --- a/docs/README_ru.md +++ b/docs/README_ru.md @@ -11,13 +11,14 @@ Clash Meta GUI базируется на Языки: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Предпросмотр diff --git a/package.json b/package.json index f80ad179d..9ef745150 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clash-verge", - "version": "2.5.8", + "version": "2.5.9", "license": "GPL-3.0-only", "scripts": { "prepare": "husky || true", @@ -146,5 +146,5 @@ ] }, "type": "module", - "packageManager": "pnpm@12.10.0" + "packageManager": "pnpm@12.11.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24d7b008e..ceac328bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.10.0 - version: 12.10.0 + specifier: 12.11.2 + version: 12.11.2 packages: - '@pnpm/exe.android-arm64@12.10.0': - resolution: {integrity: sha512-QMMRzOI/7CU4xLbKY6O2BZn3VI4+a8LLyim18H7it8llBWuBPMqDqUURE8045k+7lI8/lI2/tElhyKptEviKDQ==} + '@pnpm/exe.android-arm64@12.11.2': + resolution: {integrity: sha512-owLw13OfPkEoY+hLBNeeG+dCFhywr7q2d/EHPz4mNL2RhKnv8gNwMARkjkiDQu234aItVwFNDeGnmQXm+ikkAg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.10.0': - resolution: {integrity: sha512-41zwlROW+t523Nfqo4rWp8IPiSK5vnAWrnCey26Je45rX1SMNc4ABY7OFmPw41/t+Y0CE+4tjTj+0s9YGqPM5w==} + '@pnpm/exe.android-x64@12.11.2': + resolution: {integrity: sha512-GYPkVebl4iokOA/4MPSpGoUVfFlsth7uqOCe1XiaiM23LAdvUOMJBObqHJijr89ZqjwHm9pO6vO45GRwJ2U2qA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.10.0': - resolution: {integrity: sha512-L+r7AD8Fjf8Vcc4ZRJl+Pjzc27CIeQ/FiRwPmOluFO2UcXubmGHdW+OuMUxrU0vkoXiGR61Ft0RtaPwTRY4W/g==} + '@pnpm/exe.darwin-arm64@12.11.2': + resolution: {integrity: sha512-n9M4On66y8FDxUAKgMO1FXt0qihyG4e+56p3/F0uo8Bx9KplW8BwKVnXigzCJ8aNM6sVVPkOE6M9XXbeVIxnZA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.10.0': - resolution: {integrity: sha512-/TCF1K6E+4PnbkLgqGS1Iqlf5bHp6FDzuXgu6++PMMAFkF7Yk5EF3BcfG1gYYjpkvlJBccvVMsRBnWFbSyqu1w==} + '@pnpm/exe.darwin-x64@12.11.2': + resolution: {integrity: sha512-oSW7n3sBT0c0cyk4kj10T2Y6Mpm+OFEiCAWozgX039tdoI6yqJF1uaFD3sw7ugLdu6VV3+uY0pa1TUZIPdxDWA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.10.0': - resolution: {integrity: sha512-k7/WFA91E2qhnR/2EOEv402U8bjP758y2pybbdiB5Hrd4KhgviVOJp5cFrXpKgd8HZJs/RZN7cdmMvU2Q2BVgg==} + '@pnpm/exe.freebsd-x64@12.11.2': + resolution: {integrity: sha512-qbzc3KoDpa/Lkf8vs3jnDe6dUUbso+6eLIyssYFMiliLpYNxn8dirk11YoNnOQcdHVB50v5RFls1Yid6ttT5pg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.10.0': - resolution: {integrity: sha512-ChD8bN0lRCRaL+CMOaMUgZQ4WHzEkDXmDHrZgXgdoo6yymQKKzr66Ic7Sl/D7Ky1cVUcFEzHjlq4fGDJwt6Lqg==} + '@pnpm/exe.linux-arm64-musl@12.11.2': + resolution: {integrity: sha512-uMEYDwKRktGwANubQ+kYCwmhNwg3Ik6qK89ahsbQ5cP30R/5FVJb/T99vr9XRUBC/A7mZI6eeT92oyVas9ACyg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.10.0': - resolution: {integrity: sha512-egl5RUSQFh7kETBdmMhaTuaQKP1pjH6Cxq370zgpFFKJXF+GGkIzK+jLNazrjX9pKIWF3iqn7kUeyP6yKxzBoA==} + '@pnpm/exe.linux-arm64@12.11.2': + resolution: {integrity: sha512-6e8ZAQUwFXAWHHHv5FesWqf/kEK04wuitBv5f/gxzkC0DiBfaixr+f+6JQede8SF4Ji5LcMwVFJbzAtffpWxEg==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.10.0': - resolution: {integrity: sha512-YJ6bEZ+stPpv71CGfGQQeyIpMx/zSMEcAXQzG+rjBtzVfyNsxYciRevhJ+PE9b2qKqN78Mh/1tOMmx9ly4VXGw==} + '@pnpm/exe.linux-ppc64@12.11.2': + resolution: {integrity: sha512-Ui8A4nepkVupx2VpuFfysEluMvWK6gTB5rgVts7Ss/+8dj0RD28x3F/seNeH/zSypgC6yKdV6qsKVQh/uffTuQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.10.0': - resolution: {integrity: sha512-njfd4euctC1zH1nVBbm6+r1c+0y/TtgqN523rk/KMSxqItpV5QAVa9Yz48Rt5DOIKmuLq7xXOhsQodBlbTk3pw==} + '@pnpm/exe.linux-riscv64@12.11.2': + resolution: {integrity: sha512-i1yLdQ5bu+wjQ+srtFoJAaY2gN/+oXHFdjul4llCeoDxHE0rTIG3R41NhHfCjyFPW4+CpbBT58933CL9sMOCnA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.10.0': - resolution: {integrity: sha512-4JI2F8pYGMcsG6ny61OiBSvZeDozc64TbPozbTU2X5uCsaFCsCvcpr/WwyypwCv+/mNjcIhaKhlxpwJYsnteIg==} + '@pnpm/exe.linux-s390x@12.11.2': + resolution: {integrity: sha512-YS1/lhPjjTsN4QL50VaFZKRa3Uc/bDnhE8eKbJMFvuX06UuFS5YaiDg04APoZdfVSGqTfX27QAg7rfLMSWaqxA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.10.0': - resolution: {integrity: sha512-xayd2iG8ugdvjlGeOvsfm+iSe0bFu9RTX3/8HWOLAQmHdbLKi4hPbxP0S4c9tj8pNej/IxVBQLJksRXFlhNjPw==} + '@pnpm/exe.linux-x64-musl@12.11.2': + resolution: {integrity: sha512-7eIxQR05fvJxCujUSKp5KTtehJ7QqrgqP/HD1+BcB/SaV2Be4XI1PEHiOgA1zxsiMIeMvTF6E4G4dYHrFjeXRg==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.10.0': - resolution: {integrity: sha512-PFkzWEghFLKuocxMHtn57mlJ59f5aBbqjX6FCUtiunEHqxTxOibSduQPs3Llm6VWbAXBjD3VrX29Z+bjU1Dpyw==} + '@pnpm/exe.linux-x64@12.11.2': + resolution: {integrity: sha512-FK7QKe24AYgppJwO3mpooYptduuvIGqKJPi5ac7MdnWkjUy46JBfYhCW0grnkWKC8YOf0x+vGviuQqJEj3SpQQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.10.0': - resolution: {integrity: sha512-0HtTS+TLONWd/dNSczyOkK0UeZ36cbdhC/2ljZ61DPhvc4jTb7ur0yU47On0in7Im3yXwzTY4vtF8ykrDwc+NQ==} + '@pnpm/exe.win32-arm64@12.11.2': + resolution: {integrity: sha512-PCTjVDdJb6VvraZxrEeFv6EqEA0hhIsAySx28e95J2Tqpi88hLpgOXgNqV0vpxq9A+b8GLQfyk/D+1UlJPv/YQ==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.10.0': - resolution: {integrity: sha512-IcW3rehTGzJ7YnmHEWnbnJakjbBmnH0r29qiajK9+6b9psMF9IjocFr0dyitRVSfXxyW/FI5PIK3mOswGoyFTg==} + '@pnpm/exe.win32-x64@12.11.2': + resolution: {integrity: sha512-8sgX86OvRjJ3yZs/TvGR2L31wH9ScPZ+f+geKI3ikuB0RRiK3i4LlTRO8OXN6pdRqj8epMde4312MCjj7JsJdA==} cpu: [x64] os: [win32] - pnpm@12.10.0: - resolution: {integrity: sha512-Eip7bd+dQCaLwNsal4CJm5Xk+out/Ke9RQYVrYz3wmWfX1acIO18U4hrx7CNDVClT0rVBwaEf1aC7GxQSEFlOQ==} + pnpm@12.11.2: + resolution: {integrity: sha512-JaUWUqGWeFr18ovabf6NxYLr76SsHRgCIc4TkCMcX7dmN4IupLbkYzznSs3LCnf3Ja8RutOBd/3t2u6BipVcuQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.10.0': + '@pnpm/exe.android-arm64@12.11.2': optional: true - '@pnpm/exe.android-x64@12.10.0': + '@pnpm/exe.android-x64@12.11.2': optional: true - '@pnpm/exe.darwin-arm64@12.10.0': + '@pnpm/exe.darwin-arm64@12.11.2': optional: true - '@pnpm/exe.darwin-x64@12.10.0': + '@pnpm/exe.darwin-x64@12.11.2': optional: true - '@pnpm/exe.freebsd-x64@12.10.0': + '@pnpm/exe.freebsd-x64@12.11.2': optional: true - '@pnpm/exe.linux-arm64-musl@12.10.0': + '@pnpm/exe.linux-arm64-musl@12.11.2': optional: true - '@pnpm/exe.linux-arm64@12.10.0': + '@pnpm/exe.linux-arm64@12.11.2': optional: true - '@pnpm/exe.linux-ppc64@12.10.0': + '@pnpm/exe.linux-ppc64@12.11.2': optional: true - '@pnpm/exe.linux-riscv64@12.10.0': + '@pnpm/exe.linux-riscv64@12.11.2': optional: true - '@pnpm/exe.linux-s390x@12.10.0': + '@pnpm/exe.linux-s390x@12.11.2': optional: true - '@pnpm/exe.linux-x64-musl@12.10.0': + '@pnpm/exe.linux-x64-musl@12.11.2': optional: true - '@pnpm/exe.linux-x64@12.10.0': + '@pnpm/exe.linux-x64@12.11.2': optional: true - '@pnpm/exe.win32-arm64@12.10.0': + '@pnpm/exe.win32-arm64@12.11.2': optional: true - '@pnpm/exe.win32-x64@12.10.0': + '@pnpm/exe.win32-x64@12.11.2': optional: true - pnpm@12.10.0: + pnpm@12.11.2: optionalDependencies: - '@pnpm/exe.android-arm64': 12.10.0 - '@pnpm/exe.android-x64': 12.10.0 - '@pnpm/exe.darwin-arm64': 12.10.0 - '@pnpm/exe.darwin-x64': 12.10.0 - '@pnpm/exe.freebsd-x64': 12.10.0 - '@pnpm/exe.linux-arm64': 12.10.0 - '@pnpm/exe.linux-arm64-musl': 12.10.0 - '@pnpm/exe.linux-ppc64': 12.10.0 - '@pnpm/exe.linux-riscv64': 12.10.0 - '@pnpm/exe.linux-s390x': 12.10.0 - '@pnpm/exe.linux-x64': 12.10.0 - '@pnpm/exe.linux-x64-musl': 12.10.0 - '@pnpm/exe.win32-arm64': 12.10.0 - '@pnpm/exe.win32-x64': 12.10.0 + '@pnpm/exe.android-arm64': 12.11.2 + '@pnpm/exe.android-x64': 12.11.2 + '@pnpm/exe.darwin-arm64': 12.11.2 + '@pnpm/exe.darwin-x64': 12.11.2 + '@pnpm/exe.freebsd-x64': 12.11.2 + '@pnpm/exe.linux-arm64': 12.11.2 + '@pnpm/exe.linux-arm64-musl': 12.11.2 + '@pnpm/exe.linux-ppc64': 12.11.2 + '@pnpm/exe.linux-riscv64': 12.11.2 + '@pnpm/exe.linux-s390x': 12.11.2 + '@pnpm/exe.linux-x64': 12.11.2 + '@pnpm/exe.linux-x64-musl': 12.11.2 + '@pnpm/exe.win32-arm64': 12.11.2 + '@pnpm/exe.win32-x64': 12.11.2 --- lockfileVersion: '9.0' diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e8dcc4b7..6a1bc7942 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clash-verge" -version = "2.5.8" +version = "2.5.9" description = "clash verge" authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] license = "GPL-3.0-only" @@ -80,7 +80,7 @@ tauri-plugin-fs = "2.6.0" tauri-plugin-process = "2.4.0" tauri-plugin-deep-link = "2.6.1" tauri-plugin-window-state = "2.5.0" -zip = "8.6.0" +zip = "9.0.1" flate2 = "1.1.10" reqwest_dav = "0.3.3" aes-gcm = { version = "0.11.1" } diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index d872f7cac..8c20504e8 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -373,6 +373,8 @@ pub struct ProfilePatch { #[specta(type = Option)] pub url: Option, pub selected: Option>, + #[specta(type = Option>)] + pub latency_test_urls: Option>, pub extra: Option, pub updated: Option, pub option: Option, @@ -387,6 +389,7 @@ impl From for PrfItem { file: patch.file, url: patch.url, selected: patch.selected, + latency_test_urls: patch.latency_test_urls, extra: patch.extra, updated: patch.updated, option: patch.option, diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index aa5521833..f20d0b6b6 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -108,20 +108,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap CmdResult { + proxy_view(None).await +} + +pub(crate) async fn proxy_view(provider_timeout: Option) -> CmdResult { let runtime = Config::runtime().await; let latest_runtime = runtime.latest_arc(); let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref()); let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref()); let mihomo = Handle::mihomo(); - let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); + let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async { + match provider_timeout { + Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers()) + .await + .ok() + .and_then(Result::ok), + None => mihomo.get_proxy_providers().await.ok(), + } + }); let proxies = proxies.stringify_err()?; Ok(ProxyViewBuilder::build(ProxyViewInput { runtime_group_order, group_scopes, proxies, - providers: providers.ok(), + providers, })) } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index da8728d21..0c99355d5 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -53,6 +53,10 @@ pub struct PrfItem { #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option>)] + pub latency_test_urls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, @@ -229,6 +233,7 @@ impl PrfItem { desc: Some(desc), file: Some(file), url: None, + latency_test_urls: None, selected: None, extra: None, option: Some(PrfOption { @@ -408,6 +413,7 @@ impl PrfItem { desc: desc.cloned(), file: Some(file), url: Some(url.as_str().into()), + latency_test_urls: None, selected: None, extra, option: Some(PrfOption { diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 4b149b716..7680fff58 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -250,6 +250,7 @@ impl IProfiles { patch!(each, item, file); patch!(each, item, url); patch!(each, item, selected); + patch!(each, item, latency_test_urls); patch!(each, item, extra); patch!(each, item, updated); patch!(each, item, option); diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index e7366aec3..ad67b6b0a 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -400,7 +400,9 @@ impl CoreManager { } #[cfg(target_os = "windows")] if matches!(*mode, RunningMode::NotRunning) { - clash_verge_service_ipc::execution::check_sidecar_available().await?; + clash_verge_service_ipc::execution::check_sidecar_available() + .await + .map_err(super::state::explain_core_occupancy)?; } if !matches!(status, ServiceStatus::SidecarAllowed) { SERVICE_MANAGER.allow_sidecar_for_session()?; @@ -640,7 +642,11 @@ impl CoreManager { { run_service_start_with_sidecar_fallback( || self.start_core_by_service(), - clash_verge_service_ipc::execution::check_sidecar_available, + || async { + clash_verge_service_ipc::execution::check_sidecar_available() + .await + .map_err(super::state::explain_core_occupancy) + }, |error| self.start_sidecar_after_service_failure(error), |reason| { crate::core::runstate::RUN_STATE @@ -685,7 +691,10 @@ impl CoreManager { ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(), _ => return Ok(()), }; - if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await { + if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available() + .await + .map_err(super::state::explain_core_occupancy) + { logging!( warn, Type::Core, diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 6cd39a300..d30549b91 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -17,6 +17,15 @@ use std::path::Path; use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_shell::ShellExt as _; +pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error { + let detail = error.to_string(); + if detail.starts_with("process verge-mihomo") && detail.ends_with("; refusing a second core") { + error.context(clash_verge_i18n::t!("core.alreadyRunning").into_owned()) + } else { + error + } +} + #[cfg(target_os = "windows")] fn sidecar_config_without_tun(yaml: &str) -> Result { use serde_yaml_ng::{Mapping, Value}; @@ -154,7 +163,8 @@ impl CoreManager { self.wait_for_sidecar_exit().await?; let execution = clash_verge_service_ipc::execution::reserve_sidecar() .await - .inspect_err(service::record_residual_service)?; + .inspect_err(service::record_residual_service) + .map_err(explain_core_occupancy)?; self.core_stopped(); let sidecar_ipc = dirs::sidecar_ipc_path()?; @@ -511,6 +521,20 @@ mod readiness_tests { time::Duration, }; + #[test] + fn core_occupancy_explanation_preserves_the_original_error() { + for detail in [ + "process verge-mihomo remains after IPC failure; refusing a second core", + "process verge-mihomo-alpha.exe (PID 123) is still running; refusing a second core", + ] { + let error = super::explain_core_occupancy(anyhow::anyhow!(detail)); + assert_eq!(error.to_string(), clash_verge_i18n::t!("core.alreadyRunning")); + assert_eq!(error.root_cause().to_string(), detail); + } + let error = super::explain_core_occupancy(anyhow::anyhow!("could not inspect remaining cores")); + assert_eq!(error.to_string(), "could not inspect remaining cores"); + } + #[tokio::test] async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> { let attempts = Arc::new(AtomicUsize::new(0)); diff --git a/src-tauri/src/core/proxy_view.rs b/src-tauri/src/core/proxy_view.rs index 9056b521b..a78729187 100644 --- a/src-tauri/src/core/proxy_view.rs +++ b/src-tauri/src/core/proxy_view.rs @@ -1,8 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde::Serialize; use tauri_plugin_mihomo::models::{ - DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, + DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, }; pub struct ProxyViewInput { @@ -77,6 +77,8 @@ pub struct ProxyGroupView { pub test_url: Option, #[specta(type = Vec)] pub history: Vec, + #[specta(type = HashMap)] + pub extra: HashMap, #[serde(flatten)] pub capabilities: ProxyCapabilities, pub members: Vec, @@ -93,6 +95,8 @@ pub struct ProxyNodeView { pub alive: bool, #[specta(type = Vec)] pub history: Vec, + #[specta(type = HashMap)] + pub extra: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -258,7 +262,7 @@ impl ProxyViewBuilder { } else { ProxyViewProviderState::Unavailable }; - let (mut core_groups, core_nodes) = partition_core(proxies); + let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some()); let (mut records, core_node_ids) = build_core_records(core_nodes); let (providers, provider_candidates) = build_provider_records(providers, &mut records); let resolver = MemberResolver { @@ -291,14 +295,14 @@ impl ProxyViewBuilder { } } -fn partition_core(proxies: Proxies) -> (BTreeMap, BTreeMap) { +fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap, BTreeMap) { let mut groups = BTreeMap::new(); let mut nodes = BTreeMap::new(); for (name, proxy) in proxies.proxies { if proxy.all.is_some() { groups.insert(name, proxy); - } else { + } else if !providers_available || proxy.provider_name.is_empty() { nodes.insert(name, proxy); } } @@ -406,6 +410,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro test_url, alive, history, + extra, udp, xudp, tfo, @@ -439,6 +444,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro icon, test_url, history, + extra, capabilities: ProxyCapabilities { udp, xudp, @@ -458,6 +464,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo test_url, alive, history, + extra, udp, xudp, tfo, @@ -473,6 +480,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo proxy_type, alive, history, + extra, id, hidden, icon, @@ -526,3 +534,62 @@ fn build_standalone(core_node_ids: &BTreeMap) -> Vec { ); standalone } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> { + let view = ProxyViewBuilder::build(ProxyViewInput { + runtime_group_order: vec!["A".into(), "B".into()], + group_scopes: ["A", "B"] + .into_iter() + .map(|name| { + ( + name.into(), + GroupScope { + providers: vec![name.into()], + exclude_types: vec![], + }, + ) + }) + .collect(), + proxies: serde_json::from_value(json!({"proxies": { + "A": {"all": ["node"], "type": "Selector"}, + "B": {"all": ["node"], "type": "Selector"}, + "node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]} + }}))?, + providers: Some(serde_json::from_value(json!({"providers": { + "A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]}, + "B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]} + }}))?), + }); + for (group, expected) in view.groups.iter().zip([32, 123]) { + let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else { + anyhow::bail!("Expected node") + }; + let record = &view.records[record_id]; + assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected)); + assert!( + matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name) + ); + } + Ok(()) + } + #[test] + fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> { + let view = ProxyViewBuilder::build(ProxyViewInput { + runtime_group_order: vec!["A".into()], + group_scopes: BTreeMap::new(), + proxies: serde_json::from_value(json!({"proxies": { + "A": {"all": ["node"], "type": "Selector"}, + "node": {"name": "node", "provider-name": "provider"} + }}))?, + providers: None, + }); + assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. })); + Ok(()) + } +} diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index 140e1d089..9b3d158dc 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -261,7 +261,7 @@ impl Default for Sysopt { } #[cfg(target_os = "windows")] -static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; +static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;*.local;"; #[cfg(target_os = "windows")] static BYPASS_SEPARATOR: &str = ";"; #[cfg(target_os = "linux")] diff --git a/src-tauri/src/core/tray/latency.rs b/src-tauri/src/core/tray/latency.rs new file mode 100644 index 000000000..5c1106ede --- /dev/null +++ b/src-tauri/src/core/tray/latency.rs @@ -0,0 +1,156 @@ +use super::Tray; +use crate::{ + Type, cmd, + config::Config, + core::{ + handle, + notify::{Refresh, announce}, + proxy_view::{ProxyMemberRef, ProxyNodeSource}, + }, + logging, +}; + +const TRAY_DELAY_TEST_CONCURRENCY: usize = 10; +const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204"; + +fn is_latency_test_url(raw: &str) -> bool { + let raw = raw.trim(); + raw.split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) + && tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https")) +} + +pub(super) fn test_url( + group: &crate::core::proxy_view::ProxyGroupView, + verge: &crate::config::IVerge, + profiles: &crate::config::IProfiles, +) -> String { + let custom_url = profiles + .current + .as_ref() + .and_then(|uid| profiles.get_item(uid).ok()) + .and_then(|profile| profile.latency_test_urls.as_ref()) + .and_then(|urls| urls.get(group.name.as_str())) + .map(|url| url.as_str()) + .filter(|url| !url.trim().is_empty()); + let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty()); + let default_url = verge + .default_latency_test + .as_deref() + .filter(|url| !url.trim().is_empty()); + custom_url + .or(group_url) + .or(default_url) + .filter(|&url| is_latency_test_url(url)) + .unwrap_or(TRAY_DEFAULT_LATENCY_URL) + .trim() + .to_owned() +} + +static RUNNING: parking_lot::Mutex> = + parking_lot::Mutex::new(std::collections::BTreeSet::new()); + +pub(super) fn is_running(group_name: &str) -> bool { + RUNNING.lock().contains(group_name) +} + +pub(super) async fn test_proxy_group_delay(group_name: &str) { + if !RUNNING.lock().insert(group_name.to_owned()) { + return; + } + Tray::global().refresh_latency_item(group_name); + let _running = scopeguard::guard(group_name, |name| { + RUNNING.lock().remove(name); + Tray::global().refresh_latency_item(name); + }); + use futures::{StreamExt as _, stream}; + + let view = match cmd::proxy::get_proxy_view().await { + Ok(view) => view, + Err(err) => { + logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}"); + return; + } + }; + let verge = Config::verge().await.latest_arc(); + + let group = view + .groups + .iter() + .chain(view.global.iter()) + .find(|group| group.name.as_str() == group_name); + let Some(group) = group else { + logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}"); + return; + }; + + let profiles = Config::profiles().await.latest_arc(); + let url = test_url(group, &verge, &profiles); + let timeout = verge + .default_latency_timeout + .filter(|timeout| *timeout > 0) + .unwrap_or(10000) as u32; + + let mut targets: Vec<(std::string::String, Option)> = Vec::with_capacity(group.members.len()); + for member in &group.members { + match member { + ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) { + Some(ProxyNodeSource::Provider { + provider_name, + proxy_name, + }) => targets.push((proxy_name.clone(), Some(provider_name.clone()))), + _ => targets.push((name.clone(), None)), + }, + ProxyMemberRef::Group { name } => targets.push((name.clone(), None)), + ProxyMemberRef::Unresolved { .. } => {} + } + } + let mihomo = handle::Handle::mihomo(); + + stream::iter(targets.into_iter().map(move |(member, provider)| { + let url = url.clone(); + async move { + let result = match provider { + Some(provider_name) => { + mihomo + .healthcheck_node_in_provider(&provider_name, &member, &url, timeout) + .await + } + None => mihomo.delay_proxy_by_name(&member, &url, timeout).await, + }; + if let Err(err) = result { + logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}"); + } + } + })) + .buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY) + .for_each(|()| std::future::ready(())) + .await; + + if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await { + logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}"); + } + + announce(Refresh::Proxies); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latency_urls_require_a_valid_http_host_and_port() { + for invalid in [ + "http://?", + "http://host:invalid", + "http://bad host/", + "ftp://localhost", + "http:localhost", + ] { + assert!(!is_latency_test_url(invalid), "{invalid}"); + } + for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] { + assert!(is_latency_test_url(valid), "{valid}"); + } + } +} diff --git a/src-tauri/src/core/tray/menu_def.rs b/src-tauri/src/core/tray/menu_def.rs index 7012ce219..6c9641773 100644 --- a/src-tauri/src/core/tray/menu_def.rs +++ b/src-tauri/src/core/tray/menu_def.rs @@ -32,6 +32,7 @@ define_menu! { outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes", profiles => PROFILES, "tray_profiles", "tray.profiles", proxies => PROXIES, "tray_proxies", "tray.proxies", + test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency", system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy", tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode", close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections", diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 3d7f0aaf5..221f23aa1 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1,5 +1,6 @@ use crate::config::{IProfilePreview, IVerge}; use crate::core::lightweight; +use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1}; use crate::core::tray::menu_def::TrayAction; use crate::process::AsyncHandler; use crate::singleton; @@ -14,7 +15,6 @@ use crate::{ use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_logging::logging_error; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; -use tauri_plugin_mihomo::models::Proxies; use tokio::fs; use super::handle; @@ -28,6 +28,7 @@ use tauri::{ menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; +mod latency; mod menu_def; #[cfg(target_os = "macos")] mod speed_task; @@ -55,8 +56,25 @@ enum IconKind { Tun, } +struct LatencyMenuItem { + item: MenuItem, + running: bool, + proxies: Vec, + provider_state: ProxyViewProviderState, +} + +struct ProxyLatencyItem { + item: CheckMenuItem, + name: std::string::String, + provider_name: Option, + text: std::string::String, +} + pub struct Tray { limiter: SystemLimiter, + proxy_hover_limiter: SystemLimiter, + menu_update: tokio::sync::Mutex<()>, + latency_items: parking_lot::Mutex>, #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController, } @@ -130,6 +148,9 @@ impl Default for Tray { fn default() -> Self { Self { limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), + proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock), + menu_update: tokio::sync::Mutex::new(()), + latency_items: parking_lot::Mutex::new(HashMap::new()), #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController::new(), } @@ -195,6 +216,7 @@ impl Tray { } async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> { + let _update = self.menu_update.lock().await; let Some(tray) = app_handle.tray_by_id(TRAY_ID) else { logging!(warn, Type::Tray, "Failed to update tray menu: tray not found"); return Ok(()); @@ -219,29 +241,133 @@ impl Tray { let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); let is_lightweight_mode = is_in_lightweight_mode(); - logging_error!( - Type::Tray, - tray.set_menu(Some( - create_tray_menu( - app_handle, - Some(mode.as_str()), - *system_proxy, - *tun_mode, - tun_mode_available, - profiles_preview, - TrayMenuOptions { - is_lightweight_mode, - include_proxy_groups, - }, - ) - .await?, - )) - ); + let (menu, latency_items) = create_tray_menu( + app_handle, + Some(mode.as_str()), + *system_proxy, + *tun_mode, + tun_mode_available, + profiles_preview, + TrayMenuOptions { + is_lightweight_mode, + include_proxy_groups, + }, + ) + .await?; + tray.set_menu(Some(menu))?; + let groups: Vec<_> = latency_items.keys().cloned().collect(); + *self.latency_items.lock() = latency_items; + for group in groups { + self.refresh_latency_item(&group); + } logging!(debug, Type::Tray, "托盘菜单更新成功"); Ok(()) } + async fn refresh_proxy_items(&'static self) { + let Ok(update) = self.menu_update.try_lock() else { + return; + }; + if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() { + return; + } + let Ok(Ok(view)) = tokio::time::timeout( + Duration::from_secs(1), + cmd::proxy::proxy_view(Some(Duration::from_millis(750))), + ) + .await + else { + return; + }; + if self + .latency_items + .lock() + .values() + .any(|entry| entry.provider_state != view.provider_state) + { + // Provider fallback changes member identities; rebuild through the existing queue. + drop(update); + logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await); + return; + } + let verge = Config::verge().await.latest_arc(); + let profiles = Config::profiles().await.latest_arc(); + logging_error!( + Type::Tray, + handle::Handle::app_handle().run_on_main_thread(move || { + // Keep rebuilding serialized until these handles have received their snapshot. + let _update = update; + let mut items = self.latency_items.lock(); + for (group_name, entry) in items.iter_mut() { + let Some(group) = find_group(&view, group_name) else { + continue; + }; + let url = latency::test_url(group, &verge, &profiles); + let mut members = HashMap::with_capacity(group.members.len()); + for member in &group.members { + members + .entry((member_name(member), member_provider(&view, member))) + .or_insert(member); + } + for proxy in &mut entry.proxies { + let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else { + continue; + }; + let text = proxy_item_text(&view, member, &url, &verge); + let checked = group.now.as_ref() == Some(&proxy.name); + if text != proxy.text { + match proxy.item.set_text(&text) { + Ok(()) => proxy.text = text, + Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"), + } + } + match proxy.item.is_checked() { + Ok(current) if current != checked => { + logging_error!(Type::Tray, proxy.item.set_checked(checked)) + } + Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"), + _ => {} + } + } + } + }) + ); + } + + fn refresh_latency_item(&self, group_name: &str) { + let group_name = group_name.to_owned(); + logging_error!( + Type::Tray, + handle::Handle::app_handle().run_on_main_thread(move || { + Self::global().refresh_latency_item_on_main_thread(&group_name); + }) + ); + } + + fn refresh_latency_item_on_main_thread(&self, group_name: &str) { + let running = latency::is_running(group_name); + let item = { + let mut items = self.latency_items.lock(); + items.get_mut(group_name).and_then(|entry| { + if entry.running == running { + return None; + } + entry.running = running; + Some(entry.item.clone()) + }) + }; + if let Some(item) = item { + let label = if running { + clash_verge_i18n::t!("tray.testingLatency") + } else { + clash_verge_i18n::t!("tray.testLatency") + }; + logging_error!(Type::Tray, item.set_text(label)); + logging_error!(Type::Tray, item.set_enabled(!running)); + } + } + pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); @@ -457,96 +583,175 @@ fn create_profile_menu_item( .collect() } +const UNTESTED_DELAY_TEXT: &str = "-ms"; +const TIMEOUT_DELAY_TEXT: &str = "T/O"; +const TRAY_GROUP_DEPTH_LIMIT: usize = 8; + +const fn member_name(member: &ProxyMemberRef) -> &str { + match member { + ProxyMemberRef::Node { name, .. } + | ProxyMemberRef::Group { name } + | ProxyMemberRef::Unresolved { name, .. } => name.as_str(), + } +} + +fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> { + view.groups + .iter() + .chain(view.global.iter()) + .find(|group| group.name.as_str() == name) +} + +/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a +/// node the core tested on its own still shows a value. +fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option { + view.records + .get(record_id) + .map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history)) + .and_then(|history| history.last()) + .map(|history| history.delay) +} + +/// A group row shows the delay of the node the group currently selects. The core never writes a +/// history for the group itself, so `now` is followed until the chain reaches a node. +fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option { + let mut current = group_name; + for _ in 0..TRAY_GROUP_DEPTH_LIMIT { + let Some(group) = find_group(view, current) else { break }; + let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else { + break; + }; + let Some(member) = group.members.iter().find(|member| member_name(member) == now) else { + break; + }; + match member { + ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url), + ProxyMemberRef::Group { name } => current = name.as_str(), + ProxyMemberRef::Unresolved { .. } => break, + } + } + find_group(view, group_name) + .map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history)) + .and_then(|history| history.last()) + .map(|history| history.delay) +} + +fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> { + let ProxyMemberRef::Node { record_id, .. } = member else { + return None; + }; + match &view.records.get(record_id)?.source { + ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name), + ProxyNodeSource::Core { .. } => None, + } +} + +fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String { + let timeout = verge + .default_latency_timeout + .filter(|timeout| *timeout > 0) + .unwrap_or(10000); + let delay = match member { + ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url), + ProxyMemberRef::Group { name } => selected_member_delay(view, name, url), + ProxyMemberRef::Unresolved { .. } => None, + }; + let delay_text = delay.map_or_else( + || UNTESTED_DELAY_TEXT.to_owned(), + |delay| { + if delay == 0 || u32::from(delay) >= timeout as u32 { + TIMEOUT_DELAY_TEXT.to_owned() + } else { + format!("{delay}ms") + } + }, + ); + format!("{} | {delay_text}", member_name(member)) +} + fn create_subcreate_proxy_menu_item( app_handle: &AppHandle, proxy_mode: &str, - proxy_group_order_map: Option>, - proxy_nodes_data: Option, + view: Option, + latency_items: &mut HashMap, + texts: &MenuTexts, + verge: &IVerge, + profiles: &crate::config::IProfiles, ) -> Vec> { - let proxy_submenus: Vec> = { - let mut submenus: Vec<(String, usize, Submenu)> = Vec::new(); - - // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 - if let Some(proxy_nodes_data) = proxy_nodes_data { - for (group_name, group_data) in proxy_nodes_data.proxies.iter() { - let should_show = match proxy_mode { - "global" => group_name == "GLOBAL", - _ => group_name != "GLOBAL", - } && !group_data.hidden.unwrap_or_default(); - - if !should_show { - continue; - } - - let Some(all_proxies) = group_data.all.as_ref() else { - continue; - }; - - let now_proxy = group_data.now.as_deref().unwrap_or_default(); - - let group_items: Vec> = all_proxies - .iter() - .filter_map(|proxy_str| { - let is_selected = *proxy_str == now_proxy; - let item_id = format!("proxy_{}_{}", group_name, proxy_str); - - let delay_text = proxy_nodes_data - .proxies - .get(proxy_str) - .and_then(|h| h.history.last()) - .map(|h| match h.delay { - 0 => "-ms".into(), - delay if delay >= 10000 => "-ms".into(), - _ => format!("{}ms", h.delay), - }) - .unwrap_or_else(|| "-ms".into()); - - let display_text = format!("{} | {}", proxy_str, delay_text); - - CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>) - .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e)) - .ok() - }) - .collect(); - - if group_items.is_empty() { - continue; - } - - let group_display_name = group_name.to_string(); - - let group_items_refs: Vec<&dyn IsMenuItem> = - group_items.iter().map(|item| item as &dyn IsMenuItem).collect(); - - if let Ok(submenu) = Submenu::with_id_and_items( - app_handle, - format!("proxy_group_{}", group_name), - group_display_name, - true, - &group_items_refs, - ) { - let insertion_index = submenus.len(); - submenus.push((group_name.into(), insertion_index, submenu)); - } else { - logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name); - } + let Some(view) = view else { return Vec::new() }; + view.groups + .iter() + .chain(view.global.iter()) + .filter_map(|group| { + if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") { + return None; } - } - - if let Some(order_map) = proxy_group_order_map.as_ref() { - submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| { - match (order_map.get(name_a), order_map.get(name_b)) { - (Some(index_a), Some(index_b)) => index_a.cmp(index_b), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => original_index_a.cmp(original_index_b), - } - }); - } - - submenus.into_iter().map(|(_, _, submenu)| submenu).collect() - }; - proxy_submenus + let url = latency::test_url(group, verge, profiles); + let proxies: Vec = group + .members + .iter() + .filter_map(|member| { + let name = member_name(member); + let text = proxy_item_text(&view, member, &url, verge); + let checked = group.now.as_deref() == Some(name); + CheckMenuItem::with_id( + app_handle, + format!("proxy_{}_{}", group.name, name), + &text, + true, + checked, + None::<&str>, + ) + .map(|item| ProxyLatencyItem { + item, + name: name.to_owned(), + provider_name: member_provider(&view, member).map(str::to_owned), + text, + }) + .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}")) + .ok() + }) + .collect(); + let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect(); + if group_items.is_empty() { + return None; + } + let running = latency::is_running(&group.name); + let test_item = MenuItem::with_id( + app_handle, + format!("{}_{}", MenuIds::TEST_LATENCY, group.name), + if running { + clash_verge_i18n::t!("tray.testingLatency") + } else { + texts.test_latency.clone() + }, + !running, + None::<&str>, + ) + .ok()?; + latency_items.insert( + group.name.clone(), + LatencyMenuItem { + item: test_item.clone(), + running, + proxies, + provider_state: view.provider_state, + }, + ); + let separator = PredefinedMenuItem::separator(app_handle).ok()?; + let mut items: Vec<&dyn IsMenuItem> = vec![&test_item, &separator]; + items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem)); + Submenu::with_id_and_items( + app_handle, + format!("proxy_group_{}", group.name), + &group.name, + true, + &items, + ) + .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}")) + .ok() + }) + .collect() } fn create_proxy_menu_item( @@ -593,54 +798,38 @@ async fn create_tray_menu( tun_mode_available: bool, profiles_preview: Vec>, options: TrayMenuOptions, -) -> Result> { +) -> Result<(tauri::menu::Menu, HashMap)> { let current_proxy_mode = mode.unwrap_or(""); let mut verge_settings = Config::verge().await.latest_arc(); let fetch_proxy_groups = options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable"); - // TODO: should update tray menu again when it was timeout error - let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups { - let proxy_nodes_data = - tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies()) - .await - .map_or(None, |res| res.ok()); - - let runtime = Config::runtime().await.latest_arc(); - let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| { - config - .get("proxy-groups") - .and_then(|groups| groups.as_sequence()) - .map(|groups| { - groups - .iter() - .filter_map(|group| group.get("name")) - .filter_map(|name| name.as_str()) - .enumerate() - .map(|(index, name)| (name.into(), index)) - .collect::>() - }) - .unwrap_or_default() - }); - - (proxy_nodes_data, runtime_proxy_groups_order) + // Proxy data must remain optional so a stopped Core cannot roll back unrelated settings. + let proxy_view = if fetch_proxy_groups { + tokio::time::timeout( + Duration::from_millis(1000), + cmd::proxy::proxy_view(Some(Duration::from_millis(750))), + ) + .await + .ok() + .and_then(Result::ok) } else { - (None, None) + None }; if fetch_proxy_groups { verge_settings = Config::verge().await.latest_arc(); } + let profiles_config = Config::profiles().await.latest_arc(); + let tray_proxy_groups_display_mode = verge_settings .tray_proxy_groups_display_mode .as_deref() .unwrap_or("default"); let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; - let proxy_group_order_map = runtime_proxy_groups_order; - let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false); let version = env!("CARGO_PKG_VERSION"); @@ -720,9 +909,17 @@ async fn create_tray_menu( &profile_menu_items_refs, )?; + let mut latency_items = HashMap::new(); let (proxies_menu, inline_proxy_items) = if include_proxy_groups { - let proxy_sub_menus = - create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); + let proxy_sub_menus = create_subcreate_proxy_menu_item( + app_handle, + current_proxy_mode, + proxy_view, + &mut latency_items, + &texts, + &verge_settings, + &profiles_config, + ); match tray_proxy_groups_display_mode { "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, @@ -866,10 +1063,25 @@ async fn create_tray_menu( ]); let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; - Ok(menu) + Ok((menu, latency_items)) } fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { + let refresh_proxies = match tray_event { + TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(), + TrayIconEvent::Click { + button: MouseButton::Left | MouseButton::Right, + button_state: MouseButtonState::Down, + .. + } => true, + _ => false, + }; + if refresh_proxies { + AsyncHandler::spawn(|| async { + Tray::global().refresh_proxy_items().await; + }); + } + if matches!( tray_event, TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. } @@ -991,6 +1203,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { }; feat::toggle_proxy_profile(profile_index.into()).await; } + id if id.starts_with(MenuIds::TEST_LATENCY) => { + let group_name = id + .strip_prefix(MenuIds::TEST_LATENCY) + .and_then(|rest| rest.strip_prefix('_')); + if let Some(group_name) = group_name { + logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name); + latency::test_proxy_group_delay(group_name).await; + } + } id if id.starts_with("proxy_") => { let rest = match id.strip_prefix("proxy_") { Some(r) => r, diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 334ef1ccb..6b2a82e90 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -172,6 +172,12 @@ pub struct DelayHistory { pub delay: u16, } +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +pub struct ProxyExtra { + pub alive: bool, + pub history: Vec, +} + fn platform_types() -> specta::Types { use specta::datatype::{DataType, Fields}; let mut types = specta::Types::default().register::(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2231069d5..db0d56c2b 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,5 +1,5 @@ { - "version": "2.5.8", + "version": "2.5.9", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "bundle": { "active": true, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 4b891a623..c7145b05f 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -47,7 +47,10 @@ import { useCoreDataStatus, useProxiesData, } from '@/providers/app-data-context' +import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' +import { mutate } from '@/services/mutate' +import { profileTestUrls } from '@/services/proxy-test-url' import { findCurrentGroupMember, getRecord, @@ -675,6 +678,20 @@ export const CurrentProxyCard = () => { [proxyView], ) + useEffect(() => { + if (!selectedGroup) return + const customUrl = currentProfile + ? profileTestUrls(currentProfile)[selectedGroup.name] + : undefined + delayManager.setUrl( + selectedGroup.name, + customUrl?.trim() || + selectedGroup.testUrl?.trim() || + verge?.default_latency_test?.trim() || + 'http://cp.cloudflare.com/generate_204', + ) + }, [selectedGroup, currentProfile, verge?.default_latency_test]) + const unsortedProxyOptions = useMemo( () => optionsForGroup(selectedGroup), [optionsForGroup, selectedGroup], @@ -907,6 +924,12 @@ export const CurrentProxyCard = () => { } refreshProxy() + mutate(() => syncTrayProxySelection(), { + id: 'sync-tray-selection', + errorNotice: false, + }).catch((error) => { + console.error('[CurrentProxyCard] Failed to sync tray latency:', error) + }) }) const proxyOptions = useMemo( diff --git a/src/components/layout/notice-manager.test.ts b/src/components/layout/notice-manager.test.ts new file mode 100644 index 000000000..e55139545 --- /dev/null +++ b/src/components/layout/notice-manager.test.ts @@ -0,0 +1,67 @@ +import { createInstance } from 'i18next' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import { afterEach, expect, it, vi } from 'vitest' + +import settings from '@/locales/en/settings.json' +import { + getSnapshotNotices, + hideNotice, + showNotice, + syncSidecarFailure, +} from '@/services/notice-service' + +import { NoticeManager } from './notice-manager' + +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), +})) +vi.mock('@/utils/open-external-url', () => ({ openExternalUrl: vi.fn() })) + +afterEach(() => { + getSnapshotNotices().forEach(({ id }) => hideNotice(id)) +}) + +it.each([ + [ + 'startup', + 'process verge-mihomo remains after IPC failure; refusing a second core', + ], + [ + 'restart', + 'process verge-mihomo-alpha remains after IPC failure; refusing a second core', + ], + [ + 'sidecar', + 'Service core rejected: permission denied; Sidecar fallback failed: process verge-mihomo.exe (PID 123) is still running; refusing a second core', + ], +])( + 'explains an occupied core during %s and preserves diagnostics', + async (operation, detail) => { + if (operation === 'startup') { + showNotice.error('settings.feedback.errors.clash.startFailed', detail) + } else if (operation === 'restart') { + showNotice.error({ code: 'CORE_RESTART_FAILED', detail }) + } else { + syncSidecarFailure({ revision: 1, detail }) + } + const i18n = createInstance() + await i18n.init({ + lng: 'en', + resources: { en: { translation: { settings } } }, + }) + const html = renderToStaticMarkup( + createElement(I18nextProvider, { i18n }, createElement(NoticeManager)), + ) + expect(html).toContain('a core process is already running') + expect(html).toContain('Close the related app or stop the leftover process') + expect(html).toContain(detail) + if (operation === 'sidecar') { + expect(html).toContain('service-core-permissions') + expect(html).toContain('PID 123') + } + }, +) diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index c70bd0838..8769a28ae 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -70,8 +70,8 @@ const resolveNoticeMessage = ( const detail = i18n.params?.message const existingCore = - notice.code === 'SERVICE_SIDECAR_FAILED' && typeof detail === 'string' - ? /process verge-mihomo(?:-alpha)?(?:\.exe)? \(PID (\d+)\) is still running; refusing a second core\s*$/.exec( + typeof detail === 'string' + ? /process verge-mihomo(?:-alpha|-al)?(?:\.exe)? (?:\(PID (\d+)\) is still running|remains after IPC failure); refusing a second core\s*$/.exec( detail, ) : null @@ -101,9 +101,8 @@ const resolveNoticeMessage = ( )} - {t('settings.feedback.errors.clashService.coreAlreadyRunning', { - pid: existingCore[1], - })} + {t('settings.feedback.errors.clashService.coreAlreadyRunning')} + {existingCore[1] && PID {existingCore[1]}} {t('shared.feedback.errors.details')} diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index 540981203..54d0d65bf 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -684,6 +684,9 @@ export const RulesEditorViewer = (props: Props) => { ruleType.name !== 'SUB-RULE' && ( (null) useEffect(() => { - delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + delayManager.setUrl( + groupName, + testUrl?.trim() || url?.trim() || defaultLatencyUrl, + ) }, [groupName, testUrl, defaultLatencyUrl, url]) // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, @@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { )} {textState === 'url' && ( - onHeadState({ testUrl: e.target.value })} + onCommit={(testUrl) => onHeadState({ testUrl })} sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} @@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { if (testUrl?.trim() && textState !== 'filter') { onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidUrl(testUrl)) { + if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 2489b2ab0..0b4fa96b1 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles' import { useProxySelection } from '@/hooks/use-proxy-selection' import { useVerge } from '@/hooks/use-verge' import { useProxiesData, useSystemData } from '@/providers/app-data-context' +import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' +import { mutate } from '@/services/mutate' import { isInteractableMember, resolveMember, @@ -133,6 +135,12 @@ function useProxyRenderState( console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) } finally { onProxies() + mutate(() => syncTrayProxySelection(), { + id: 'sync-tray-selection', + errorNotice: false, + }).catch((error) => { + console.error('[ProxyGroups] Failed to sync tray latency:', error) + }) } }), ) diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index 4db54f36f..4fa9aefc7 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -11,7 +11,7 @@ import { SortByAlphaRounded, SortRounded, } from '@mui/icons-material' -import { Box, IconButton, TextField, type SxProps } from '@mui/material' +import { Box, IconButton, type SxProps } from '@mui/material' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' import { debugLog } from '@/utils/debug' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' +import { ProxyTestUrlInput } from './proxy-test-url-input' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -72,7 +73,10 @@ export const ProxyHead = ({ 'http://cp.cloudflare.com/generate_204' useEffect(() => { - delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + delayManager.setUrl( + groupName, + testUrl?.trim() || url?.trim() || defaultLatencyUrl, + ) }, [groupName, testUrl, defaultLatencyUrl, url]) return ( @@ -97,7 +101,7 @@ export const ProxyHead = ({ debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidUrl(testUrl)) { + if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } @@ -188,7 +192,8 @@ export const ProxyHead = ({ )} {textState === 'url' && ( - onHeadState({ testUrl: e.target.value })} + onCommit={(testUrl) => onHeadState({ testUrl })} sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} diff --git a/src/components/proxy/proxy-test-url-input.tsx b/src/components/proxy/proxy-test-url-input.tsx new file mode 100644 index 000000000..287e2f0f5 --- /dev/null +++ b/src/components/proxy/proxy-test-url-input.tsx @@ -0,0 +1,55 @@ +import { TextField, type TextFieldProps } from '@mui/material' +import { useEffect, useEffectEvent, useRef, useState } from 'react' + +import { useProfiles } from '@/hooks/use-profiles' + +type Props = Omit & { + value: string + onCommit: (url: string) => void +} + +export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => { + const { profiles } = useProfiles() + return ( + + ) +} + +const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => { + const [draft, setDraft] = useState(null) + const pendingRef = useRef(null) + const commitOnUnmount = useEffectEvent(() => { + if (pendingRef.current !== null && pendingRef.current !== value) + onCommit(pendingRef.current) + }) + useEffect(() => () => commitOnUnmount(), []) + + return ( + { + pendingRef.current = event.target.value + setDraft(event.target.value) + }} + onBlur={() => { + const next = pendingRef.current + pendingRef.current = null + setDraft(null) + if (next !== null && next !== value) onCommit(next) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + const input = event.target as HTMLInputElement + input.blur() + } + }} + /> + ) +} diff --git a/src/components/proxy/use-filter-sort.test.ts b/src/components/proxy/use-filter-sort.test.ts index 2f8d0d46e..08730cd5e 100644 --- a/src/components/proxy/use-filter-sort.test.ts +++ b/src/components/proxy/use-filter-sort.test.ts @@ -13,11 +13,18 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) => kind: 'node', ref: { kind: 'node', name, recordId: `${memberIndex}` }, node: { + recordId: `${memberIndex}`, history: [{ delay }], + extra: { + 'http://cp.cloudflare.com/generate_204': { + alive: true, + history: [{ time: new Date(0).toISOString(), delay }], + }, + }, source: { kind: 'provider', providerName: `${memberIndex}` }, }, }, - }) as ResolvedMemberOccurrence + }) as unknown as ResolvedMemberOccurrence afterEach(() => vi.restoreAllMocks()) @@ -36,7 +43,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays', for (const cached of [false, true]) { const group = `sort-${cached}` if (cached) { - values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay)) + values.forEach((delay, i) => + delayManager.setDelay(list[i].member, group, delay), + ) } for (const timeout of [10000, 20, 0, NaN]) { const expected = list @@ -66,7 +75,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne node(3, 25), ] const cachedOrder = [list[0], list[2], list[1], list[3]] - delayManager.setDelay('0', 'expiry', 1) + delayManager.setDelay(list[0].member, 'expiry', 1) const get = vi.spyOn(delayManager, 'getDelayFix') expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(get).toHaveBeenCalledTimes(list.length) @@ -80,7 +89,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne list[3], list[0], ]) - delayManager.setDelay('0', 'expiry', 2) + delayManager.setDelay(list[0].member, 'expiry', 2) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) get.mockClear() filterSort([], 'expiry', '', 1) diff --git a/src/components/proxy/use-head-state.ts b/src/components/proxy/use-head-state.ts index 5d30c5e1c..d536564cc 100644 --- a/src/components/proxy/use-head-state.ts +++ b/src/components/proxy/use-head-state.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useReducer } from 'react' import { useProfiles } from '@/hooks/use-profiles' +import { + profileTestUrls, + commitProfileTestUrl, +} from '@/services/proxy-test-url' import { ProxySortType } from './use-filter-sort' @@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = { } type HeadStateAction = - | { type: 'reset' } - | { type: 'replace'; payload: Record } + | { type: 'reset'; profile: string } + | { type: 'replace'; profile: string; payload: Record } | { type: 'update'; groupName: string; patch: Partial } + | { type: 'urls'; urls: Record } + +interface LoadedHeadState { + profile: string | null + heads: Record +} function headStateReducer( - state: Record, + state: LoadedHeadState, action: HeadStateAction, -): Record { +): LoadedHeadState { switch (action.type) { case 'reset': - return {} + return { profile: action.profile, heads: {} } case 'replace': - return action.payload + return { profile: action.profile, heads: action.payload } + case 'urls': { + let heads = state.heads + for (const [name, testUrl] of Object.entries(action.urls)) { + if (heads[name]?.testUrl === testUrl) continue + heads = { + ...heads, + [name]: { ...DEFAULT_STATE, ...heads[name], testUrl }, + } + } + return heads === state.heads ? state : { ...state, heads } + } case 'update': { - const prev = state[action.groupName] || DEFAULT_STATE - return { ...state, [action.groupName]: { ...prev, ...action.patch } } + const prev = state.heads[action.groupName] || DEFAULT_STATE + return { + ...state, + heads: { + ...state.heads, + [action.groupName]: { ...prev, ...action.patch }, + }, + } } default: return state @@ -55,30 +82,42 @@ function headStateReducer( } export function useHeadStateNew() { - const { profiles } = useProfiles() + const { profiles, current: profile } = useProfiles() const current = profiles?.current || '' - const [state, dispatch] = useReducer(headStateReducer, {}) + const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer( + headStateReducer, + { profile: null, heads: {} }, + ) useEffect(() => { + const urls = profile ? profileTestUrls(profile) : {} + if (loadedProfile === current) { + dispatch({ type: 'urls', urls }) + return + } try { const data = JSON.parse( - localStorage.getItem(HEAD_STATE_KEY)!, + localStorage.getItem(HEAD_STATE_KEY) || '{}', ) as HeadStateStorage - const value = data[current] || {} + const value = data?.[current] || {} + for (const [name, testUrl] of Object.entries(urls)) { + value[name] = { ...DEFAULT_STATE, ...value[name], testUrl } + } if (value && typeof value === 'object') { - dispatch({ type: 'replace', payload: value }) + dispatch({ type: 'replace', profile: current, payload: value }) } else { - dispatch({ type: 'reset' }) + dispatch({ type: 'reset', profile: current }) } } catch { - dispatch({ type: 'reset' }) + dispatch({ type: 'reset', profile: current }) } - }, [current]) + }, [current, loadedProfile, profile]) useEffect(() => { + if (loadedProfile !== current) return const timer = setTimeout(() => { try { const item = localStorage.getItem(HEAD_STATE_KEY) @@ -94,13 +133,16 @@ export function useHeadStateNew() { }) return () => clearTimeout(timer) - }, [state, current]) + }, [state, current, loadedProfile]) const setHeadState = useCallback( (groupName: string, obj: Partial) => { dispatch({ type: 'update', groupName, patch: obj }) + if (current && obj.testUrl !== undefined) { + void commitProfileTestUrl(current, groupName, obj.testUrl) + } }, - [], + [current], ) return [state, setHeadState] as const diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 2e215bdf4..faa3e230e 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -117,6 +117,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({ mptcp: false, smux: false, history: [], + extra: {}, members, }) diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 84f789ba0..09f5472c4 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -138,7 +138,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { const defaultBypass = () => { if (isWindows) { - return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;' + return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;*.local;' } if (systemName === 'linux') { return 'localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1' diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index 89e8b6825..0dba7f952 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -45,6 +45,7 @@ const languageOptions = supportedLanguages.map((code) => { es: 'Español', jp: '日本語', zhtw: '繁體中文', + pt: 'Português', } const label = labels[code] || code return { code, label } diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index a234b5c3d..2ebbd3c2a 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,8 +1,9 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import type { ProfilePatch, ProfileSelection } from '@/services/bindings' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { mutate } from '@/services/mutate' +import { migrateProfileTestUrls } from '@/services/proxy-test-url' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' @@ -35,6 +36,10 @@ export const useProfiles = () => { refetchInterval: false, }) + useEffect(() => { + if (profiles) migrateProfileTestUrls(profiles) + }, [profiles]) + const refetchRef = useRef(refetch) refetchRef.current = refetch const mutateProfiles = useCallback(async () => { diff --git a/src/hooks/use-proxy-delay-state.ts b/src/hooks/use-proxy-delay-state.ts index f8a07f299..8b999edba 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -34,7 +34,6 @@ export function useProxyDelayState( groupName: string, ): UseProxyDelayState { const name = member.ref.name - const details = memberDetails(member) const unresolved = member.kind === 'unresolved' const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name) const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY) @@ -43,18 +42,18 @@ export function useProxyDelayState( useEffect(() => { if (isPreset) return - delayManager.setListener(name, groupName, setDelayState) + delayManager.setListener(member, groupName, setDelayState) return () => { - delayManager.removeListener(name, groupName) + delayManager.removeListener(member, groupName) } - }, [name, groupName, isPreset]) + }, [member, groupName, isPreset]) const updateDelay = useCallback(() => { if (unresolved) { setDelayState(INITIAL_DELAY) return } - const cachedUpdate = delayManager.getDelayUpdate(name, groupName) + const cachedUpdate = delayManager.getDelayUpdate(member, groupName) if (cachedUpdate) { setDelayState({ ...cachedUpdate }) return @@ -67,7 +66,9 @@ export function useProxyDelayState( } let updatedAt = 0 - const history = details?.history + const history = + delayManager.getHistory(member, groupName) ?? + memberDetails(member)?.history if (history && history.length > 0) { const lastRecord = history[history.length - 1] const parsed = Date.parse(lastRecord.time) @@ -77,16 +78,18 @@ export function useProxyDelayState( } setDelayState({ delay: fallbackDelay, updatedAt }) - }, [details?.history, groupName, member, name, unresolved]) + }, [groupName, member, unresolved]) useEffect(() => { updateDelay() - }, [updateDelay]) + return delayManager.addGroupListener(groupName, updateDelay) + }, [groupName, updateDelay]) const onDelay = useLockFn(async () => { if (!isInteractableMember(member)) return setDelayState({ delay: -2, updatedAt: Date.now() }) - setDelayState(await delayManager.checkDelay(member, groupName, timeout)) + await delayManager.checkDelay(member, groupName, timeout) + updateDelay() }) return { diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index a92d77c6d..379e17e96 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "تعذّر إعادة تثبيت الخدمة", "repairFailed": "تعذّر إصلاح الخدمة", "sidecarFailed": "تعذّر المتابعة باستخدام Sidecar", - "coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة." + "coreAlreadyRunning": "تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر." }, "sysproxy": { "privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index c072b63d9..1556a404e 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Dienst konnte nicht neu installiert werden", "repairFailed": "Dienst konnte nicht repariert werden", "sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen", - "coreAlreadyRunning": "Der Sidecar-Modus konnte nicht gestartet werden: Ein anderer Kern läuft noch (PID {{pid}}). Beenden Sie andere Clash-Verge-Instanzen oder die App, die diesen Kern verwendet, und versuchen Sie es erneut." + "coreAlreadyRunning": "Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu." }, "sysproxy": { "privilegeRequired": "Systemproxy kann nicht gesetzt werden: macOS hat die Änderung mangels Berechtigung abgelehnt. Installieren Sie den Clash-Verge-Dienst; als Administrator können Sie in den erweiterten Optionen unter Systemeinstellungen → Datenschutz & Sicherheit auch die Passwortpflicht für systemweite Einstellungen deaktivieren.", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 83fc780c8..277c0fc27 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Failed to reinstall the service", "repairFailed": "Failed to repair the service", "sidecarFailed": "Failed to continue with Sidecar", - "coreAlreadyRunning": "Could not start normal mode (Sidecar): another core is still running (PID {{pid}}). Exit other Clash Verge instances or the app using this core, then retry." + "coreAlreadyRunning": "Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer." }, "sysproxy": { "privilegeRequired": "Cannot set the system proxy: macOS refused the change for lack of privileges. Install the Clash Verge service; an administrator can also turn off the administrator-password requirement for system-wide settings, in the advanced options of System Settings → Privacy & Security.", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 4d00690cd..5d7252513 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "No se pudo reinstalar el servicio", "repairFailed": "No se pudo reparar el servicio", "sidecarFailed": "No se pudo continuar con Sidecar", - "coreAlreadyRunning": "No se pudo iniciar el modo Sidecar: hay otro núcleo en ejecución (PID {{pid}}). Cierre las otras instancias de Clash Verge o la aplicación que usa ese núcleo y vuelva a intentarlo." + "coreAlreadyRunning": "No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo." }, "sysproxy": { "privilegeRequired": "No se puede establecer el proxy del sistema: macOS rechazó el cambio por falta de permisos. Instale el servicio de Clash Verge; si es administrador, también puede desactivar la exigencia de contraseña de administrador para los ajustes de todo el sistema en las opciones avanzadas de Ajustes del Sistema → Privacidad y seguridad.", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 573c5c2c3..79dcc96f9 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "نصب مجدد سرویس ناموفق بود", "repairFailed": "تعمیر سرویس ناموفق بود", "sidecarFailed": "ادامه با Sidecar ناموفق بود", - "coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید." + "coreAlreadyRunning": "هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید." }, "sysproxy": { "privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index e7d425c08..341cf9676 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Gagal memasang ulang layanan", "repairFailed": "Gagal memperbaiki layanan", "sidecarFailed": "Gagal melanjutkan dengan Sidecar", - "coreAlreadyRunning": "Tidak dapat memulai mode Sidecar: inti lain masih berjalan (PID {{pid}}). Tutup instans Clash Verge lain atau aplikasi yang menggunakan inti tersebut, lalu coba lagi." + "coreAlreadyRunning": "Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer." }, "sysproxy": { "privilegeRequired": "Tidak dapat mengatur proxy sistem: macOS menolak perubahan ini karena hak akses tidak mencukupi. Pasang layanan Clash Verge; jika Anda administrator, Anda juga dapat mematikan keharusan kata sandi administrator untuk pengaturan seluruh sistem di opsi lanjutan Pengaturan Sistem → Privasi & Keamanan.", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 6a8530aa4..06af65f1c 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "サービスの再インストールに失敗しました", "repairFailed": "サービスの修復に失敗しました", "sidecarFailed": "Sidecar での続行に失敗しました", - "coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。" + "coreAlreadyRunning": "コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。" }, "sysproxy": { "privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 241f4f9fa..6bbcf08a0 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "서비스를 다시 설치하지 못했습니다", "repairFailed": "서비스를 복구하지 못했습니다", "sidecarFailed": "Sidecar로 계속하지 못했습니다", - "coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요." + "coreAlreadyRunning": "코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요." }, "sysproxy": { "privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.", diff --git a/src/locales/pt/connections.json b/src/locales/pt/connections.json new file mode 100644 index 000000000..8034e7599 --- /dev/null +++ b/src/locales/pt/connections.json @@ -0,0 +1,34 @@ +{ + "page": { + "title": "Conexões" + }, + "components": { + "fields": { + "host": "Host", + "dlSpeed": "Vel. de download", + "ulSpeed": "Vel. de upload", + "chains": "Cadeias", + "rule": "Regra", + "process": "Processo", + "time": "Hora", + "source": "Origem", + "destination": "Destino", + "destinationPort": "Porta de destino", + "type": "Tipo" + }, + "order": { + "default": "Padrão", + "uploadSpeed": "Velocidade de upload", + "downloadSpeed": "Velocidade de download" + }, + "actions": { + "active": "Ativas", + "closed": "Fechadas", + "closeConnection": "Fechar conexão" + }, + "columnManager": { + "title": "Colunas", + "dragHandle": "Alça de arrastar" + } + } +} diff --git a/src/locales/pt/home.json b/src/locales/pt/home.json new file mode 100644 index 000000000..8df3dcc77 --- /dev/null +++ b/src/locales/pt/home.json @@ -0,0 +1,156 @@ +{ + "page": { + "tooltips": { + "lightweightMode": "Modo leve", + "manual": "Manual", + "settings": "Configurações do início" + }, + "cards": { + "trafficStats": "Estatísticas de tráfego", + "networkSettings": "Configurações de rede", + "proxyMode": "Modo de proxy" + }, + "settings": { + "cards": { + "profile": "Cartão de perfil", + "currentProxy": "Cartão do proxy atual", + "network": "Cartão de configurações de rede", + "proxyMode": "Cartão do modo de proxy", + "traffic": "Cartão de estatísticas de tráfego", + "tests": "Cartão de testes de sites", + "ip": "Cartão de informações de IP", + "clashInfo": "Cartões de informações do Clash", + "systemInfo": "Cartões de informações do sistema" + }, + "title": "Configurações do início" + }, + "title": "Início" + }, + "components": { + "proxyTun": { + "status": { + "systemProxyEnabled": "Proxy do sistema ativado", + "systemProxyDisabled": "Proxy do sistema desativado", + "tunModeServiceRequired": "Modo TUN requer o serviço", + "tunModeEnabled": "Modo TUN ativado", + "tunModeDisabled": "Modo TUN desativado" + }, + "tooltips": { + "systemProxy": "Informações do proxy do sistema", + "tunMode": "Informações de interceptação do modo TUN" + } + }, + "clashInfo": { + "title": "Informações do Clash", + "fields": { + "coreVersion": "Versão do núcleo", + "systemProxyAddress": "Endereço do proxy do sistema", + "mixedPort": "Porta mista", + "uptime": "Tempo ativo", + "rulesCount": "Quantidade de regras" + } + }, + "systemInfo": { + "title": "Informações do sistema", + "fields": { + "osInfo": "Informações do SO", + "autoLaunch": "Início automático", + "runningMode": "Modo de execução", + "lastCheckUpdate": "Última verificação de atualização", + "vergeVersion": "Versão do Verge" + }, + "actions": { + "settings": "Configurações" + }, + "badges": { + "notRunning": "Núcleo não está em execução", + "adminMode": "Modo administrador", + "serviceMode": "Modo serviço", + "sidecarMode": "Modo usuário", + "adminServiceMode": "Modo admin + serviço" + } + }, + "ipInfo": { + "title": "Informações de IP", + "labels": { + "ip": "IP", + "asn": "ASN", + "isp": "ISP", + "org": "ORG", + "location": "Localização", + "timezone": "Fuso horário", + "autoRefresh": "Atualização automática", + "unknown": "Desconhecido" + }, + "errors": { + "load": "Falha ao obter informações de IP", + "loadWithDetails": "Falha ao obter informações de IP: {{message}}", + "noServices": "Nenhum serviço de detecção de IP disponível" + } + }, + "currentProxy": { + "title": "Nó atual", + "actions": { + "refreshDelay": "Teste de latência" + }, + "labels": { + "globalMode": "Modo global", + "directMode": "Modo direto", + "group": "Grupo", + "proxy": "Proxy", + "noActiveNode": "Nenhum nó de proxy ativo" + }, + "status": { + "testing": "Testando", + "untested": "Não testado", + "error": "Erro", + "timeout": "Tempo esgotado", + "latencyHigh": "Latência alta", + "latencyMedium": "Latência moderada", + "latencyGood": "Latência boa", + "latencyExcellent": "Latência excelente", + "uninitialized": "Não inicializado", + "noProxyNode": "Nenhum nó de proxy" + } + }, + "tests": { + "title": "Testes de sites" + }, + "traffic": { + "metrics": { + "uploadSpeed": "Velocidade de upload", + "downloadSpeed": "Velocidade de download", + "activeConnections": "Conexões ativas", + "memoryUsage": "Uso do núcleo" + }, + "legends": { + "upload": "Envio", + "download": "Recebimento" + }, + "patterns": { + "minutes": "{{time}} minutos" + }, + "unknownTime": "Horário desconhecido", + "chartStyles": { + "smooth": "Suave", + "linear": "Linear" + }, + "diagnostics": "Pontos: {{points}} | Agrupados: {{compressed}} | FPS: {{fps}}" + }, + "clashMode": { + "errors": { + "communication": "Erro de comunicação com o núcleo, reinicie o computador e tente novamente" + }, + "labels": { + "rule": "Regra", + "global": "Global", + "direct": "Direto" + }, + "descriptions": { + "rule": "Escolhe proxies automaticamente de acordo com o conjunto de regras.", + "global": "Encaminha todas as requisições de rede pelo proxy selecionado.", + "direct": "Ignora o proxy e conecta diretamente à internet." + } + } + } +} diff --git a/src/locales/pt/layout.json b/src/locales/pt/layout.json new file mode 100644 index 000000000..6c1a2142b --- /dev/null +++ b/src/locales/pt/layout.json @@ -0,0 +1,61 @@ +{ + "components": { + "navigation": { + "tabs": { + "home": "Início", + "proxies": "Proxies", + "profiles": "Perfis", + "connections": "Conexões", + "rules": "Regras", + "logs": "Logs", + "unlock": "Teste", + "settings": "Ajustes" + }, + "menu": { + "reorderMode": "Modo de reordenação do menu", + "restoreDefaultOrder": "Restaurar ordem padrão", + "unlock": "Desbloquear ordem do menu", + "lock": "Bloquear ordem do menu", + "collapseNavBar": "Recolher barra de navegação", + "expandNavBar": "Expandir barra de navegação" + } + }, + "serviceMigration": { + "locationRefusedMessage": "O serviço não pode iniciar o núcleo porque o caminho de instalação ou as permissões do diretório falharam em uma verificação de segurança. Reinstale o serviço para restaurar seus arquivos e permissões e tente novamente. O Windows pode solicitar aprovação de administrador.", + "notAutoStartedMessage": "O tipo de inicialização do serviço Clash Verge foi alterado para Manual ou Desativado (geralmente por ferramentas de otimização do sistema), então ele não inicia mais com o Windows; esta sessão está usando Sidecar. Reinstale o serviço para restaurar a inicialização automática. O Windows pode solicitar aprovação de administrador.", + "title": "Atualização do serviço necessária", + "message": "A versão do serviço Clash Verge não corresponde à versão exigida por este app. Escolha Reinstalar serviço para desinstalar o serviço existente e instalar a versão exigida, ou continue com o Sidecar nesta sessão.", + "checkingMessage": "Verificando o status do serviço do sistema e o modo de execução atual. Aguarde…", + "unavailableMessage": "O serviço Clash Verge necessário não está disponível. Instale ou repare-o, ou continue com o Sidecar nesta sessão.", + "reinstall": "Reinstalar serviço", + "repair": "Reparar serviço", + "continueSidecar": "Continuar com Sidecar", + "success": "O serviço Clash Verge foi atualizado com sucesso", + "errors": { + "actionFailed": "A operação do serviço falhou", + "stateRefreshFailed": "Falha ao atualizar o estado do serviço", + "restartFailed": "Falha ao reiniciar o núcleo", + "revalidationFailed": "Falha ao atualizar o estado de execução", + "sidecarFailed": "Falha ao continuar com o Sidecar" + } + }, + "sysproxyPrivilege": { + "title": "Não foi possível definir o proxy do sistema", + "message": "O macOS recusou a alteração por falta de privilégios. Instalar o serviço Clash Verge permite que ele faça alterações de proxy como administrador, e este aviso não voltará.", + "serviceReadyMessage": "O serviço Clash Verge está pronto, mas o núcleo ainda está em execução como sidecar, por isso a definição do proxy do sistema foi recusada. Mudar para o modo serviço resolve — sem precisar reinstalar.", + "alternative": "Como alternativa, desative “exigir senha de administrador para acessar as configurações do sistema” em Ajustes do Sistema → Privacidade e Segurança → Avançado.", + "later": "Depois", + "installing": "Instalando o serviço do sistema. Pode ser solicitada a senha de administrador.", + "restarting": "Serviço instalado. Reiniciando o núcleo por meio dele…", + "applying": "Aplicando a alteração do proxy do sistema…", + "tunTitle": "O modo TUN precisa do serviço do sistema", + "tunMessage": "O modo TUN precisa do serviço do sistema Clash Verge para assumir o tráfego de rede. Depois de instalado, o modo TUN será ativado para você." + } + }, + "feedback": { + "errors": { + "restartCoreStopFailed": "Não foi possível parar o núcleo com segurança, então a reinicialização foi cancelada", + "quitCoreStopFailed": "Não foi possível parar o núcleo com segurança, então a saída foi cancelada" + } + } +} diff --git a/src/locales/pt/logs.json b/src/locales/pt/logs.json new file mode 100644 index 000000000..0a73fd4bb --- /dev/null +++ b/src/locales/pt/logs.json @@ -0,0 +1,9 @@ +{ + "page": { + "title": "Logs" + }, + "actions": { + "showDescending": "Mais recentes primeiro", + "showAscending": "Mais antigos primeiro" + } +} diff --git a/src/locales/pt/profiles.json b/src/locales/pt/profiles.json new file mode 100644 index 000000000..0fa7fe50d --- /dev/null +++ b/src/locales/pt/profiles.json @@ -0,0 +1,221 @@ +{ + "page": { + "actions": { + "updateAll": "Atualizar todos os perfis", + "viewRuntimeConfig": "Ver configuração em execução", + "reactivate": "Reativar perfis", + "import": "Importar" + }, + "batch": { + "actions": { + "delete": "Excluir perfis selecionados", + "selectAll": "Selecionar tudo", + "deselectAll": "Desmarcar tudo", + "done": "Concluído" + }, + "summary": { + "selected": "Selecionados", + "items": "itens" + }, + "title": "Operações em lote" + }, + "importForm": { + "placeholder": "URL do perfil", + "actions": { + "paste": "Colar" + } + }, + "feedback": { + "errors": { + "invalidUrl": "URL de perfil inválida. Insira uma URL começando com http:// ou https://", + "onlyYaml": "Somente arquivos YAML são suportados", + "createFailed": "Falha ao criar o perfil", + "deleteFailed": "Falha ao excluir o perfil", + "enhanceFailed": "Falha ao reativar os perfis", + "importFailed": "Falha ao importar o perfil", + "openFailed": "Falha ao abrir o perfil", + "readFailed": "Falha ao ler o perfil", + "reorderFailed": "Falha ao reordenar os perfis", + "switchFailed": "Falha ao trocar de perfil", + "updateFailed": "Falha ao atualizar o perfil" + }, + "notifications": { + "importRetry": "Falha na importação, tentando novamente com o proxy Clash...", + "importFail": "Falha na importação mesmo com o proxy Clash", + "importNeedsRefresh": "Perfil importado, mas pode precisar de atualização manual", + "importSuccess": "Perfil importado com sucesso, reinicie se não aparecer", + "profileSwitched": "Perfil trocado", + "profileReactivated": "Perfil reativado", + "switchBusy": "A troca de perfil já está em andamento", + "batchDeleted": "Perfis selecionados excluídos com sucesso" + }, + "notices": { + "forceRefreshCompleted": "Atualização forçada concluída", + "discardedKeys": "As extensões gravaram {{keys}}, que são gerenciados pelas Configurações, então esses valores foram descartados", + "emergencyRefreshFailed": "Falha na atualização de emergência: {{message}}" + }, + "tooltips": { + "forceRefreshStaleData": "Os dados do perfil estão indisponíveis; clique para forçar a atualização" + } + }, + "title": "Perfis" + }, + "components": { + "card": { + "labels": { + "clickToImport": "Clique para importar a assinatura" + } + }, + "fileInput": { + "chooseFile": "Escolher arquivo" + }, + "menu": { + "home": "Início", + "select": "Selecionar", + "shareQrCode": "Compartilhar QR Code", + "editInfo": "Editar informações", + "editFile": "Editar arquivo", + "editRules": "Editar regras", + "editProxies": "Editar proxies", + "editGroups": "Editar grupos de proxy", + "extendConfig": "Configuração de extensão", + "extendScript": "Script de extensão", + "openFile": "Abrir arquivo", + "update": "Atualizar", + "updateViaProxy": "Atualizar via proxy" + }, + "more": { + "global": { + "merge": "Extensão global", + "script": "Script de extensão global" + }, + "chips": { + "merge": "Mesclar", + "script": "Script" + } + }, + "profileItem": { + "tooltips": { + "showLast": "Clique para mostrar a última atualização", + "showNext": "Clique para mostrar a próxima atualização" + }, + "status": { + "lastUpdateFailed": "Falha na última atualização", + "nextUp": "Próxima", + "noSchedule": "Sem agendamento", + "unknown": "Desconhecido", + "autoUpdateDisabled": "Atualização automática desativada" + } + } + }, + "modals": { + "profileForm": { + "title": { + "create": "Criar perfil", + "edit": "Editar perfil" + }, + "fields": { + "type": "Tipo", + "description": "Descrição", + "subscriptionUrl": "URL da assinatura", + "userAgent": "User Agent", + "httpTimeout": "Tempo limite HTTP", + "updateInterval": "Intervalo de atualização", + "useSystemProxy": "Usar proxy do sistema", + "useClashProxy": "Usar proxy Clash", + "acceptInvalidCerts": "Permitir certificados inválidos (perigoso)", + "allowAutoUpdate": "Permitir atualização automática" + }, + "types": { + "remote": "Remoto", + "local": "Local" + }, + "errors": { + "typeRequired": "Selecione um tipo de perfil", + "urlRequired": "Insira a URL da assinatura", + "uidMissing": "O identificador do perfil está ausente", + "saveFailed": "Falha ao salvar o perfil" + }, + "warnings": { + "frequentUpdate": "Um intervalo curto recarrega o núcleo com essa frequência e derruba as conexões existentes. Recomendados: {{minutes}} minutos (24 horas)." + }, + "feedback": { + "notifications": { + "creationRetry": "Falha ao criar o perfil, tentando novamente com o proxy Clash...", + "creationSuccess": "Perfil criado com sucesso com o proxy Clash" + } + } + }, + "proxiesEditor": { + "title": "Editar proxies", + "placeholders": { + "multiUri": "Use quebras de linha para várias URIs (Base64 suportado)" + }, + "actions": { + "prepend": "Adicionar proxy no início", + "append": "Adicionar proxy no fim" + } + }, + "groupsEditor": { + "title": "Editar grupos de proxy", + "errors": { + "nameRequired": "Nome do grupo obrigatório", + "nameExists": "O nome do grupo já existe" + }, + "fields": { + "type": "Tipo de grupo", + "name": "Nome do grupo", + "icon": "Ícone do grupo de proxy", + "proxies": "Usar proxies", + "provider": "Usar provedor", + "healthCheckUrl": "URL de verificação de saúde", + "expectedStatus": "Status esperado", + "interval": "Intervalo", + "maxFailedTimes": "Máximo de falhas", + "interfaceName": "Nome da interface", + "routingMark": "Marca de roteamento", + "filter": "Filtro", + "excludeFilter": "Filtro de exclusão", + "excludeType": "Tipo de exclusão", + "includeAll": "Incluir todos os proxies e provedores", + "includeAllProxies": "Incluir todos os proxies", + "includeAllProviders": "Incluir todos os provedores" + }, + "toggles": { + "lazy": "Lazy", + "disableUdp": "Desativar UDP", + "hidden": "Oculto" + }, + "actions": { + "prepend": "Adicionar grupo no início", + "append": "Adicionar grupo no fim" + } + }, + "editor": { + "actions": { + "format": "Formatar documento" + }, + "messages": { + "readOnly": "Não é possível editar em um editor somente leitura" + }, + "enhance": { + "globalMerge": "Aplica-se a todos os perfis, após as configurações do app. O script global, a configuração do perfil e o script do perfil ainda podem alterar o resultado.", + "globalScript": "Aplica-se a todos os perfis, usando o resultado da mesclagem da configuração global. A configuração do perfil e o script do perfil ainda podem alterar o resultado.", + "profileMerge": "Aplica-se somente a este perfil, após a configuração global e o script global. O script do perfil ainda pode alterar o resultado.", + "profileScript": "Aplica-se somente a este perfil, usando o resultado da mesclagem da configuração do perfil. Esta é a última etapa de extensão antes de as configurações do app serem reaplicadas.", + "order": "Ordem: Perfil original → Configurações do app → Configuração de extensão global → Script de extensão global → Configuração de extensão do perfil → Script de extensão do perfil → Configurações do app reaplicadas", + "settingsPriority": "Nota: os campos gerenciados pelas configurações do app sempre usam essas configurações na configuração final. Alterar esses campos nas extensões não tem efeito." + } + }, + "confirmDelete": { + "title": "Confirmar exclusão", + "message": "Esta operação não é reversível" + }, + "logViewer": { + "title": "Console de scripts" + }, + "qrViewer": { + "title": "QR Code da assinatura" + } + } +} diff --git a/src/locales/pt/proxies.json b/src/locales/pt/proxies.json new file mode 100644 index 000000000..1740f6043 --- /dev/null +++ b/src/locales/pt/proxies.json @@ -0,0 +1,122 @@ +{ + "page": { + "modes": { + "rule": "Regra", + "global": "Global", + "direct": "Direto" + }, + "actions": { + "toggleChain": "Proxy em cadeia", + "connect": "Conectar", + "disconnect": "Desconectar", + "connecting": "Conectando...", + "clearChainConfig": "Excluir configuração da cadeia" + }, + "provider": { + "title": "Provedor de proxy", + "actions": { + "updateAll": "Atualizar tudo", + "update": "Atualizar" + } + }, + "rules": { + "title": "Regras de proxy", + "select": "Selecionar regras" + }, + "labels": { + "proxyCount": "Quantidade de proxies", + "delayCheckReset": "Teste de latência para cancelar a fixação", + "nodeCount": "{{count}} nós" + }, + "tooltips": { + "locate": "localizar", + "delayCheck": "Teste de latência", + "sortDefault": "Ordenar por padrão", + "sortDelay": "Ordenar por latência", + "sortName": "Ordenar por nome", + "delayCheckUrl": "URL do teste de latência", + "showBasic": "Proxy básico", + "showDetail": "Proxy detalhado", + "filter": "Filtro" + }, + "placeholders": { + "delayCheckUrl": "URL do teste de latência" + }, + "empty": { + "noAvailableGroups": "Nenhum grupo de proxy disponível", + "noProxies": "Nenhum proxy", + "noSubscriptions": { + "title": "Nenhuma assinatura importada", + "description": "Não há nada para exibir na página Proxies. Vá para Perfis para importar e ativar uma assinatura." + }, + "inactiveSubscription": { + "title": "Nenhuma assinatura ativa", + "description": "Foram encontradas assinaturas, mas nenhuma está ativa. Vá para Perfis e selecione uma para ativar." + }, + "coreUnavailable": { + "title": "Núcleo temporariamente indisponível", + "description": "Não foi possível obter as informações de proxy do núcleo. Tente reiniciá-lo. Se o problema persistir, abra a pasta de logs e verifique os logs do núcleo." + }, + "noProxyInfo": { + "title": "Nenhuma informação de proxy disponível", + "description": "Não há grupos de proxy nem nós para exibir. Verifique se a assinatura ativa contém grupos e nós de proxy válidos, ou reinicie o núcleo e revise os logs." + }, + "actions": { + "openProfiles": "Ir para Perfis", + "restartCore": "Reiniciar núcleo", + "openLogs": "Abrir pasta de logs" + } + }, + "chain": { + "header": "Configuração do proxy em cadeia", + "warning": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", + "empty": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", + "instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy", + "minimumNodes": "O proxy em cadeia requer pelo menos 2 nós", + "minimumNodesHint": "O proxy em cadeia requer pelo menos 2 nós. Adicione mais um nó.", + "connectFailed": "Falha ao conectar à cadeia de proxy", + "disconnectFailed": "Falha ao desconectar da cadeia de proxy", + "duplicateNode": "O nó de proxy já existe na cadeia", + "entryNode": "Entrada", + "exitNode": "Saída" + }, + "messages": { + "directMode": "Modo direto" + }, + "title": { + "default": "Grupos de proxy", + "chainMode": "Modo de proxy em cadeia" + } + }, + "feedback": { + "warnings": { + "invalidTestUrl": "A URL de teste deve começar com http:// ou https://" + }, + "notifications": { + "provider": { + "updateSuccess": "{{name}} atualizado com sucesso", + "updateFailed": "Falha ao atualizar {{name}}: {{message}}", + "genericError": "Falha na atualização: {{message}}", + "none": "Nenhum provedor disponível para atualizar", + "allUpdated": "Todos os provedores atualizados com sucesso" + } + } + }, + "components": { + "enums": { + "strategies": { + "select": "Selecionar proxy manualmente", + "url-test": "Selecionar proxy com base na latência do teste de URL", + "fallback": "Alternar para outro proxy em caso de erro", + "load-balance": "Distribuir proxies com balanceamento de carga", + "relay": "Passar pela cadeia de proxy definida" + }, + "policies": { + "DIRECT": "Os dados saem diretamente (DIRECT)", + "REJECT": "Intercepta as requisições (REJECT)", + "REJECT-DROP": "Descarta as requisições (REJECT-DROP)", + "PASS": "Ignora esta regra quando houver correspondência (PASS)" + } + } + } +} diff --git a/src/locales/pt/rules.json b/src/locales/pt/rules.json new file mode 100644 index 000000000..b335b527b --- /dev/null +++ b/src/locales/pt/rules.json @@ -0,0 +1,82 @@ +{ + "page": { + "provider": { + "trigger": "Provedor de regras", + "dialogTitle": "Provedores de regras", + "actions": { + "updateAll": "Atualizar tudo", + "update": "Atualizar" + } + }, + "title": "Regras" + }, + "feedback": { + "notifications": { + "provider": { + "updateSuccess": "{{name}} atualizado com sucesso", + "updateFailed": "Falha ao atualizar {{name}}: {{message}}", + "genericError": "Falha na atualização: {{message}}", + "none": "Nenhum provedor disponível para atualizar", + "allUpdated": "Todos os provedores foram atualizados com sucesso" + } + } + }, + "modals": { + "editor": { + "form": { + "labels": { + "type": "Tipo de regra", + "content": "Conteúdo da regra", + "proxyPolicy": "Política de proxy" + }, + "toggles": { + "noResolve": "Sem resolução" + }, + "actions": { + "prependRule": "Adicionar regra no início", + "appendRule": "Adicionar regra no fim" + }, + "validation": { + "conditionRequired": "Condição da regra obrigatória", + "invalidRule": "Regra inválida" + } + }, + "ruleTypes": { + "DOMAIN": "Corresponder ao domínio completo (DOMAIN)", + "DOMAIN-SUFFIX": "Corresponder ao sufixo de domínio (DOMAIN-SUFFIX)", + "DOMAIN-KEYWORD": "Corresponder à palavra-chave do domínio (DOMAIN-KEYWORD)", + "DOMAIN-REGEX": "Corresponder ao domínio por regex (DOMAIN-REGEX)", + "GEOSITE": "Corresponder a domínios do GeoSite (GEOSITE)", + "GEOIP": "Corresponder ao código de país do IP (GEOIP)", + "SRC-GEOIP": "Corresponder ao código de país do IP de origem (SRC-GEOIP)", + "IP-ASN": "Corresponder ao ASN do IP (IP-ASN)", + "SRC-IP-ASN": "Corresponder ao ASN do IP de origem (SRC-IP-ASN)", + "IP-CIDR": "Corresponder à faixa de endereços IP (IP-CIDR)", + "IP-CIDR6": "Corresponder à faixa de endereços IPv6 (IP-CIDR6)", + "SRC-IP-CIDR": "Corresponder à faixa de endereços IP de origem (SRC-IP-CIDR)", + "IP-SUFFIX": "Corresponder à faixa de sufixo de IP (IP-SUFFIX)", + "SRC-IP-SUFFIX": "Corresponder à faixa de sufixo de IP de origem (SRC-IP-SUFFIX)", + "SRC-PORT": "Corresponder à faixa de portas de origem (SRC-PORT)", + "DST-PORT": "Corresponder à faixa de portas de destino (DST-PORT)", + "IN-PORT": "Corresponder à porta de entrada (IN-PORT)", + "DSCP": "Tag DSCP (apenas entrada TPROXY UDP) (DSCP)", + "PROCESS-NAME": "Corresponder ao nome do processo (PROCESS-NAME)", + "PROCESS-PATH": "Corresponder ao caminho completo do processo (PROCESS-PATH)", + "PROCESS-NAME-REGEX": "Corresponder ao nome do processo por regex (PROCESS-NAME-REGEX)", + "PROCESS-PATH-REGEX": "Corresponder ao caminho completo do processo por regex (PROCESS-PATH-REGEX)", + "NETWORK": "Corresponder ao protocolo de rede (TCP/UDP) (NETWORK)", + "UID": "Corresponder ao ID de usuário Linux (UID)", + "IN-TYPE": "Corresponder ao tipo de entrada (IN-TYPE)", + "IN-USER": "Corresponder ao usuário de entrada (IN-USER)", + "IN-NAME": "Corresponder ao nome da entrada (IN-NAME)", + "SUB-RULE": "Sub-regra (SUB-RULE)", + "RULE-SET": "Corresponder ao conjunto de regras (RULE-SET)", + "AND": "E lógico (AND)", + "OR": "OU lógico (OR)", + "NOT": "NÃO lógico (NOT)", + "MATCH": "Corresponder a todas as requisições (MATCH)" + }, + "title": "Editar regras" + } + } +} diff --git a/src/locales/pt/settings.json b/src/locales/pt/settings.json new file mode 100644 index 000000000..1f95164bc --- /dev/null +++ b/src/locales/pt/settings.json @@ -0,0 +1,716 @@ +{ + "page": { + "actions": { + "manual": "Manual", + "telegram": "Canal do Telegram", + "github": "Repositório do Github" + }, + "actionsGroupLabel": "Links das configurações", + "title": "Configurações" + }, + "sections": { + "system": { + "title": "Configurações do Sistema", + "toggles": { + "tunMode": "Modo Tun", + "systemProxy": "Proxy do Sistema" + }, + "tooltips": { + "silentStart": "Inicia o programa em segundo plano sem exibir o painel" + }, + "fields": { + "autoLaunch": "Iniciar com o Sistema", + "silentStart": "Início Silencioso" + }, + "notifications": { + "tunMode": { + "autoDisabled": "Modo TUN desativado automaticamente porque o serviço está indisponível", + "autoDisableFailed": "Falha ao desativar o Modo TUN automaticamente" + } + } + }, + "proxyControl": { + "tooltips": { + "systemProxy": "Ative para modificar as configurações de proxy do sistema operacional. Se a ativação falhar, modifique as configurações de proxy do sistema operacional manualmente", + "tunMode": "Modo Tun (placa de rede virtual): captura todo o tráfego do sistema. Quando ativado, não é necessário ativar o proxy do sistema.", + "tunUnavailable": "O TUN requer o Modo Serviço ou o Modo Administrador" + }, + "actions": { + "installService": "Instalar Serviço", + "uninstallService": "Desinstalar Serviço", + "switchToServiceMode": "Mudar para o modo serviço" + }, + "fields": { + "systemProxy": "Proxy do Sistema", + "tunMode": "Modo Tun" + }, + "messages": { + "installedCheckProxy": "O serviço está instalado e o núcleo está em execução por meio dele. Verifique o interruptor do proxy do sistema e refaça a alteração se ela não tiver sido aplicada.", + "installedCoreNotOnService": "O serviço está instalado, mas o núcleo não está em execução por meio dele. Verifique o núcleo e tente novamente.", + "installedProxyRestored": "O serviço está instalado, o núcleo está em execução por meio dele e a alteração do proxy do sistema foi aplicada." + } + }, + "externalController": { + "title": "Controlador Externo", + "fields": { + "enable": "Ativar Controlador Externo", + "address": "Controlador Externo", + "secret": "Segredo do Núcleo" + }, + "placeholders": { + "address": "Obrigatório", + "secret": "Recomendado" + }, + "tooltips": { + "copy": "Copiar para a área de transferência" + }, + "messages": { + "addressRequired": "O endereço do controlador não pode estar vazio", + "secretRequired": "O segredo não pode estar vazio", + "copyFailed": "Falha ao copiar", + "controllerCopied": "Endereço do controlador copiado para a área de transferência", + "secretCopied": "Segredo copiado para a área de transferência" + } + }, + "externalCors": { + "title": "Configuração de CORS Externo", + "fields": { + "allowPrivateNetwork": "Permitir acesso à rede privada", + "allowedOrigins": "Origens Permitidas" + }, + "placeholders": { + "origin": "Insira uma URL válida" + }, + "actions": { + "add": "Adicionar" + }, + "messages": { + "alwaysIncluded": "Origens sempre incluídas: {{urls}}" + }, + "tooltips": { + "open": "Configurações de CORS Externo" + } + }, + "appearance": { + "light": "Claro", + "dark": "Escuro", + "system": "Sistema" + }, + "clash": { + "title": "Configurações do Clash", + "form": { + "fields": { + "allowLan": "Permitir LAN", + "dnsOverwrite": "Sobrescrever DNS", + "ipv6": "IPv6", + "unifiedDelay": "Atraso Unificado", + "logLevel": "Nível de Log", + "portConfig": "Configuração de Portas", + "external": "Externo", + "webUI": "Web UI", + "clashCore": "Núcleo do Clash", + "openUwpTool": "Abrir ferramenta UWP", + "updateGeoData": "Atualizar GeoData", + "tunnels": { + "title": "Gerenciamento de Túneis", + "localAddr": "Endereço de Escuta Local", + "localPort": "Porta de Escuta Local", + "targetAddr": "Endereço de Destino", + "targetPort": "Porta de Destino", + "proxyGroup": "Grupo de Proxy", + "proxyNode": "Nó de Proxy", + "protocols": "Protocolo", + "existing": "Túneis Existentes", + "default": "Seguir a Configuração Atual", + "optional": "Opcional", + "messages": { + "incomplete": "Preencha todos os campos obrigatórios do túnel", + "invalidLocalAddr": "Endereço de escuta local inválido", + "invalidLocalPort": "Porta de escuta local inválida", + "invalidTargetAddr": "Endereço de destino inválido", + "invalidTargetPort": "Porta de destino inválida" + }, + "actions": { + "add": "Adicionar", + "addNew": "Adicionar Novo Túnel" + } + } + }, + "tooltips": { + "networkInterface": "Interface de Rede", + "unifiedDelay": "Com o atraso unificado ativado, dois testes de atraso são realizados para eliminar as diferenças de atraso entre diferentes tipos de nós causadas por handshakes de conexão, etc.", + "logLevel": "Este parâmetro é válido apenas para os arquivos de log do núcleo na pasta Service do diretório de logs", + "openUwpTool": "Desde o Windows 8, os aplicativos UWP (como a Microsoft Store) são impedidos de acessar diretamente os serviços de rede do host local, e esta ferramenta pode ser usada para contornar essa restrição" + }, + "options": { + "logLevel": { + "debug": "Debug", + "info": "Info", + "warning": "Aviso", + "error": "Erro", + "silent": "Silencioso" + } + } + } + } + }, + "components": { + "verge": { + "basic": { + "title": "Configurações Básicas do Verge", + "actions": { + "browse": "Procurar" + }, + "trayOptions": { + "showMainWindow": "Abrir Janela", + "showTrayMenu": "Abrir Menu", + "disable": "Desativar" + }, + "fields": { + "language": "Idioma", + "themeMode": "Modo do Tema", + "trayClickEvent": "Ação de Clique na Bandeja", + "copyEnvType": "Tipo de Env ao Copiar", + "startPage": "Página Inicial", + "startupScript": "Script de Inicialização", + "themeSetting": "Configurações do Tema", + "layoutSetting": "Configurações de Layout", + "misc": "Diversos", + "hotkeySetting": "Configurações de Atalhos" + } + }, + "advanced": { + "title": "Configurações Avançadas do Verge", + "tooltips": { + "backupInfo": "Suporta backup local ou via WebDAV dos arquivos de configuração", + "openConfDir": "Se o software funcionar de forma anormal, FAÇA BACKUP e exclua todos os arquivos desta pasta e reinicie o software", + "liteMode": "Fecha a interface gráfica e mantém apenas o núcleo em execução" + }, + "actions": { + "copyVersion": "Copiar Versão" + }, + "notifications": { + "latestVersion": "Você já está na versão mais recente", + "versionCopied": "Versão copiada para a área de transferência" + }, + "fields": { + "backupSetting": "Configurações de Backup", + "runtimeConfig": "Configuração em Execução", + "openConfDir": "Abrir Pasta de Configuração", + "openCoreDir": "Abrir Pasta do Núcleo", + "openLogsDir": "Abrir Pasta de Logs", + "checkUpdates": "Verificar Atualizações", + "openDevTools": "Ferramentas de Desenvolvedor", + "liteModeSettings": "Configurações do Modo Leve", + "exit": "Sair", + "exportDiagnostics": "Exportar Informações de Diagnóstico", + "vergeVersion": "Versão do Verge" + } + }, + "theme": { + "title": "Configurações do Tema", + "fields": { + "primaryColor": "Cor Primária", + "secondaryColor": "Cor Secundária", + "primaryText": "Texto Primário", + "secondaryText": "Texto Secundário", + "infoColor": "Cor de Informação", + "warningColor": "Cor de Aviso", + "errorColor": "Cor de Erro", + "successColor": "Cor de Sucesso", + "fontFamily": "Família da Fonte", + "cssInjection": "Injeção de CSS" + }, + "actions": { + "editCss": "Editar CSS" + }, + "dialogs": { + "editCssTitle": "Editar CSS" + } + }, + "layout": { + "title": "Configurações de Layout", + "fields": { + "preferSystemTitlebar": "Preferir Barra de Título do Sistema", + "trafficGraph": "Gráfico de Tráfego", + "memoryUsage": "Uso do Núcleo", + "proxyGroupIcon": "Ícone do Grupo de Proxy", + "toastPosition": "Posição das Notificações", + "hoverNavigator": "Navegador de Salto ao Passar o Mouse", + "hoverNavigatorDelay": "Atraso do Navegador de Salto ao Passar o Mouse", + "navIcon": "Ícone de Navegação", + "collapseNavBar": "Recolher Barra de Navegação", + "trayIcon": "Ícone da Bandeja", + "proxyGroupsDisplayMode": "Modo de Exibição dos Grupos de Proxy", + "showOutboundModesInline": "Mostrar Modos de Saída em Linha", + "commonTrayIcon": "Ícone Comum da Bandeja", + "systemProxyTrayIcon": "Ícone da Bandeja do Proxy do Sistema", + "tunTrayIcon": "Ícone da Bandeja do Tun", + "enableTrayIcon": "Ativar Ícone da Bandeja", + "enableTraySpeed": "Ativar Velocidade na Bandeja", + "pauseRenderTrafficStatsOnBlur": "Pausar a renderização das estatísticas de tráfego quando sem foco" + }, + "tooltips": { + "hoverNavigator": "Rola automaticamente até o grupo de proxy correspondente ao passar o mouse sobre as letras do alfabeto", + "hoverNavigatorDelay": "Atraso antes da rolagem automática ao passar o mouse, em milissegundos" + }, + "options": { + "icon": { + "monochrome": "Monocromático", + "colorful": "Colorido", + "disable": "Desativar" + }, + "toastPosition": { + "topLeft": "Superior Esquerdo", + "topRight": "Superior Direito", + "bottomLeft": "Inferior Esquerdo", + "bottomRight": "Inferior Direito" + }, + "proxyGroupsDisplayMode": { + "default": "Padrão", + "inline": "Em Linha", + "disable": "Desativar" + } + } + } + } + }, + "modals": { + "clashPort": { + "title": "Configuração de Portas", + "fields": { + "mixed": "Porta Mista", + "socks": "Porta Socks", + "http": "Porta Http(s)", + "redir": "Porta Redir", + "tproxy": "Porta Tproxy" + }, + "actions": { + "random": "Porta Aleatória" + }, + "messages": { + "portInUse": "A porta {{port}} já está em uso", + "runningPort": "Usando atualmente a porta {{port}}", + "duplicatePort": "Os listeners ativados devem usar portas diferentes", + "invalidPort": "Insira uma porta entre 1 e 65535", + "saved": "Configurações de porta salvas", + "saveFailed": "Falha ao salvar as configurações de porta", + "automaticFallback": "A porta {{oldPort}} estava em uso, então esta sessão é executada em {{newPort}}. Sua configuração não foi alterada e {{oldPort}} será tentada novamente na próxima inicialização.", + "automaticFallbackFailed": "Falha no fallback automático da porta mista do proxy: {{error}}", + "portTooLow": "A porta deve ser 1000 ou maior", + "portTooHigh": "A porta deve ser 65535 ou menor" + } + }, + "clashCore": { + "variants": { + "release": "Versão Release", + "alpha": "Versão Alpha" + } + }, + "liteMode": { + "title": "Configurações do Modo Leve", + "actions": { + "enterNow": "Entrar no Modo Leve Agora" + }, + "toggles": { + "autoEnter": "Entrar Automaticamente no Modo Leve" + }, + "tooltips": { + "autoEnter": "Ative para acionar automaticamente o Modo Leve depois que a janela ficar fechada por um período" + }, + "fields": { + "delay": "Atraso para Entrar Automaticamente no Modo Leve" + }, + "messages": { + "autoEnterHint": "Ao fechar a janela, o Modo Leve será ativado automaticamente após {{n}} minutos" + } + }, + "backup": { + "title": "Configurações de Backup", + "tabs": { + "local": "Backup local", + "webdav": "Backup WebDAV" + }, + "actions": { + "selectTarget": "Selecionar destino do backup", + "backup": "Backup", + "export": "Exportar", + "exportBackup": "Exportar Backup", + "importBackup": "Importar Backup", + "deleteBackup": "Excluir Backup", + "restore": "Restaurar", + "restoreBackup": "Restaurar Backup", + "viewHistory": "Ver histórico" + }, + "fields": { + "webdavUrl": "URL do Servidor WebDAV", + "username": "Usuário", + "info": "Os backups são armazenados localmente no diretório de dados do aplicativo. Use a lista abaixo para restaurar ou excluir backups." + }, + "messages": { + "webdavUrlRequired": "A URL do WebDAV não pode estar vazia", + "invalidWebdavUrl": "Formato de URL do WebDAV inválido", + "usernameRequired": "O usuário não pode estar vazio", + "passwordRequired": "A senha não pode estar vazia", + "webdavConfigSaved": "Configuração do WebDAV salva com sucesso", + "webdavConfigSaveFailed": "Falha ao salvar a configuração do WebDAV: {{error}}", + "backupCreated": "Backup criado com sucesso", + "backupFailed": "Falha no backup: {{error}}", + "localBackupCreated": "Backup local criado com sucesso", + "localBackupFailed": "Falha no backup local", + "restoreSuccess": "Restauração concluída, o aplicativo será reiniciado em 1s", + "localBackupExported": "Backup local exportado com sucesso", + "localBackupExportFailed": "Falha ao exportar o backup local", + "localBackupImported": "Backup local importado com sucesso", + "localBackupImportFailed": "Falha ao importar o backup local: {{error}}", + "webdavRefreshSuccess": "Atualização do WebDAV concluída com sucesso", + "webdavRefreshFailed": "Falha na atualização do WebDAV: {{error}}", + "confirmDelete": "Confirmar a exclusão deste arquivo de backup?", + "confirmRestore": "Confirmar a restauração deste arquivo de backup?" + }, + "auto": { + "title": "Backup automático", + "scheduleLabel": "Ativar backup agendado", + "scheduleHelper": "Cria backups locais em segundo plano no intervalo configurado.", + "intervalLabel": "Frequência do backup", + "changeLabel": "Backup em alterações críticas", + "changeHelper": "Faz backup automaticamente quando a Configuração/Script Global Estendido é alterado.", + "options": { + "hours": "A cada {{n}} horas", + "days": "A cada {{n}} dias" + } + }, + "manual": { + "title": "Backup manual", + "local": "Cria um snapshot neste dispositivo, armazenado no diretório de dados do aplicativo.", + "webdav": "Envia um snapshot para o seu servidor WebDAV assim que as credenciais forem definidas.", + "configureWebdav": "Configurar WebDAV" + }, + "history": { + "title": "Histórico de backups", + "summary": "{{count}} backups • mais recente {{recent}}", + "empty": "Nenhum backup disponível", + "unknownPlatform": "desconhecida", + "unknownTime": "Hora desconhecida" + }, + "webdav": { + "title": "Configurações do WebDAV" + }, + "table": { + "filename": "Nome do Arquivo", + "backupTime": "Hora do Backup", + "actions": "Ações", + "noBackups": "Nenhum backup disponível", + "rowsPerPage": "Linhas por página" + } + }, + "misc": { + "title": "Diversos", + "fields": { + "appLogLevel": "Nível de Log do Aplicativo", + "appLogMaxSize": "Tamanho Máximo do Log do Aplicativo", + "appLogMaxCount": "Quantidade Máxima de Logs do Aplicativo", + "autoCloseConnections": "Fechar Conexões Automaticamente", + "autoCheckUpdate": "Verificar Atualizações Automaticamente", + "enableBuiltinEnhanced": "Ativar Aprimoramento Integrado", + "proxyLayoutColumns": "Colunas do Layout de Proxy", + "autoLogClean": "Limpeza Automática de Logs", + "autoDelayDetection": "Detecção Automática de Atraso", + "autoDelayDetectionInterval": "Intervalo da Detecção Automática de Atraso", + "defaultLatencyTest": "Teste de Latência Padrão", + "defaultLatencyTimeout": "Tempo Limite de Latência Padrão" + }, + "tooltips": { + "autoCloseConnections": "Encerra as conexões estabelecidas quando a seleção do grupo de proxy ou o modo de proxy muda", + "enableBuiltinEnhanced": "Tratamento de compatibilidade para o arquivo de configuração", + "autoDelayDetection": "Testa periodicamente a latência do nó atual em segundo plano", + "defaultLatencyTest": "Usado apenas para testes de requisição do cliente HTTP e não afeta o arquivo de configuração" + }, + "options": { + "proxyLayoutColumns": { + "auto": "Colunas Automáticas" + }, + "autoLogClean": { + "never": "Nunca Limpar", + "retainDays": "Manter {{n}} Dias" + } + } + }, + "update": { + "title": "Nova Versão v{{version}}", + "actions": { + "goToRelease": "Ir para a Página da Release", + "update": "Atualizar" + }, + "messages": { + "breakChangeError": "Esta versão é uma atualização importante e não oferece suporte a atualizações dentro do aplicativo. Desinstale-a e baixe e instale a nova versão manualmente", + "available": "Uma nova versão está disponível" + }, + "alerts": { + "note": "Nota", + "tip": "Dica", + "important": "Importante", + "warning": "Aviso", + "caution": "Cuidado" + } + }, + "sysproxy": { + "title": "Configurações do Proxy do Sistema", + "fieldsets": { + "currentStatus": "Proxy do Sistema Atual" + }, + "fields": { + "enableStatus": "Status de Ativação:", + "serverAddr": "Endereço do Servidor: ", + "pacUrl": "URL do PAC: ", + "proxyHost": "Host do Proxy", + "usePacMode": "Usar Modo PAC", + "proxyGuard": "Proteção do Proxy", + "guardDuration": "Duração da Proteção", + "alwaysUseDefaultBypass": "Sempre usar o Bypass Padrão", + "enableBypassCheck": "Validar Formato do Bypass do Proxy", + "proxyBypass": "Configurações de Bypass do Proxy: ", + "bypass": "Bypass: ", + "pacScriptContent": "Conteúdo do Script PAC" + }, + "tooltips": { + "proxyGuard": "Ative para impedir que outros softwares modifiquem as configurações de proxy do sistema operacional" + }, + "messages": { + "durationTooShort": "A duração do daemon do proxy não pode ser menor que 1 segundo", + "invalidBypass": "Formato de Bypass inválido", + "invalidProxyHost": "Formato de host do proxy inválido" + }, + "actions": { + "editPac": "Editar PAC" + } + }, + "tun": { + "title": "Modo Tun", + "fields": { + "stack": "Stack do Tun", + "device": "Nome do Dispositivo", + "autoRoute": "Rota Automática", + "routeExcludeAddress": "Endereços Excluídos da Rota", + "strictRoute": "Rota Estrita", + "autoDetectInterface": "Detectar Interface Automaticamente", + "dnsHijack": "Sequestro de DNS", + "mtu": "Unidade Máxima de Transmissão", + "autoRedirect": "Redirecionamento Automático" + }, + "tooltips": { + "dnsHijack": "Use , para separar vários servidores DNS", + "autoRedirect": "Configura automaticamente os redirecionamentos TCP do nftables/iptables" + }, + "messages": { + "applied": "Configurações Aplicadas", + "invalidRouteExcludeAddress": "Insira um bloco CIDR válido", + "routeExcludeAddressHint": "Somente CIDR IPv4/IPv6 é suportado, como 192.168.0.0/16 ou fd00::/8" + } + }, + "dns": { + "dialog": { + "title": "Sobrescrever DNS", + "profileScope": "A opção de sobrescrever DNS é salva separadamente para cada perfil e se aplica somente a esse perfil.", + "warning": "Se você não está familiarizado com estas configurações, não as modifique e mantenha a Sobrescrita de DNS desativada" + }, + "protection": { + "title": "Ativar Sobrescrita de DNS?", + "message": "Esta assinatura contém configurações de DNS personalizadas. A Sobrescrita de DNS pode substituí-las, impedindo que os nós se conectem ou que alguns sites sejam carregados. Ativar mesmo assim?", + "enableAnyway": "Ativar mesmo assim", + "keepDisabled": "Manter desativada", + "autoDisabled": "A Sobrescrita de DNS foi desativada para preservar as configurações de DNS personalizadas da assinatura atual." + }, + "sections": { + "general": "Configurações de DNS", + "fallbackFilter": "Configurações do Filtro de Fallback", + "hosts": "Configurações de Hosts" + }, + "fields": { + "enable": "Ativar DNS", + "listen": "Escuta do DNS", + "enhancedMode": "Modo Aprimorado", + "fakeIpRange": "Intervalo de Fake IP", + "fakeIpRange6": "Intervalo de Fake IP (IPv6)", + "fakeIpFilterMode": "Modo do Filtro de Fake IP", + "ipv6": { + "label": "IPv6", + "description": "Ativar resolução DNS IPv6" + }, + "preferH3": { + "label": "Preferir H3", + "description": "O DNS DOH usa HTTP/3" + }, + "respectRules": { + "label": "Respeitar Regras", + "description": "As conexões DNS seguem as regras de roteamento" + }, + "useHosts": { + "label": "Usar Hosts", + "description": "Ative para resolver hosts por meio do arquivo hosts" + }, + "useSystemHosts": { + "label": "Usar Hosts do Sistema", + "description": "Ative para resolver hosts por meio do arquivo hosts do sistema" + }, + "directPolicy": { + "label": "Nameserver Direto Segue a Política", + "description": "Define se deve seguir a política de nameserver" + }, + "defaultNameserver": { + "label": "Nameserver Padrão", + "description": "Servidores DNS padrão usados para resolver servidores DNS" + }, + "nameserver": { + "label": "Nameserver", + "description": "Lista de servidores DNS, separados por vírgula" + }, + "fallback": { + "label": "Fallback", + "description": "Lista de servidores DNS de fallback, separados por vírgula" + }, + "proxy": { + "label": "Nameserver do Servidor Proxy", + "description": "Servidores DNS para resolução de domínios dos nós de proxy" + }, + "directNameserver": { + "label": "Nameserver Direto", + "description": "Servidores DNS para resolução de domínios de saída direta, suporta a palavra-chave 'system', separados por vírgula" + }, + "fakeIpFilter": { + "label": "Filtro de Fake IP", + "description": "Domínios que ignoram a resolução de fake IP, separados por vírgula" + }, + "nameserverPolicy": { + "label": "Política de Nameserver", + "description": "Servidor DNS específico por domínio, vários servidores separados por ponto e vírgula, formato: domínio=servidor1;servidor2" + }, + "geoipFiltering": { + "label": "Filtragem GeoIP", + "description": "Ativar filtragem GeoIP para o fallback" + }, + "geoipCode": "Código GeoIP", + "fallbackIpCidr": { + "label": "CIDR de IP do Fallback", + "description": "CIDRs de IP que não usam servidores de fallback, separados por vírgula" + }, + "fallbackDomain": { + "label": "Domínio do Fallback", + "description": "Domínios que usam servidores de fallback, separados por vírgula" + }, + "hosts": { + "label": "Hosts", + "description": "Mapeamento personalizado de domínio para IP ou domínio" + } + }, + "messages": { + "saved": "Configurações de DNS salvas", + "configError": "Erro na configuração de DNS:" + }, + "errors": { + "invalid": "Configuração inválida", + "invalidYaml": "Formato YAML inválido" + } + }, + "webUI": { + "actions": { + "openUrl": "Abrir URL" + }, + "title": "Web UI", + "messages": { + "supportedPlaceholders": "Suporta %host, %port, %secret", + "placeholderInstruction": "Substitua host, port, secret por %host, %port, %secret" + }, + "errors": { + "clashInfoUnavailable": "As informações de conexão do núcleo não estão disponíveis", + "invalidServer": "O endereço do servidor do núcleo \"{{server}}\" é inválido", + "openFailed": "Falha ao abrir a Web UI" + } + }, + "hotkey": { + "toggles": { + "enableGlobal": "Ativar Atalho Global" + }, + "title": "Configurações de Atalhos", + "functions": { + "rule": "Modo Regra", + "global": "Modo Global", + "openOrCloseDashboard": "Abrir/Fechar Painel", + "toggleSystemProxy": "Ativar/Desativar Proxy do Sistema", + "toggleTunMode": "Ativar/Desativar Modo Tun", + "entryLightweightMode": "Entrar no Modo Leve", + "direct": "Modo Direto", + "reactivateProfiles": "Reativar Perfis" + } + }, + "networkInterface": { + "title": "Interface de Rede", + "fields": { + "ipAddress": "Endereço IP", + "macAddress": "Endereço MAC" + } + } + }, + "feedback": { + "errors": { + "clash": { + "configUpdateFailed": "Falha ao atualizar a configuração do núcleo", + "modeUpdateFailed": "Falha ao alterar o modo de proxy", + "restartFailed": "Falha ao reiniciar o núcleo", + "startFailed": "Falha ao iniciar o núcleo", + "serviceCoreStopped": "O núcleo do serviço parou inesperadamente", + "stopFailed": "Falha ao parar o núcleo" + }, + "clashService": { + "installFailed": "Falha ao instalar o serviço", + "uninstallFailed": "Falha ao desinstalar o serviço", + "reinstallFailed": "Falha ao reinstalar o serviço", + "repairFailed": "Falha ao reparar o serviço", + "sidecarFailed": "Falha ao continuar com o Sidecar", + "coreAlreadyRunning": "Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador." + }, + "sysproxy": { + "privilegeRequired": "Não é possível definir o proxy do sistema: o macOS recusou a alteração por falta de privilégios. Instale o serviço do Clash Verge; um administrador também pode desativar a exigência de senha de administrador para configurações do sistema, nas opções avançadas em Ajustes do Sistema → Privacidade e Segurança.", + "directFallback": "Falha ao definir o proxy do sistema; o tráfego foi revertido para conexão direta.", + "guardStopped": "A proteção do proxy do sistema parou após falhar várias vezes ao executar. O proxy do sistema não está mais sendo mantido.", + "sidecarWhileServiceReady": "Não foi possível definir o proxy do sistema porque o núcleo ainda está em execução como Sidecar, embora o serviço do Clash Verge esteja pronto. Reinicie o núcleo para mudar para o modo serviço.", + "coreNotReady": "Não foi possível ativar o proxy do sistema porque o núcleo não está em execução. Inicie o núcleo (ou instale/repare o serviço do Clash Verge) e tente novamente.", + "systemCallFailed": "Não foi possível gravar o proxy do sistema porque uma chamada de sistema do Windows falhou. Geralmente é temporário, então tente novamente." + } + }, + "notifications": { + "clash": { + "restartSuccess": "Núcleo do Clash Reiniciado", + "versionUpdated": "Versão do Núcleo Atualizada", + "alreadyLatestVersion": "Já Está Usando a Versão Mais Recente do Núcleo", + "changeSuccess": "Núcleo alterado com sucesso", + "changeFailed": "Falha ao alterar o núcleo", + "geoDataUpdated": "GeoData Atualizado" + }, + "clashService": { + "appDataNotOwned": "O serviço se recusou a iniciar o núcleo porque, no disco, a pasta de dados do aplicativo não pertence à sua conta (comum quando a pasta pessoal está em um disco externo). Reparar o serviço não resolverá isso. Execute este comando em um terminal e reinicie o aplicativo:", + "installSuccess": "Serviço Instalado com Sucesso", + "permissionFallback": "{{reason}} Mudou para o modo normal (Sidecar); o TUN requer privilégios de administrador. Siga o guia para corrigir e reinstalar o serviço. Se preferir não corrigir ou não souber como, execute o aplicativo como administrador sem instalar o serviço.", + "permissionWritableReason": "Contas comuns podem modificar os arquivos do núcleo ou uma pasta superior, então o serviço se recusou a executar o núcleo com privilégios elevados.", + "permissionRejectedReason": "O serviço não conseguiu confirmar que os arquivos e pastas do núcleo são seguros para execução com privilégios elevados.", + "permissionRepairGuide": "Ver instruções de reparo", + "sidecarFallback": "O serviço não conseguiu iniciar o núcleo. Mudou para o modo Sidecar; o serviço continua indisponível.", + "uninstallSuccess": "Serviço Desinstalado com Sucesso" + }, + "updater": { + "withClashProxySuccess": "Atualização com o proxy do Clash concluída com sucesso", + "withClashProxyFailed": "A atualização falhou mesmo com o proxy do Clash" + } + } + }, + "statuses": { + "clash": { + "stopping": "Parando o Núcleo...", + "restarting": "Reiniciando o Núcleo..." + }, + "clashService": { + "installing": "Instalando o Serviço...", + "uninstalling": "Desinstalando o Serviço..." + } + } +} diff --git a/src/locales/pt/shared.json b/src/locales/pt/shared.json new file mode 100644 index 000000000..c6840b87e --- /dev/null +++ b/src/locales/pt/shared.json @@ -0,0 +1,139 @@ +{ + "actions": { + "cancel": "Cancelar", + "close": "Fechar", + "confirm": "Confirmar", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "new": "Novo", + "enable": "Ativar", + "upgrade": "Atualizar versão", + "restart": "Reiniciar", + "resetToDefault": "Restaurar padrão", + "refresh": "Atualizar", + "retry": "Tentar novamente", + "refreshPage": "Recarregar página", + "showDetails": "Mostrar detalhes", + "hideDetails": "Ocultar detalhes", + "listView": "Visualização em lista", + "tableView": "Visualização em tabela", + "pause": "Pausar", + "resume": "Retomar", + "closeAll": "Fechar tudo", + "clear": "Limpar", + "previous": "Anterior", + "next": "Próximo", + "check": "Verificar" + }, + "labels": { + "updateAt": "Atualizado em", + "timeout": "Tempo limite", + "icon": "Ícone", + "name": "Nome", + "readOnly": "Somente leitura", + "expireTime": "Expira em", + "updateTime": "Hora da atualização", + "usedTotal": "Usado / Total", + "from": "De", + "password": "Senha", + "retryAttempts": "Tentativas", + "downloaded": "Baixado", + "uploaded": "Enviado" + }, + "statuses": { + "enabled": "Ativado", + "disabled": "Desativado", + "saving": "Salvando...", + "empty": "Vazio", + "loading": "Carregando..." + }, + "units": { + "milliseconds": "ms", + "seconds": "segundos", + "minutes": "min", + "hours": "h", + "kilobytes": "KB", + "files": "Arquivos" + }, + "placeholders": { + "resetInput": "Limpar entrada", + "filter": "Condições do filtro", + "matchCase": "Diferenciar maiúsculas", + "matchWholeWord": "Palavra inteira", + "useRegex": "Usar expressão regular" + }, + "validation": { + "invalidRegex": "Expressão regular inválida" + }, + "window": { + "maximize": "Maximizar", + "minimize": "Minimizar" + }, + "editorModes": { + "visualization": "Visualização", + "advanced": "Avançado" + }, + "feedback": { + "errors": { + "unexpected": "Algo deu errado", + "label": "Erro", + "unknown": "Erro desconhecido", + "details": "Detalhes do erro", + "stack": "Pilha de erros", + "componentStack": "Pilha de componentes", + "trafficUnavailable": "Dados de tráfego indisponíveis", + "operationFailed": "A operação falhou", + "trafficStats": "Erro nas estatísticas de tráfego", + "trafficStatsDescription": "O componente de estatísticas de tráfego encontrou um erro e foi desativado para evitar falhas." + }, + "notices": { + "raw": "{{message}}", + "prefixedRaw": "{{prefix}} {{message}}" + }, + "notifications": { + "importSuccess": "Perfil importado com sucesso", + "importSubscriptionSuccess": "Assinatura importada com sucesso", + "importWithClashProxy": "Perfil importado com o proxy do Clash", + "updateAvailable": "Atualização disponível", + "saved": "Salvo com sucesso", + "common": { + "copySuccess": "Copiado com sucesso", + "saveSuccess": "Configuração salva com sucesso", + "saveFailed": "Falha ao salvar a configuração", + "refreshFailed": "Falha ao atualizar; exibindo o último valor carregado" + } + }, + "validation": { + "config": { + "failed": "A validação da configuração da assinatura falhou. Verifique o arquivo de configuração da assinatura; as modificações foram revertidas.", + "bootFailed": "A validação da configuração da assinatura na inicialização falhou. Iniciado com a configuração padrão; verifique o arquivo de configuração da assinatura.", + "processTerminated": "O processo de validação foi encerrado. Verifique se algum software de segurança está impedindo a execução do núcleo ou se a CPU e o ambiente de execução do seu computador são compatíveis com o executável do núcleo." + }, + "script": { + "syntaxError": "Erro de sintaxe no script, alterações revertidas", + "missingMain": "Erro no script, alterações revertidas", + "fileNotFound": "Arquivo ausente, alterações revertidas", + "fileError": "Erro no arquivo de script, alterações revertidas" + }, + "yaml": { + "syntaxError": "Erro de sintaxe YAML, alterações revertidas", + "readError": "Erro de leitura YAML, alterações revertidas", + "mappingError": "Erro de mapeamento YAML, alterações revertidas" + }, + "merge": { + "syntaxError": "Erro de sintaxe no arquivo de mesclagem, alterações revertidas", + "mappingError": "Erro de mapeamento no arquivo de mesclagem, alterações revertidas" + } + } + }, + "filters": { + "logLevels": { + "all": "ALL", + "debug": "DEBUG", + "info": "INFO", + "warn": "WARN", + "error": "ERROR" + } + } +} diff --git a/src/locales/pt/tests.json b/src/locales/pt/tests.json new file mode 100644 index 000000000..20c04d542 --- /dev/null +++ b/src/locales/pt/tests.json @@ -0,0 +1,60 @@ +{ + "page": { + "actions": { + "testAll": "Testar tudo" + }, + "title": "Testar" + }, + "components": { + "item": { + "actions": { + "test": "Testar" + } + } + }, + "modals": { + "test": { + "title": { + "create": "Criar teste", + "edit": "Editar teste" + }, + "fields": { + "url": "URL de teste" + }, + "errors": { + "nameRequired": "Informe um nome", + "urlRequired": "Informe uma URL de teste", + "invalidSvg": "O ícone não é um SVG válido", + "uidMissing": "O identificador do item de teste está ausente", + "saveFailed": "Falha ao salvar o item de teste" + } + } + }, + "statuses": { + "test": { + "pending": "Pendente", + "yes": "Sim", + "no": "Não", + "failed": "Falhou", + "completed": "Concluído", + "disallowedIsp": "ISP não permitido", + "originalsOnly": "Somente originais", + "noDisney": "Não (IP banido pelo Disney+)", + "unsupportedRegion": "País/região não suportado", + "failedNetwork": "Falhou (conexão de rede)" + } + }, + "unlock": { + "page": { + "actions": { + "testing": "Testando..." + }, + "empty": "Nenhum item de teste de desbloqueio", + "messages": { + "detectionFailedWithName": "Falha na detecção de {{name}}", + "detectionTimeout": "Tempo limite da detecção esgotado ou falhou" + }, + "title": "Teste de desbloqueio" + } + } +} diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index e8541bdd8..550bd9615 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Не удалось переустановить службу", "repairFailed": "Не удалось восстановить службу", "sidecarFailed": "Не удалось продолжить в режиме Sidecar", - "coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку." + "coreAlreadyRunning": "Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер." }, "sysproxy": { "privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 262f730c0..fad3447e0 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Hizmet yeniden kurulamadı", "repairFailed": "Hizmet onarılamadı", "sidecarFailed": "Sidecar ile devam edilemedi", - "coreAlreadyRunning": "Sidecar modu başlatılamadı: başka bir çekirdek hâlâ çalışıyor (PID {{pid}}). Diğer Clash Verge örneklerini veya bu çekirdeği kullanan uygulamayı kapatıp yeniden deneyin." + "coreAlreadyRunning": "Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın." }, "sysproxy": { "privilegeRequired": "Sistem vekil sunucusu ayarlanamıyor: macOS yetki yetersizliği nedeniyle değişikliği reddetti. Clash Verge hizmetini kurun; yöneticiyseniz Sistem Ayarları → Gizlilik ve Güvenlik bölümündeki gelişmiş seçeneklerden, sistem genelindeki ayarlar için yönetici parolası zorunluluğunu da kapatabilirsiniz.", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 774b90f0b..68154eae4 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Хезмәтне яңадан урнаштырып булмады", "repairFailed": "Хезмәтне төзәтеп булмады", "sidecarFailed": "Sidecar белән дәвам итеп булмады", - "coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз." + "coreAlreadyRunning": "Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз." }, "sysproxy": { "privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 38e37127d..936f2cb15 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "重新安装服务失败", "repairFailed": "修复服务失败", "sidecarFailed": "继续使用 Sidecar 失败", - "coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。" + "coreAlreadyRunning": "无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。" }, "sysproxy": { "privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 35e956b28..569ce3347 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "重新安裝服務失敗", "repairFailed": "修復服務失敗", "sidecarFailed": "繼續使用 Sidecar 失敗", - "coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。" + "coreAlreadyRunning": "無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。" }, "sysproxy": { "privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。", diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 6599b2a57..efb7ef99f 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -212,10 +212,12 @@ const ProfilePage = () => { } setLoading(true) + const prevCurrent = profiles.current + const handleImportSuccess = async (noticeKey: string) => { showNotice.success(noticeKey) setUrl('') - await performRobustRefresh() + await performRobustRefresh(prevCurrent) } try { await mutate(() => importProfile(url), { @@ -261,7 +263,7 @@ const ProfilePage = () => { } // `useProfiles` already retries three times; add only one business-level retry. - const performRobustRefresh = async () => { + const performRobustRefresh = async (prevCurrent?: string | null) => { let retryCount = 0 const maxRetries = 1 const baseDelay = 200 @@ -270,13 +272,15 @@ const ProfilePage = () => { try { debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`) - await mutateProfiles() + const fresh = await fetchProfilesIntoCache() await new Promise((resolve) => setTimeout(resolve, baseDelay * (retryCount + 1)), ) - await onEnhance(false) + if (fresh?.current !== prevCurrent) { + await onEnhance(false) + } return } catch (error) { console.error(`[导入刷新] 第${retryCount + 1}次刷新失败:`, error) @@ -289,8 +293,10 @@ const ProfilePage = () => { console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`) try { - await fetchProfilesIntoCache() - await onEnhance(false) + const fresh = await fetchProfilesIntoCache() + if (fresh?.current !== prevCurrent) { + await onEnhance(false) + } showNotice.error( 'profiles.page.feedback.notifications.importNeedsRefresh', 3000, @@ -888,7 +894,9 @@ const ProfilePage = () => { mb: 1.5, display: 'grid', overflow: 'hidden', - gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', + // Up to 4 columns until a column would pass 480px, then more; 24px is the 3 gaps from `gap: 1`. + gridTemplateColumns: + 'repeat(auto-fill, minmax(clamp(260px, calc((100% - 24px) / 4), 480px), 1fr))', gap: 1, px: 0.5, }} diff --git a/src/services/bindings.ts b/src/services/bindings.ts index 0dd8f9aed..970875fe4 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -659,6 +659,7 @@ export type PrfItem_Deserialize = { desc?: string | null url?: string | null selected?: PrfSelected[] | null + latency_test_urls?: { [key in string]: string } | null extra?: PrfExtra | null updated?: number | null option?: PrfOption_Deserialize | null @@ -674,6 +675,7 @@ export type PrfItem_Serialize = { desc?: string | null url?: string | null selected?: PrfSelected[] | null + latency_test_urls?: { [key in string]: string } | null extra?: PrfExtra | null updated: number | null option?: PrfOption_Serialize | null @@ -768,6 +770,7 @@ export type ProfilePatch_Deserialize = { file?: string | null url?: string | null selected?: PrfSelected[] | null + latency_test_urls?: { [key in string]: string } | null extra?: PrfExtra | null updated?: number | null option?: PrfOption_Deserialize | null @@ -780,6 +783,7 @@ export type ProfilePatch_Serialize = { file: string | null url: string | null selected: PrfSelected[] | null + latency_test_urls: { [key in string]: string } | null extra: PrfExtra | null updated: number | null option: PrfOption_Serialize | null @@ -801,6 +805,11 @@ export type ProxyCapabilities = { smux: boolean } +export type ProxyExtra = { + alive: boolean + history: DelayHistory[] +} + export type ProxyGroupView = | ProxyGroupView_Serialize | ProxyGroupView_Deserialize @@ -815,6 +824,7 @@ export type ProxyGroupView_Deserialize = { icon: string | null testUrl: string | null history: DelayHistory[] + extra: { [key in string]: ProxyExtra } members: ProxyMemberRef[] } & ProxyCapabilities @@ -828,6 +838,7 @@ export type ProxyGroupView_Serialize = { icon?: string | null testUrl?: string | null history: DelayHistory[] + extra: { [key in string]: ProxyExtra } members: ProxyMemberRef[] } & ProxyCapabilities @@ -853,6 +864,7 @@ export type ProxyNodeView_Deserialize = { type: string alive: boolean history: DelayHistory[] + extra: { [key in string]: ProxyExtra } id: string | null hidden: boolean | null icon: string | null @@ -866,6 +878,7 @@ export type ProxyNodeView_Serialize = { type: string alive: boolean history: DelayHistory[] + extra: { [key in string]: ProxyExtra } id?: string | null hidden?: boolean | null icon?: string | null diff --git a/src/services/cmds.ts b/src/services/cmds.ts index f46377335..001ee3c71 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -91,8 +91,18 @@ export async function deleteProfile(index: string) { } export async function patchProfile(index: string, profile: ProfilePatch) { - const { type, name, desc, file, url, selected, extra, updated, option } = - profile + const { + type, + name, + desc, + file, + url, + selected, + latency_test_urls, + extra, + updated, + option, + } = profile return commands.patchProfile(index, { type, name, @@ -100,6 +110,7 @@ export async function patchProfile(index: string, profile: ProfilePatch) { file, url, selected, + latency_test_urls, extra, updated, option, diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index a4d7c92fd..5f6a51afe 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -59,3 +59,140 @@ describe('group delay completion', () => { stop() }) }) + +test('uses newer core history after a tray test without hiding an active page test', () => { + const member = node('tray-refresh') + if (member.kind !== 'node') throw new Error('Expected node') + const cached = delayManager.setDelay(member, 'tray-group', 50) + member.node.extra = { + [delayManager.getUrl('tray-group')]: { + alive: true, + history: [ + { time: new Date(cached.updatedAt + 1000).toISOString(), delay: 300 }, + ], + }, + } + + expect(delayManager.getDelayFix(member, 'tray-group')).toBe(300) + expect(delayManager.getDelayUpdate(member, 'tray-group')?.delay).toBe(300) + + delayManager.setDelay(member, 'tray-group', -2) + expect(delayManager.getDelayFix(member, 'tray-group')).toBe(-2) +}) + +test('latency URL validation rejects malformed hosts and ports and trims valid URLs', () => { + for (const url of [ + 'http://?', + 'http://host:invalid', + 'http://bad host/', + 'ftp://localhost', + 'http:localhost', + ]) { + delayManager.setUrl('url-validation', url) + expect(delayManager.getUrl('url-validation')).toBe( + 'http://cp.cloudflare.com/generate_204', + ) + } + delayManager.setUrl('url-validation', ' HTTP://localhost:8080/ ') + expect(delayManager.getUrl('url-validation')).toBe('HTTP://localhost:8080/') +}) + +test('another URL cannot replace a group measurement, but a newer matching URL can', () => { + const member = node('url-isolation') + if (member.kind !== 'node') throw new Error('Expected node') + delayManager.setUrl('url-a', 'http://localhost/a') + const cached = delayManager.setDelay(member, 'url-a', 50) + const newer = new Date(cached.updatedAt + 1000).toISOString() + Object.assign(member.node, { + history: [{ time: newer, delay: 126 }], + extra: { + 'http://localhost/a': { + alive: true, + history: [ + { time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 }, + ], + }, + 'http://localhost/b': { + alive: true, + history: [{ time: newer, delay: 126 }], + }, + }, + }) + expect(delayManager.getDelayFix(member, 'url-a')).toBe(50) + Object.assign(member.node, { + extra: { + 'http://localhost/a': { + alive: true, + history: [{ time: newer, delay: 75 }], + }, + }, + }) + expect(delayManager.getDelayFix(member, 'url-a')).toBe(75) +}) + +test('same-named provider members keep separate measurements and listeners', async () => { + const first = node('same-provider-name') + const second = node('same-provider-name') + if (first.kind !== 'node' || second.kind !== 'node') + throw new Error('Expected nodes') + first.node.recordId = 'provider-a:N' + first.node.source = { kind: 'provider', providerName: 'a', proxyName: 'N' } + second.node.recordId = 'provider-b:N' + second.node.source = { kind: 'provider', providerName: 'b', proxyName: 'N' } + const { healthcheckNodeInProvider } = await import('tauri-plugin-mihomo-api') + vi.mocked(healthcheckNodeInProvider) + .mockResolvedValueOnce({ delay: 40 }) + .mockResolvedValueOnce({ delay: 125 }) + const firstListener = vi.fn() + const secondListener = vi.fn() + delayManager.setListener(first, 'provider-collision', firstListener) + delayManager.setListener(second, 'provider-collision', secondListener) + await delayManager.checkListDelay([first, second], 'provider-collision', 5000) + await flush() + expect(firstListener).toHaveBeenLastCalledWith( + expect.objectContaining({ delay: 40 }), + ) + expect(secondListener).toHaveBeenLastCalledWith( + expect.objectContaining({ delay: 125 }), + ) + delayManager.removeListener(first, 'provider-collision') + delayManager.removeListener(second, 'provider-collision') + expect(delayManager.getDelayFix(first, 'provider-collision')).toBe(40) + expect(delayManager.getDelayFix(second, 'provider-collision')).toBe(125) +}) + +test.each(['core', 'provider'] as const)( + 'cached %s delays follow source identity after record IDs move', + (kind) => { + const original = node(`stable-${kind}`) + const replacement = node(`replacement-${kind}`) + if (original.kind !== 'node' || replacement.kind !== 'node') + throw new Error('Expected nodes') + const source = (proxyName: string) => + kind === 'core' + ? { kind, proxyName } + : { kind, providerName: 'subscription', proxyName } + original.node.source = source(original.ref.name) + replacement.node.source = source(replacement.ref.name) + original.node.recordId = 'position:0' + replacement.node.recordId = 'position:0' + delayManager.setDelay(original, `reorder-${kind}`, 40) + expect(delayManager.getDelayFix(replacement, `reorder-${kind}`)).toBe(-1) + original.node.recordId = 'position:1' + expect(delayManager.getDelayFix(original, `reorder-${kind}`)).toBe(40) + }, +) + +test('unmeasured selectors show automatic history without replacing a manual URL result', () => { + const member = node('selector-member') + if (member.kind !== 'node') throw new Error('Expected node') + member.node.history = [ + { time: new Date(Date.now() + 1000).toISOString(), delay: 126 }, + ] + member.node.extra = { + 'http://localhost/automatic': { alive: true, history: member.node.history }, + } + expect(delayManager.getDelayFix(member, 'selector')).toBe(126) + delayManager.setDelay(member, 'selector', 50) + expect(delayManager.getDelayFix(member, 'selector')).toBe(50) +}) diff --git a/src/services/delay.ts b/src/services/delay.ts index 18602faaf..323809b67 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -12,13 +12,23 @@ import { } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' export type DelaySnapshot = { of: (member: ResolvedProxyMember) => number } -const hashKey = (name: string, group: string) => `${group ?? ''}::${name}` +const hashKey = (member: ResolvedProxyMember, group: string) => { + if (member.kind !== 'node') + return JSON.stringify([group, member.kind, member.ref.name]) + const source = member.node.source + return JSON.stringify([ + group, + source.kind, + source.kind === 'provider' ? source.providerName : null, + source.proxyName, + ]) +} export interface DelayUpdate { delay: number @@ -29,7 +39,7 @@ export interface DelayUpdate { const CACHE_TTL = 30 * 60 * 1000 class DelayManager { - private cache = new Map() + private cache = new Map() private urlMap = new Map() private listenerMap = new Map void>() @@ -153,13 +163,13 @@ class DelayManager { } setUrl(group: string, url: string) { - if (!isValidUrl(url)) { - debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`) + const previous = this.getUrl(group) + if (isValidLatencyTestUrl(url)) { + this.urlMap.set(group, url.trim()) + } else { this.urlMap.delete(group) - return } - debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`) - this.urlMap.set(group, url) + if (previous !== this.getUrl(group)) this.queueGroupNotification(group) } getUrl(group: string) { @@ -171,16 +181,16 @@ class DelayManager { } setListener( - name: string, + member: ResolvedProxyMember, group: string, listener: (update: DelayUpdate) => void, ) { - const key = hashKey(name, group) + const key = hashKey(member, group) this.listenerMap.set(key, listener) } - removeListener(name: string, group: string) { - const key = hashKey(name, group) + removeListener(member: ResolvedProxyMember, group: string) { + const key = hashKey(member, group) this.listenerMap.delete(key) } @@ -199,14 +209,14 @@ class DelayManager { } setDelay( - name: string, + member: ResolvedProxyMember, group: string, delay: number, - meta?: { elapsed?: number }, + meta?: { elapsed?: number; url?: string }, ): DelayUpdate { - const key = hashKey(name, group) + const key = hashKey(member, group) debugLog( - `[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`, + `[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`, ) const update: DelayUpdate = { delay, @@ -214,7 +224,9 @@ class DelayManager { updatedAt: Date.now(), } - this.cache.set(key, update) + const url = meta?.url ?? this.getUrl(group) + if (url !== this.getUrl(group)) return update + this.cache.set(key, { ...update, url }) const queue = this.pendingItemUpdates.get(key) if (queue) { @@ -227,9 +239,16 @@ class DelayManager { return update } - getDelayUpdate(name: string, group: string) { - const key = hashKey(name, group) - const entry = this.cache.get(key) + getDelayUpdate(member: ResolvedProxyMember, group: string) { + const history = this.getHistory(member, group) + const key = hashKey(member, group) + const cached = this.cache.get(key) + const entry = cached?.url === this.getUrl(group) ? cached : undefined + const latest = history?.at(-1) + const updatedAt = latest ? Date.parse(latest.time) : 0 + if (latest && entry?.delay !== -2 && updatedAt > (entry?.updatedAt ?? 0)) { + return { delay: latest.delay || 1e6, updatedAt } + } if (!entry) return undefined if (Date.now() - entry.updatedAt > CACHE_TTL) { @@ -240,23 +259,25 @@ class DelayManager { return { ...entry } } - getDelay(name: string, group: string) { - const update = this.getDelayUpdate(name, group) + getDelay(member: ResolvedProxyMember, group: string) { + const update = this.getDelayUpdate(member, group) return update ? update.delay : -1 } + getHistory(member: ResolvedProxyMember, group: string) { + return memberDetails(member)?.extra?.[this.getUrl(group)]?.history + } + getDelayFix(member: ResolvedProxyMember, group: string) { if (member.kind === 'unresolved') return -1 - const details = memberDetails(member) - const name = member.ref.name - const update = this.getDelayUpdate(name, group) + const history = + this.getHistory(member, group) ?? memberDetails(member)?.history + const update = this.getDelayUpdate(member, group) if (update && (update.delay >= 0 || update.delay === -2)) { return update.delay } - if (details?.history && details.history.length > 0) { - return details.history[details.history.length - 1].delay || 1e6 - } + if (history?.length) return history[history.length - 1].delay || 1e6 return -1 } @@ -298,12 +319,12 @@ class DelayManager { `[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`, ) - this.setDelay(name, group, -2) + const url = this.getUrl(group) + this.setDelay(member, group, -2, { url }) const startTime = Date.now() try { - const url = this.getUrl(group) debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`) const timeoutPromise = new Promise((resolve) => { @@ -324,14 +345,14 @@ class DelayManager { const elapsed = elapsedTime debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`) - return this.setDelay(name, group, delay, { elapsed }) + return this.setDelay(member, group, delay, { elapsed, url }) } catch (error) { await new Promise((resolve) => setTimeout(resolve, 500)) console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error) const delay = 1e6 // error const elapsed = Date.now() - startTime - return this.setDelay(name, group, delay, { elapsed }) + return this.setDelay(member, group, delay, { elapsed, url }) } } @@ -344,10 +365,9 @@ class DelayManager { debugLog( `[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`, ) - const names = proxies.map((member) => member.ref.name) this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1) - names.forEach((name) => { - this.setDelay(name, group, -2) + proxies.forEach((member) => { + this.setDelay(member, group, -2) }) let index = 0 @@ -359,7 +379,7 @@ class DelayManager { const currName = currMember.ref.name try { - this.setDelay(currName, group, -2) + this.setDelay(currMember, group, -2) // Stagger requests so a batch does not hit the core at once. if (index > 1) { @@ -375,13 +395,13 @@ class DelayManager { `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, error, ) - this.setDelay(currName, group, 1e6) + this.setDelay(currMember, group, 1e6) } return help() } - const actualConcurrency = Math.min(concurrency, names.length, 10) + const actualConcurrency = Math.min(concurrency, proxies.length, 10) debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`) const promiseList: Promise[] = [] diff --git a/src/services/i18n.ts b/src/services/i18n.ts index d614c5e90..588de9ea5 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -15,6 +15,7 @@ export const supportedLanguages = [ 'es', 'jp', 'zhtw', + 'pt', ] export const FALLBACK_LANGUAGE = 'zh' diff --git a/src/services/proxy-test-url.test.ts b/src/services/proxy-test-url.test.ts new file mode 100644 index 000000000..4f2969c88 --- /dev/null +++ b/src/services/proxy-test-url.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test, vi } from 'vitest' + +vi.mock('@/services/cmds', () => ({ + getProfiles: vi.fn(), + patchProfile: vi.fn().mockResolvedValue(undefined), + syncTrayProxySelection: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) +vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } })) + +import { + getProfiles, + patchProfile, + syncTrayProxySelection, +} from '@/services/cmds' +import { revalidateQueries } from '@/services/query-client' + +import { + profileTestUrls, + saveProfileTestUrls, + commitProfileTestUrl, +} from './proxy-test-url' + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +test('committed edits retain their profile and merge with fresh saved URLs', async () => { + vi.mocked(getProfiles).mockResolvedValue({ + current: 'new-profile', + items: [ + { + uid: 'old-profile', + type: null, + name: null, + file: null, + updated: null, + latency_test_urls: { untouched: 'https://example.com/' }, + }, + ], + }) + await commitProfileTestUrl('old-profile', 'A', ' http://localhost/new ') + expect(patchProfile).toHaveBeenCalledExactlyOnceWith('old-profile', { + latency_test_urls: { + untouched: 'https://example.com/', + A: 'http://localhost/new', + }, + }) + expect(revalidateQueries).toHaveBeenCalledWith([['getProfiles']]) + expect(syncTrayProxySelection).toHaveBeenCalledOnce() +}) + +test('migration cannot overwrite a previously saved or cleared override', async () => { + vi.mocked(getProfiles).mockResolvedValue({ + items: [ + { + uid: 'p', + type: null, + name: null, + file: null, + updated: null, + latency_test_urls: { A: '' }, + }, + ], + }) + await saveProfileTestUrls('p', { A: 'http://localhost/stale' }, true) + expect(patchProfile).not.toHaveBeenCalled() +}) + +test('pending edits survive a reload and older save acknowledgements', async () => { + vi.useFakeTimers() + const profile = { + uid: 'pending-profile', + type: null, + name: null, + file: null, + updated: null, + latency_test_urls: { A: 'http://localhost/old' }, + } + vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) + let finish!: () => void + vi.mocked(patchProfile).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(null) + }), + ) + const first = commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + await vi.advanceTimersByTimeAsync(300) + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + const second = commitProfileTestUrl( + profile.uid, + 'A', + 'http://localhost/newest', + ) + finish() + await first + expect(profileTestUrls(profile).A).toBe('http://localhost/newest') + await second + expect(patchProfile).toHaveBeenLastCalledWith(profile.uid, { + latency_test_urls: { A: 'http://localhost/newest' }, + }) +}) + +test('a failed save preserves the committed input across profile refreshes', async () => { + const profile = { + uid: 'failed-profile', + type: null, + name: null, + file: null, + updated: null, + latency_test_urls: { A: 'http://localhost/old' }, + } + vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) + vi.mocked(patchProfile).mockRejectedValueOnce(new Error('save failed')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/old') + log.mockRestore() +}) diff --git a/src/services/proxy-test-url.ts b/src/services/proxy-test-url.ts new file mode 100644 index 000000000..059b632fe --- /dev/null +++ b/src/services/proxy-test-url.ts @@ -0,0 +1,101 @@ +import { + getProfiles, + patchProfile, + syncTrayProxySelection, +} from '@/services/cmds' +import { mutate } from '@/services/mutate' + +function readLegacyTestUrls(uid: string): Record { + try { + const states = JSON.parse(localStorage.getItem('proxy-head-state') || '{}')[ + uid + ] + return Object.fromEntries( + Object.entries(states || {}).flatMap(([name, state]) => { + const url = (state as { testUrl?: string }).testUrl + return typeof url === 'string' && url.trim() ? [[name, url.trim()]] : [] + }), + ) + } catch { + return {} + } +} + +export async function saveProfileTestUrls( + uid: string, + urls: Record, + migrateOnly = false, +) { + return mutate( + async () => { + const profiles = await getProfiles() + const profile = profiles.items?.find((item) => item.uid === uid) + if (!profile || (migrateOnly && profile.latency_test_urls !== undefined)) + return + await patchProfile(uid, { + latency_test_urls: { + ...(profile.latency_test_urls ?? readLegacyTestUrls(uid)), + ...urls, + }, + }) + await syncTrayProxySelection().catch((error) => { + console.error( + 'Failed to refresh tray after saving latency test URL:', + error, + ) + }) + }, + { id: `patch-profile:${uid}`, revalidate: [['getProfiles']] }, + ) +} + +const migrating = new Set() + +export function migrateProfileTestUrls(profiles: IProfilesConfig) { + for (const profile of profiles.items || []) { + const uid = profile.uid + if (!uid) continue + if (profile.latency_test_urls !== undefined || migrating.has(uid)) continue + const urls = readLegacyTestUrls(uid) + if (!Object.keys(urls).length) continue + migrating.add(uid) + void saveProfileTestUrls(uid, urls, true) + .catch((error) => { + console.error('Failed to migrate latency test URLs:', error) + }) + .finally(() => migrating.delete(uid)) + } +} + +const pending = new Map>() + +export async function commitProfileTestUrl( + uid: string, + group: string, + url: string, +) { + const edit = { url: url.trim() } + const urls = { ...pending.get(uid), [group]: edit } + pending.set(uid, urls) + try { + await saveProfileTestUrls(uid, { [group]: edit.url }) + const current = pending.get(uid) + if (current?.[group] === edit) { + delete current[group] + if (!Object.keys(current).length) pending.delete(uid) + } + } catch (error) { + console.error('Failed to save latency test URL:', error) + } +} + +export function profileTestUrls(profile: IProfileItem): Record { + return { + ...profile.latency_test_urls, + ...Object.fromEntries( + Object.entries( + (profile.uid ? pending.get(profile.uid) : undefined) ?? {}, + ).map(([group, edit]) => [group, edit.url]), + ), + } +} diff --git a/src/utils/network.ts b/src/utils/network.ts index 647efafc1..99db4a6f9 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -20,6 +20,19 @@ export const isValidUrl = (value: string) => require_tld: false, }) +export const isValidLatencyTestUrl = (value: string) => { + const raw = value.trim() + if (!/^https?:\/\//i.test(raw)) return false + try { + const url = new URL(raw) + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname + ) + } catch { + return false + } +} + export const isValidPort = (value: string) => isPort(value.trim()) export const normalizeHost = (value: string): string | null => {