fix(ipc): reconcile Specta bindings with dev

This commit is contained in:
Tunglies
2026-10-12 02:19:25 +08:00
94 changed files with 3551 additions and 391 deletions
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+9 -5
View File
@@ -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');
+90
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.10.0
pnpm 12.11.2
rust 1.99.0
Generated
+8 -13
View File
@@ -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"
+2 -4
View File
@@ -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" }
+3 -20
View File
@@ -1,24 +1,9 @@
## v2.5.8
## v2.5.9
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
**🖥️ Windows**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
</details>
@@ -31,11 +16,9 @@
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
+4 -3
View File
@@ -11,13 +11,14 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
<p align="center">
Languages:
<a href="./README.md">简体中文</a> ·
<a href="./docs/README_en.md">English</a> ·
<a href="./docs/README_es.md">Español</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./docs/README_fa.md">فارسی</a> ·
<a href="./docs/README_ja.md">日本語</a> ·
<a href="./docs/README_ko.md">한국어</a> ·
<a href="./docs/README_fa.md">فارسی</a>
<a href="./docs/README_pt.md">Português</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./README.md">简体中文</a>
</p>
## Preview
+4
View File
@@ -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: إغلاق كل الاتصالات
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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: بستن همه اتصال‌ها
+4
View File
@@ -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
+4
View File
@@ -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: すべての接続を閉じる
+4
View File
@@ -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: 모든 연결 닫기
+94
View File
@@ -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
+4
View File
@@ -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: Закрыть все соединения
+4
View File
@@ -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
+4
View File
@@ -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: Барлык тоташуларны ябу
+4
View File
@@ -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: 关闭所有连接
+4
View File
@@ -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: 關閉所有連線
+52
View File
@@ -1,3 +1,55 @@
## v2.5.8
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
**🖥️ Windows**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
**🖥️/🍎 Windows/macOS**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
## v2.5.7
<details>
+4 -3
View File
@@ -11,13 +11,14 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
<p align="center">
Languages:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Preview
+4 -3
View File
@@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
<p align="center">
Idiomas:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Vista previa
+4 -3
View File
@@ -11,13 +11,14 @@
<p align="center">
زبان‌ها:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## پیش‌نمایش
+4 -3
View File
@@ -11,13 +11,14 @@
<p align="center">
言語:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## プレビュー
+4 -3
View File
@@ -11,13 +11,14 @@
<p align="center">
언어:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## 미리보기
+125
View File
@@ -0,0 +1,125 @@
<h1 align="center">
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
<br>
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
<br>
</h1>
<h3 align="center">
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
</h3>
<p align="center">
Idiomas:
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Pré-visualização
| Escuro | Claro |
| -------------------------------------- | -------------------------------------- |
| ![Visual escuro](./preview_dark.png) | ![Visual claro](./preview_light.png) |
## Instalação
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
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.
+4 -3
View File
@@ -11,13 +11,14 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
<p align="center">
Языки:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Предпросмотр
+2 -2
View File
@@ -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"
}
+61 -61
View File
@@ -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'
+2 -2
View File
@@ -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" }
+3
View File
@@ -373,6 +373,8 @@ pub struct ProfilePatch {
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
@@ -387,6 +389,7 @@ impl From<ProfilePatch> 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,
+14 -2
View File
@@ -108,20 +108,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
#[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
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,
}))
}
+6
View File
@@ -53,6 +53,10 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>,
@@ -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 {
+1
View File
@@ -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);
+12 -3
View File
@@ -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,
+25 -1
View File
@@ -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<std::string::String> {
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));
+72 -5
View File
@@ -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<String>,
#[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>,
#[specta(type = HashMap<String, crate::ipc::ProxyExtra>)]
pub extra: HashMap<String, Extra>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
@@ -93,6 +95,8 @@ pub struct ProxyNodeView {
pub alive: bool,
#[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>,
#[specta(type = HashMap<String, crate::ipc::ProxyExtra>)]
pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[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<String, Proxy>, BTreeMap<String, Proxy>) {
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
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<String, String>) -> Vec<String> {
);
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(())
}
}
+1 -1
View File
@@ -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.*;<local>";
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;<local>";
#[cfg(target_os = "windows")]
static BYPASS_SEPARATOR: &str = ";";
#[cfg(target_os = "linux")]
+156
View File
@@ -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<std::collections::BTreeSet<String>> =
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<std::string::String>)> = 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}");
}
}
}
+1
View File
@@ -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",
+357 -136
View File
@@ -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<Wry>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<Wry>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
}
pub struct Tray {
limiter: SystemLimiter,
proxy_hover_limiter: SystemLimiter,
menu_update: tokio::sync::Mutex<()>,
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
#[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<u16> {
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<u16> {
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<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
view: Option<ProxyViewV1>,
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
) -> Vec<Submenu<Wry>> {
let proxy_submenus: Vec<Submenu<Wry>> = {
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = 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<CheckMenuItem<Wry>> = 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<Wry>> =
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).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<ProxyLatencyItem> = 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<Wry>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
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<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> {
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
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::<HashMap<String, usize>>()
})
.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,
+6
View File
@@ -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<DelayHistory>,
}
fn platform_types() -> specta::Types {
use specta::datatype::{DataType, Fields};
let mut types = specta::Types::default().register::<crate::config::IVerge>();
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.8",
"version": "2.5.9",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
@@ -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(
@@ -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<typeof import('react')>()),
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')
}
},
)
+4 -5
View File
@@ -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 = (
</Link>
</Box>
)}
{t('settings.feedback.errors.clashService.coreAlreadyRunning', {
pid: existingCore[1],
})}
{t('settings.feedback.errors.clashService.coreAlreadyRunning')}
{existingCore[1] && <Box sx={{ mt: 1 }}>PID {existingCore[1]}</Box>}
<Box component="details" sx={{ mt: 1 }}>
<Box component="summary" sx={{ cursor: 'pointer' }}>
{t('shared.feedback.errors.details')}
@@ -684,6 +684,9 @@ export const RulesEditorViewer = (props: Props) => {
ruleType.name !== 'SUB-RULE' && (
<TextField
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
size="small"
sx={{ minWidth: '240px' }}
slotProps={{
+11 -6
View File
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
import { Box, IconButton, type SxProps } from '@mui/material'
import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
@@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { isValidLatencyTestUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(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' && (
<TextField
<ProxyTestUrlInput
name={groupName}
inputRef={inputRef}
autoComplete="new-password"
hiddenLabel
@@ -125,7 +130,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault()
e.stopPropagation()
}}
onChange={(e) => 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
}
+8
View File
@@ -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)
})
}
}),
)
+11 -6
View File
@@ -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' && (
<TextField
<ProxyTestUrlInput
name={groupName}
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
@@ -197,7 +202,7 @@ export const ProxyHead = ({
size="small"
variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -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<TextFieldProps, 'value' | 'onChange'> & {
value: string
onCommit: (url: string) => void
}
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
const { profiles } = useProfiles()
return (
<DraftTestUrlInput
key={JSON.stringify([profiles?.current, name])}
name={name}
value={value}
{...props}
/>
)
}
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
const [draft, setDraft] = useState<string | null>(null)
const pendingRef = useRef<string | null>(null)
const commitOnUnmount = useEffectEvent(() => {
if (pendingRef.current !== null && pendingRef.current !== value)
onCommit(pendingRef.current)
})
useEffect(() => () => commitOnUnmount(), [])
return (
<TextField
{...props}
value={draft ?? value}
onChange={(event) => {
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()
}
}}
/>
)
}
+13 -4
View File
@@ -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)
+60 -18
View File
@@ -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<string, HeadState> }
| { type: 'reset'; profile: string }
| { type: 'replace'; profile: string; payload: Record<string, HeadState> }
| { type: 'update'; groupName: string; patch: Partial<HeadState> }
| { type: 'urls'; urls: Record<string, string> }
interface LoadedHeadState {
profile: string | null
heads: Record<string, HeadState>
}
function headStateReducer(
state: Record<string, HeadState>,
state: LoadedHeadState,
action: HeadStateAction,
): Record<string, HeadState> {
): 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<HeadState>) => {
dispatch({ type: 'update', groupName, patch: obj })
if (current && obj.testUrl !== undefined) {
void commitProfileTestUrl(current, groupName, obj.testUrl)
}
},
[],
[current],
)
return [state, setHeadState] as const
+1
View File
@@ -117,6 +117,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false,
smux: false,
history: [],
extra: {},
members,
})
@@ -138,7 +138,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((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.*;<local>'
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;<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'
@@ -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 }
+6 -1
View File
@@ -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 () => {
+12 -9
View File
@@ -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 {
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "تعذّر إعادة تثبيت الخدمة",
"repairFailed": "تعذّر إصلاح الخدمة",
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar",
"coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة."
"coreAlreadyRunning": "تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر."
},
"sysproxy": {
"privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "نصب مجدد سرویس ناموفق بود",
"repairFailed": "تعمیر سرویس ناموفق بود",
"sidecarFailed": "ادامه با Sidecar ناموفق بود",
"coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید."
"coreAlreadyRunning": "هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید."
},
"sysproxy": {
"privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "サービスの再インストールに失敗しました",
"repairFailed": "サービスの修復に失敗しました",
"sidecarFailed": "Sidecar での続行に失敗しました",
"coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。"
"coreAlreadyRunning": "コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。"
},
"sysproxy": {
"privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。",
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "서비스를 다시 설치하지 못했습니다",
"repairFailed": "서비스를 복구하지 못했습니다",
"sidecarFailed": "Sidecar로 계속하지 못했습니다",
"coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요."
"coreAlreadyRunning": "코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요."
},
"sysproxy": {
"privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.",
+34
View File
@@ -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"
}
}
}
+156
View File
@@ -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."
}
}
}
}
+61
View File
@@ -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"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"page": {
"title": "Logs"
},
"actions": {
"showDescending": "Mais recentes primeiro",
"showAscending": "Mais antigos primeiro"
}
}
+221
View File
@@ -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 → <globalMerge>Configuração de extensão global</globalMerge> → <globalScript>Script de extensão global</globalScript> → <profileMerge>Configuração de extensão do perfil</profileMerge> → <profileScript>Script de extensão do perfil</profileScript> → 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"
}
}
}
+122
View File
@@ -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)"
}
}
}
}
+82
View File
@@ -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"
}
}
}
+716
View File
@@ -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..."
}
}
}
+139
View File
@@ -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"
}
}
}
+60
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "Не удалось переустановить службу",
"repairFailed": "Не удалось восстановить службу",
"sidecarFailed": "Не удалось продолжить в режиме Sidecar",
"coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку."
"coreAlreadyRunning": "Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер."
},
"sysproxy": {
"privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "Хезмәтне яңадан урнаштырып булмады",
"repairFailed": "Хезмәтне төзәтеп булмады",
"sidecarFailed": "Sidecar белән дәвам итеп булмады",
"coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз."
"coreAlreadyRunning": "Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз."
},
"sysproxy": {
"privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.",
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "重新安装服务失败",
"repairFailed": "修复服务失败",
"sidecarFailed": "继续使用 Sidecar 失败",
"coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。"
"coreAlreadyRunning": "无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。"
},
"sysproxy": {
"privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。",
+1 -1
View File
@@ -667,7 +667,7 @@
"reinstallFailed": "重新安裝服務失敗",
"repairFailed": "修復服務失敗",
"sidecarFailed": "繼續使用 Sidecar 失敗",
"coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。"
"coreAlreadyRunning": "無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。"
},
"sysproxy": {
"privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。",
+15 -7
View File
@@ -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,
}}
+13
View File
@@ -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
+13 -2
View File
@@ -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,
+137
View File
@@ -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)
})
+58 -38
View File
@@ -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<string, DelayUpdate>()
private cache = new Map<string, DelayUpdate & { url: string }>()
private urlMap = new Map<string, string>()
private listenerMap = new Map<string, (update: DelayUpdate) => 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<ProxyDelay>((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<void>[] = []
+1
View File
@@ -15,6 +15,7 @@ export const supportedLanguages = [
'es',
'jp',
'zhtw',
'pt',
]
export const FALLBACK_LANGUAGE = 'zh'
+124
View File
@@ -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<null>((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()
})
+101
View File
@@ -0,0 +1,101 @@
import {
getProfiles,
patchProfile,
syncTrayProxySelection,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
function readLegacyTestUrls(uid: string): Record<string, string> {
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<string, string>,
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<string>()
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<string, Record<string, { url: string }>>()
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<string, string> {
return {
...profile.latency_test_urls,
...Object.fromEntries(
Object.entries(
(profile.uid ? pending.get(profile.uid) : undefined) ?? {},
).map(([group, edit]) => [group, edit.url]),
),
}
}
+13
View File
@@ -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 => {