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 @@
+
+
+
+ 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 |
+| -------------------------------------- | -------------------------------------- |
+|  |  |
+
+## 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 => {