Compare commits

...
18 Commits
Author SHA1 Message Date
Tunglies 13676d3335 Release 2.5.4 2026-09-20 19:49:13 +08:00
oomeow 2a7972fa5b fix(core): drain sidecar events before readiness probe to avoid start… (#7966)
* fix(core): drain sidecar events before readiness probe to avoid startup stall

* refactor(core): simplify core readiness generation handling

* docs: update Changelog.md
2026-09-20 09:43:31 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 19334368cd chore(deps): update dependency pnpm to v12.5.1 (#7976)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-20 07:43:37 +00:00
wonfen 73ea0fbd80 fix(dns): keep profile values the override leaves blank or off
Refs #7292 #7933
2026-09-20 15:08:33 +08:00
9f97d938f5 chore(i18n): complete Turkish (tr) translations (#7969)
Translate the 174 frontend strings and 8 tray/notification strings that were
still English copies in the Turkish locale. Values only; no keys changed.

Co-authored-by: qwist1233-cpu <qwist1233-cpu@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 09:21:06 +08:00
wonfen 04a2a09368 fix(linux): restore SELinux service startup with v2.7.2
Upgrade clash_verge_service_ipc and bundled service downloads to v2.7.2,
which labels runtime-installed executables before publication and repairs
existing core labels instead of timing out waiting for service IPC.

Register persistent bin_t rules for production and development service
binaries and cores in the RPM post-install script. Support older semanage
versions, apply rules to existing directories, and warn when optional
SELinux tools are unavailable.

Refs: #7965
2026-09-19 23:58:29 +08:00
❤是纱雾酱哟~ 94491b4ba4 chore(gh-aw): Bump gh-aw to version 0.89.15 (#7973)
Assisted-by: vscode-copilot-chat:deepseek-v4-pro codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-19 12:08:12 +00:00
❤是纱雾酱哟~ 78516dc1dc chore(renovate): Prevent auto-updates of gh-aw (#7972)
- Stop automatically updating dependencies of `gh-aw` related stuff
- Mend Renovate have no ability to distinguish stable and pre-release
- Manual update via `gh aw upgrade` is preffered

Assisted-by: vscode-copilot-chat:deepseek-flash codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-19 12:07:43 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8f6fd3bbf9 chore(deps): update github/gh-aw-actions action to v0.89.17 (#7971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 08:41:11 +00:00
Tunglies c599f5547e fix(proxy): validate user-entered URLs via isValidUrl
Delay test inputs now reject non-http(s) URLs with a warning toast and
fall back to the default URL instead of erroring every node in the
core. Subscription import and profile card host parsing reuse the same
validator, fixing bare-domain URLs shown as local files and
credential-bearing URLs leaking into the card.
2026-09-18 13:18:30 +08:00
wonfen f6fdb34745 perf(home): render proxy dropdown options on demand 2026-09-18 00:41:54 +08:00
wonfen 65ed8d3cf2 fix(home): stabilize proxy group and node dropdowns 2026-09-18 00:26:40 +08:00
Tunglies 2baa629ac4 chore: remove unimportant notice in changelog 2026-09-17 23:32:10 +08:00
Tunglies c7ffb21294 Release v2.5.4-rc 2026-09-17 18:55:59 +08:00
❤是纱雾酱哟~ 0d0ed72467 chore(renovate): Add pnpm version catch up in devcontainer.json (#7951)
- Remove non-exist devcontainer feature of `pnpm`
- Introduce custom managers for catching up `pnpm` version

Assisted-by: vscode-copilot-chat:deepseek-flash
2026-09-17 09:44:09 +00:00
wonfen a36dcd319f CI: update autobuild time 2026-09-17 11:03:03 +08:00
wonfen 9a879dd8d8 fix(service): fall back to elevated sidecar on path permission rejection
Preserve TUN settings and restore proxy operation after fallback.
Deliver fallback notices after UI subscription and stop retrying
deterministic bundle refusals. Publish fallback state atomically
and avoid conflicting service startup notifications.
2026-09-17 00:00:39 +08:00
oomeow 20141e791e fix(profile): virtualize editor lists for rule/proxy/group editors (#7944)
* fix(profile): virtualize editor lists for rule/proxy/group editors

* refactor(list): remove feedbackClone from sortable item and list integration

* refactor(profile): use type instead of export type for category

* refactor(ui): rename measureElementRef to measureElement in sortable components
2026-09-16 12:23:12 +00:00
79 changed files with 1642 additions and 1261 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.3.3"
"pnpmVersion": "12.5.1"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1",
+22 -7
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.1": {
"github/gh-aw-actions/setup@v0.89.15": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
"version": "v0.89.15",
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
}
},
"containers": {
@@ -22,6 +22,11 @@
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -32,6 +37,11 @@
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -42,10 +52,15 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
+2 -2
View File
@@ -3,8 +3,8 @@ name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: '0 4,10 * * *'
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 10,22 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
+1 -1
View File
@@ -33,4 +33,4 @@ jobs:
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
with:
version: v0.89.1
version: v0.89.15
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.4.1
pnpm 12.5.1
rust 1.98.1
Generated
+2 -2
View File
@@ -1223,8 +1223,8 @@ dependencies = [
[[package]]
name = "clash_verge_service_ipc"
version = "2.7.1"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.1#807480e9afc7b9deb8ff3040b54be34326986dfd"
version = "2.7.2"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.2#cad270fe753553cfa2fd26fa5c338eabd210a2d5"
dependencies = [
"anyhow",
"kode-bridge",
+6 -3
View File
@@ -1,11 +1,9 @@
## v2.5.4
> [!IMPORTANT]
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
@@ -27,10 +25,12 @@
**🖥️ Windows**
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
**🍎 macOS**
@@ -41,6 +41,7 @@
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
**🍎/🐧 macOS/Linux**
@@ -78,7 +79,9 @@
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
+8 -8
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
'on': System proxy has been enabled.
'off': System proxy has been disabled.
'on': Sistem vekili etkinleştirildi.
'off': Sistem vekili devre dışı bırakıldı.
systemProxyFailed:
title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
@@ -18,8 +18,8 @@ notifications:
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled:
title: TUN Modu
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
'on': TUN modu etkinleştirildi.
'off': TUN modu devre dışı bırakıldı.
lightweightModeEntered:
title: Hafif Mod
body: Hafif moda geçildi.
@@ -33,10 +33,10 @@ notifications:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
title: Clash Verge Güncellemesi
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Şimdi Kur
later: Daha Sonra
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.
+1 -1
View File
@@ -144,5 +144,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.4.1"
"packageManager": "pnpm@12.5.1"
}
+61 -61
View File
@@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.4.1
version: 12.4.1
specifier: 12.5.1
version: 12.5.1
packages:
'@pnpm/exe.android-arm64@12.4.1':
resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==}
'@pnpm/exe.android-arm64@12.5.1':
resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.4.1':
resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==}
'@pnpm/exe.android-x64@12.5.1':
resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.4.1':
resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==}
'@pnpm/exe.darwin-arm64@12.5.1':
resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.4.1':
resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==}
'@pnpm/exe.darwin-x64@12.5.1':
resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.4.1':
resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==}
'@pnpm/exe.freebsd-x64@12.5.1':
resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.4.1':
resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==}
'@pnpm/exe.linux-arm64-musl@12.5.1':
resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.4.1':
resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==}
'@pnpm/exe.linux-arm64@12.5.1':
resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.4.1':
resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==}
'@pnpm/exe.linux-ppc64@12.5.1':
resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.4.1':
resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==}
'@pnpm/exe.linux-riscv64@12.5.1':
resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.4.1':
resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==}
'@pnpm/exe.linux-s390x@12.5.1':
resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.4.1':
resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==}
'@pnpm/exe.linux-x64-musl@12.5.1':
resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.4.1':
resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==}
'@pnpm/exe.linux-x64@12.5.1':
resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.4.1':
resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==}
'@pnpm/exe.win32-arm64@12.5.1':
resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.4.1':
resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==}
'@pnpm/exe.win32-x64@12.5.1':
resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==}
cpu: [x64]
os: [win32]
pnpm@12.4.1:
resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==}
pnpm@12.5.1:
resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.4.1':
'@pnpm/exe.android-arm64@12.5.1':
optional: true
'@pnpm/exe.android-x64@12.4.1':
'@pnpm/exe.android-x64@12.5.1':
optional: true
'@pnpm/exe.darwin-arm64@12.4.1':
'@pnpm/exe.darwin-arm64@12.5.1':
optional: true
'@pnpm/exe.darwin-x64@12.4.1':
'@pnpm/exe.darwin-x64@12.5.1':
optional: true
'@pnpm/exe.freebsd-x64@12.4.1':
'@pnpm/exe.freebsd-x64@12.5.1':
optional: true
'@pnpm/exe.linux-arm64-musl@12.4.1':
'@pnpm/exe.linux-arm64-musl@12.5.1':
optional: true
'@pnpm/exe.linux-arm64@12.4.1':
'@pnpm/exe.linux-arm64@12.5.1':
optional: true
'@pnpm/exe.linux-ppc64@12.4.1':
'@pnpm/exe.linux-ppc64@12.5.1':
optional: true
'@pnpm/exe.linux-riscv64@12.4.1':
'@pnpm/exe.linux-riscv64@12.5.1':
optional: true
'@pnpm/exe.linux-s390x@12.4.1':
'@pnpm/exe.linux-s390x@12.5.1':
optional: true
'@pnpm/exe.linux-x64-musl@12.4.1':
'@pnpm/exe.linux-x64-musl@12.5.1':
optional: true
'@pnpm/exe.linux-x64@12.4.1':
'@pnpm/exe.linux-x64@12.5.1':
optional: true
'@pnpm/exe.win32-arm64@12.4.1':
'@pnpm/exe.win32-arm64@12.5.1':
optional: true
'@pnpm/exe.win32-x64@12.4.1':
'@pnpm/exe.win32-x64@12.5.1':
optional: true
pnpm@12.4.1:
pnpm@12.5.1:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.4.1
'@pnpm/exe.android-x64': 12.4.1
'@pnpm/exe.darwin-arm64': 12.4.1
'@pnpm/exe.darwin-x64': 12.4.1
'@pnpm/exe.freebsd-x64': 12.4.1
'@pnpm/exe.linux-arm64': 12.4.1
'@pnpm/exe.linux-arm64-musl': 12.4.1
'@pnpm/exe.linux-ppc64': 12.4.1
'@pnpm/exe.linux-riscv64': 12.4.1
'@pnpm/exe.linux-s390x': 12.4.1
'@pnpm/exe.linux-x64': 12.4.1
'@pnpm/exe.linux-x64-musl': 12.4.1
'@pnpm/exe.win32-arm64': 12.4.1
'@pnpm/exe.win32-x64': 12.4.1
'@pnpm/exe.android-arm64': 12.5.1
'@pnpm/exe.android-x64': 12.5.1
'@pnpm/exe.darwin-arm64': 12.5.1
'@pnpm/exe.darwin-x64': 12.5.1
'@pnpm/exe.freebsd-x64': 12.5.1
'@pnpm/exe.linux-arm64': 12.5.1
'@pnpm/exe.linux-arm64-musl': 12.5.1
'@pnpm/exe.linux-ppc64': 12.5.1
'@pnpm/exe.linux-riscv64': 12.5.1
'@pnpm/exe.linux-s390x': 12.5.1
'@pnpm/exe.linux-x64': 12.5.1
'@pnpm/exe.linux-x64-musl': 12.5.1
'@pnpm/exe.win32-arm64': 12.5.1
'@pnpm/exe.win32-x64': 12.5.1
---
lockfileVersion: '9.0'
+17 -1
View File
@@ -56,6 +56,12 @@
"matchFileNames": [".github/workflows/*.lock.yml"],
"enabled": false
},
{
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
"matchFileNames": [".github/workflows/**"],
"matchPackageNames": ["/gh-aw/"],
"enabled": false
},
{
"description": "Skip TypeScript 7.0 series",
"matchManagers": ["npm"],
@@ -78,7 +84,7 @@
},
{
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
"matchPackageNames": ["pnpm"],
"groupName": "pnpm"
},
{
@@ -131,6 +137,16 @@
],
"depNameTemplate": "rust-lang/rust",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"description": "Update pnpm in devcontainer.json",
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
"matchStrings": [
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "pnpm",
"datasourceTemplate": "npm"
}
],
"postUpdateOptions": ["pnpmDedupe"],
+50 -15
View File
@@ -1,5 +1,5 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ',
packageJson.version,
)
packageJson.version = newVersion.startsWith('v')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('version field was not found in package.json')
}
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating package.json version:', error)
throw error
@@ -153,6 +156,34 @@ async function updateCargoVersion(newVersion) {
}
}
async function updateCargoLockVersion(newVersion) {
const _dirname = process.cwd()
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const packageVersionPattern =
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
try {
const data = await fs.readFile(cargoLockPath, 'utf8')
const updatedData = data.replace(
packageVersionPattern,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('clash-verge package entry was not found in Cargo.lock')
}
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating Cargo.lock version:', error)
throw error
}
}
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('version field was not found in tauri.conf.json')
}
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
)
@@ -239,6 +273,7 @@ async function main(versionArg) {
console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion)
await updateCargoLockVersion(newVersion)
await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) {
+1 -1
View File
@@ -99,7 +99,7 @@ tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.1", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.1", version = "2.7.1", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.2", version = "2.7.2", features = [
"client",
] }
arc-swap = "1.9.2"
@@ -0,0 +1,38 @@
#!/bin/bash
set -e
chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
fi
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
exit 0
fi
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
for service in clash-verge-service clash-verge-service-dev; do
for directory in bin cores; do
target="/var/lib/$service/$directory"
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
echo "Refusing to relabel a symlinked service directory: $target" >&2
exit 1
fi
spec="$target(/.*)?"
# Older semanage versions reject -a when an exact rule already exists.
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
printf '%s\n' "$add_error" >&2
exit 1
fi
fi
if [ -d "$target" ]; then
/usr/sbin/restorecon -R "$target"
fi
done
done
+2 -1
View File
@@ -210,7 +210,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
}
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
// A fragment, not a runnable config; the merged result is validated on apply.
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
.await
.stringify_err()
}
+5
View File
@@ -57,6 +57,11 @@ pub async fn continue_with_sidecar() -> CmdResult {
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
}
#[tauri::command]
pub fn take_service_fallback_notice() -> bool {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
+60 -1
View File
@@ -185,6 +185,42 @@ where
apply_proxy().await
}
#[cfg(target_os = "windows")]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
is_admin: bool,
start_service: Start,
start_sidecar: Fallback,
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = Result<()>>,
{
let Err(error) = start_service().await else {
return Ok(());
};
// A transport failure cannot establish whether the service already started the core.
let location_refused = error
.downcast_ref::<crate::core::service::ServiceStartRefusal>()
.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if !is_admin || !location_refused {
return Err(error);
}
logging!(
warn,
Type::Core,
"service refused the core location while app is elevated; falling back to sidecar: {error:#}"
);
let reason = format!("{error:#}");
start_sidecar(error)
.await
.map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
clear_proxy: Clear,
terminate_sidecar: Terminate,
@@ -550,7 +586,30 @@ impl CoreManager {
}
let result = match startup {
StartupDecision::Service => self.start_core_by_service().await,
StartupDecision::Service => {
#[cfg(target_os = "windows")]
{
run_service_start_with_sidecar_fallback(
crate::core::runstate::RUN_STATE.state().is_admin,
|| self.start_core_by_service(),
|error| async move {
use crate::core::runstate::RUN_STATE;
RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?;
let result = self.start_core_by_sidecar().await;
if result.is_err() {
SERVICE_MANAGER.withdraw_sidecar_allowance();
} else {
crate::core::service::notify_service_fallback();
}
result
},
)
.await
}
#[cfg(not(target_os = "windows"))]
self.start_core_by_service().await
}
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
StartupDecision::Wait => Ok(()),
};
+76 -50
View File
@@ -18,6 +18,44 @@ use tauri_plugin_shell::ShellExt as _;
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
#[cfg(target_os = "windows")]
async fn retry_service_start<Start, StartFuture>(
attempts: usize,
retry_delay: std::time::Duration,
mut start: Start,
) -> Result<()>
where
Start: FnMut() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
{
let mut last_error = None;
for attempt in 0..attempts {
match start().await {
Ok(()) => return Ok(()),
Err(error) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {error:#}",
attempt + 1,
attempts
);
if error
.downcast_ref::<service::ServiceStartRefusal>()
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
{
return Err(error);
}
last_error = Some(error);
if attempt + 1 < attempts {
tokio::time::sleep(retry_delay).await;
}
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
}
impl CoreManager {
/// Restores profile selections before callers enable the system proxy.
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
@@ -170,33 +208,10 @@ impl CoreManager {
let pid = child.pid();
tracing::Span::current().record("pid", pid);
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
// The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
// single event, so an unread channel stalls its reader threads and the core blocks on a
// full stdout pipe before it ever creates the API socket.
let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
@@ -226,6 +241,32 @@ impl CoreManager {
}
});
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
Ok(())
}
@@ -269,30 +310,15 @@ impl CoreManager {
#[cfg(target_os = "windows")]
{
use crate::constants::timing;
let mut last_err = None;
for attempt in 0..timing::SERVICE_START_RETRIES {
match service::run_core_by_service(config_file).await {
Ok(()) => {
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
return Ok(());
}
Err(e) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {e:#}",
attempt + 1,
timing::SERVICE_START_RETRIES
);
last_err = Some(e);
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
service::run_core_by_service(config_file)
})
.await?;
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
Ok(())
}
#[cfg(not(target_os = "windows"))]
+15
View File
@@ -323,6 +323,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(())
}
#[cfg(target_os = "windows")]
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
bail!("cannot fall back while a service operation is pending");
}
// Publish the refusal and allowance together so it never asks the UI for a repair.
state.service.health = ServiceHealth::Unavailable(reason);
state.service.allow_sidecar();
state.bump();
drop(state);
self.announce();
Ok(())
}
/// Requests installation only for a confirmed absent Service.
pub fn require_install_for_session(&self) -> Result<()> {
let mut state = self.service.lock();
+37 -4
View File
@@ -39,6 +39,17 @@ use std::{
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::sidecar_fallback", "");
}
pub(crate) fn take_service_fallback_notice() -> bool {
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
}
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
@@ -1076,6 +1087,25 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
.context("Clash Verge Service 未返回运行时暂存结果")
}
#[derive(Debug)]
pub(super) struct ServiceStartRefusal {
pub code: u16,
pub core_path: String,
pub message: String,
}
impl std::fmt::Display for ServiceStartRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"failed to start Service core at {} (code {}): {}",
self.core_path, self.code, self.message
)
}
}
impl std::error::Error for ServiceStartRefusal {}
/// 尝试使用服务启动core
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
@@ -1111,10 +1141,12 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
err_msg
);
start_owner_monitor();
bail!(
"failed to start Service core at {}: {err_msg}",
request.runtime.core_path
);
return Err(ServiceStartRefusal {
code: response.code,
core_path: request.runtime.core_path,
message: err_msg,
}
.into());
}
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
@@ -1133,6 +1165,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
start_owner_monitor();
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
logging!(
info,
Type::Service,
+72 -50
View File
@@ -284,8 +284,7 @@ fn process_seq_items(
config
}
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
/// App 权威的顶层控制面键;`dns.ipv6` 单独处理。
const CONTROL_PLANE_KEYS: &[&str] = &[
"external-controller",
#[cfg(unix)]
@@ -308,25 +307,20 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
"unified-delay",
];
/// The fields the app owns, held across the stages that apply the user's manual overrides.
///
/// Capturing and restoring are one value rather than two calls because the order is a
/// correctness requirement, not a style: capture must happen after the app has finished
/// deriving these fields and before any override runs, and restore must happen after every
/// override. As four separate calls that contract lived only in comments.
/// App-owned fields: captured after the app derives them, enforced after every override.
struct AuthoritativeFields {
control_plane: Mapping,
tun: Mapping,
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
/// Only when the DNS page switched it on.
dns_ipv6: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys),
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
}
}
@@ -401,7 +395,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
config
}
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
/// 只在 DNS 页开启 IPv6 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned()
}
@@ -481,11 +475,7 @@ async fn process_profile_items(
(config, exists_keys, result_map)
}
/// Merge the Application Merge Config over the profile's own configuration.
///
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
/// out of the global config from inside its loop, which made a stage that looks pure depend
/// on process state and left it untestable.
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
@@ -683,9 +673,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
config
}
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value;
@@ -708,38 +696,77 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
}
}
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
fn is_set(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(on) => *on,
Value::String(text) => !text.trim().is_empty(),
Value::Sequence(items) => !items.is_empty(),
Value::Mapping(map) => !map.is_empty(),
Value::Number(_) | Value::Tagged(_) => true,
}
}
/// Overlay set fields onto base; maps merge per key, lists are leaves.
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
for (key, value) in overlay {
let value = match (base.get_mut(&key), value) {
(Some(Value::Mapping(existing)), Value::Mapping(patch)) => {
*existing = merge_set_fields(std::mem::take(existing), patch);
continue;
}
(_, Value::Mapping(patch)) => Value::Mapping(merge_set_fields(Mapping::new(), patch)),
(_, value) => value,
};
if is_set(&value) {
base.insert(key, value);
}
}
base
}
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
config
.get_mut(key)
.and_then(Value::as_mapping_mut)
.map(std::mem::take)
.unwrap_or_default()
}
/// Returns the merged config and whether the page set `dns.ipv6`.
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
let mut owns_ipv6 = false;
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if dns_path.exists()
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
&& let Ok(mut dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
{
if let Some(hosts_value) = dns_config.get("hosts")
&& hosts_value.is_mapping()
{
config.insert("hosts".into(), hosts_value.clone());
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts") {
let hosts = merge_set_fields(take_mapping(&mut config, "hosts"), hosts);
config.insert("hosts".into(), hosts.into());
logging!(debug, Type::Core, "apply hosts configuration");
}
if let Some(dns_value) = dns_config.get("dns") {
if let Some(dns_mapping) = dns_value.as_mapping() {
let mut dns_mapping = dns_mapping.clone();
ensure_fake_ip_range6(&mut dns_mapping);
config.insert("dns".into(), dns_mapping.into());
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)");
}
} else {
let mut dns_config = dns_config;
ensure_fake_ip_range6(&mut dns_config);
config.insert("dns".into(), dns_config.into());
// Legacy layout: no `dns` root.
let dns_override = match dns_config.remove("dns") {
Some(Value::Mapping(dns)) => Some(dns),
Some(_) => None,
None => Some(dns_config),
};
if let Some(dns_override) = dns_override {
owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
}
}
config
(config, owns_ipv6)
}
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
@@ -800,9 +827,9 @@ pub async fn enhance(
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let config = apply_dns_settings(config, enable_dns_settings).await;
let (config, owns_dns_ipv6) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, enable_dns_settings);
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, owns_dns_ipv6);
let (config, exists_keys, result_map) = process_global_items(
config,
@@ -864,8 +891,7 @@ mod fake_ip_tests {
#[test]
fn a_hand_edited_empty_range_counts_as_missing() {
// The reason this is not just a `contains_key` check: YAML edited by hand often
// leaves the key present with nothing after the colon.
// Hand-edited YAML may leave the key present but empty.
for blank in ["", " "] {
let mut config = dns(&[
("ipv6", Value::from(true)),
@@ -1004,8 +1030,7 @@ mod use_tun_tests {
#[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() {
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
// resolver is restored, which is not part of the configuration.
// TUN off must not rewrite the DNS the profile chose.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false);
@@ -1038,8 +1063,7 @@ mod use_sort_tests {
#[test]
fn the_bulky_list_fields_are_written_last() {
// These are the fields that make a config file unreadable from the top, so they are
// pushed below the settings a human actually scans for.
// Bulky list fields go last so the top of the file stays readable.
let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule"));
@@ -1129,8 +1153,7 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not just skipped: the profile may have asked for the port itself, and a
// listener the user switched off must not be opened by the profile's own value.
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
@@ -1152,8 +1175,7 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
// listen anyway, which is the opposite of what switching it off means.
// Blanked, not removed: an absent key makes mihomo listen on its default.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false);
+1
View File
@@ -163,6 +163,7 @@ mod app_init {
cmd::apply_dns_config,
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
+4 -17
View File
@@ -228,27 +228,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
Ok(())
}
/// Raise existing sub-floor update intervals, once per install.
///
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
/// them, so the marker is written even when nothing needed raising.
/// Once per install; the marker is written even when no interval needed raising.
pub async fn migrate_short_update_intervals() -> Result<()> {
let marker = dirs::update_interval_migrated_path()?;
if fs::try_exists(&marker).await? {
return Ok(());
}
// Same order as every other writer holding it (lock, then draft permit): save_file runs
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
let min = constants::profile::MIN_UPDATE_INTERVAL;
let raised = Config::profiles()
.await
.with_data_modify(|mut profiles: IProfiles| async move {
// `IProfiles::new` normalises items to Some when it read the file, and returns a
// bare default() when it failed. A failed load also raises zero — without this the
// marker would burn the one shot on profiles nobody managed to load.
// A failed profiles load (bare default) must not burn the one-shot marker.
if profiles.items.is_none() {
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
}
@@ -370,14 +364,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
"nameserver-policy".into(),
Value::Mapping(serde_yaml_ng::Mapping::new()),
),
(
"proxy-server-nameserver".into(),
Value::Sequence(vec![
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("tls://223.5.5.5".into()),
]),
),
("proxy-server-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
(
+2 -1
View File
@@ -15,11 +15,12 @@
},
"rpm": {
"depends": ["openssl", "libayatana-appindicator-gtk3"],
"recommends": ["policycoreutils", "policycoreutils-python-utils"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"postInstallScript": "./packages/linux/post-install-rpm.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
}
},
+16 -3
View File
@@ -13,12 +13,24 @@ interface SortableItemProps {
index: number
group?: string
disabled?: boolean
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
measureElement?: (element: HTMLDivElement | null) => void
dataIndex?: number
style?: CSSProperties
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
}
export const SortableItem = (props: SortableItemProps) => {
const { id, index, group, disabled, children, style } = props
const {
id,
index,
group,
disabled,
measureElement,
dataIndex,
children,
style,
} = props
const { ref, handleRef, sortable, isDragging } = useSortable({
id,
index,
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
(element: HTMLDivElement | null) => {
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
ref(element)
measureElement?.(element)
},
[handleRef, ref],
[handleRef, ref, measureElement],
)
const mergedStyle: CSSProperties = {
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
}
return (
<div ref={setRef} style={mergedStyle}>
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
{children}
</div>
)
+320 -214
View File
@@ -1,6 +1,7 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import {
AccessTimeRounded,
ArrowDropDown,
ChevronRight,
NetworkCheckRounded,
WifiOff as SignalError,
@@ -15,25 +16,22 @@ import {
import {
Box,
Button,
ButtonBase,
Chip,
ClickAwayListener,
FormControl,
IconButton,
InputLabel,
type MenuProps,
MenuItem,
MenuList,
Paper,
Popper,
Select,
type SelectChangeEvent,
type PopperProps,
Tooltip,
Typography,
alpha,
useTheme,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
@@ -68,6 +66,40 @@ const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
const AUTO_CHECK_INITIAL_DELAY_MS = 100
const PROXY_MENU_MAX_HEIGHT = 500
const PROXY_MENU_VIEWPORT_MARGIN = 8
const proxyMenuModifiers: PopperProps['modifiers'] = [
{
name: 'menuSize',
enabled: true,
phase: 'beforeRead',
fn: ({ state }) => {
const popper = state.elements.popper
const anchor = state.elements.reference.getBoundingClientRect()
const viewportHeight = popper.ownerDocument.documentElement.clientHeight
// Use the larger side before flip runs so sizing cannot alternate with placement.
const availableHeight =
Math.max(anchor.top, viewportHeight - anchor.bottom) -
PROXY_MENU_VIEWPORT_MARGIN
popper.style.width = `${anchor.width}px`
popper.style.setProperty(
'--proxy-menu-max-height',
`${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`,
)
state.rects.popper.width = popper.offsetWidth
state.rects.popper.height = popper.offsetHeight
},
},
{
name: 'showMenu',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
fn: ({ state }) => {
state.styles.popper.visibility = 'visible'
},
},
]
interface ProxyOption {
memberIndex: number
@@ -163,34 +195,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
const hasPointerCoordinates = (
value: unknown,
): value is { clientX: number; clientY: number } =>
typeof value === 'object' &&
value !== null &&
'clientX' in value &&
typeof value.clientX === 'number' &&
'clientY' in value &&
typeof value.clientY === 'number'
const eventHitsElement = (event: unknown, element: HTMLElement | null) => {
if (!element) return false
const nativeEvent =
typeof event === 'object' && event !== null && 'nativeEvent' in event
? event.nativeEvent
: event
if (!hasPointerCoordinates(nativeEvent)) return false
const rect = element.getBoundingClientRect()
return (
nativeEvent.clientX >= rect.left &&
nativeEvent.clientX <= rect.right &&
nativeEvent.clientY >= rect.top &&
nativeEvent.clientY <= rect.bottom
)
}
const sortProxyOptions = (
options: ProxyOption[],
sortType: ProxySortType,
@@ -216,58 +220,71 @@ const sortProxyOptions = (
.map(({ option }) => option)
}
interface PersistentProxySelectProps {
interface PersistentSelectProps {
label: string
groupName: string
fixed?: string
value: string
selectedName: string
selectedDelay: number
options: ProxyOption[]
displayValue: React.ReactNode
open: boolean
disabled: boolean
keepOpenRef: React.RefObject<HTMLElement | null>
onOpen: () => void
onClose: () => void
onChange: (value: string) => void
onOpenChange: (open: boolean) => void
renderOptions: () => React.ReactNode
}
const PersistentProxySelect = ({
const PersistentSelect = ({
label,
groupName,
fixed,
value,
selectedName,
selectedDelay,
options,
displayValue,
open,
disabled,
keepOpenRef,
onOpen,
onClose,
onChange,
}: PersistentProxySelectProps) => {
const anchorRef = useRef<HTMLDivElement>(null)
const listboxId = 'current-proxy-node-listbox'
const labelId = 'proxy-select-label'
const fixedProxyInUsed = selectedName === fixed
onOpenChange,
renderOptions,
}: PersistentSelectProps) => {
const id = useId()
const labelId = `${id}-label`
const valueId = `${id}-value`
const listboxId = `${id}-listbox`
const anchorRef = useRef<HTMLButtonElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const popperOptions = useMemo<PopperProps['popperOptions']>(
() => ({
onFirstUpdate: () => {
const list = listRef.current
const paper = list?.parentElement
const item =
list?.querySelector<HTMLElement>(
'[aria-selected="true"]:not([aria-disabled="true"])',
) ??
list?.querySelector<HTMLElement>(
'[role="option"]:not([aria-disabled="true"])',
)
if (!item || !paper) return
// Focusing before positioning can scroll the page instead of just the menu.
item.focus({ preventScroll: true })
paper.scrollTop +=
item.getBoundingClientRect().top -
paper.getBoundingClientRect().top -
(paper.clientHeight - item.offsetHeight) / 2
},
}),
[],
)
useEffect(() => {
if (!open) return
const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
onClose()
onOpenChange(false)
} else if (event.key === 'Escape') {
onClose()
anchorRef.current
?.querySelector<HTMLElement>('[role="combobox"]')
?.focus()
onOpenChange(false)
anchorRef.current?.focus({ preventScroll: true })
}
}
document.addEventListener('keydown', closeFromKeyboard)
return () => document.removeEventListener('keydown', closeFromKeyboard)
}, [onClose, open])
}, [onOpenChange, open])
return (
<ClickAwayListener
@@ -277,134 +294,119 @@ const PersistentProxySelect = ({
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
return
}
onClose()
onOpenChange(false)
}}
>
<Box>
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
value={value}
open={false}
onOpen={onOpen}
label={label}
<Box
sx={{
position: 'relative',
borderRadius: 1,
'& fieldset': {
borderColor: (theme) =>
alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
},
...(!disabled && {
'&:hover fieldset': { borderColor: 'text.primary' },
'&:focus-within fieldset': {
borderColor: 'primary.main',
borderWidth: 2,
},
'&:focus-within legend': { color: 'primary.main' },
}),
}}
>
<ButtonBase
ref={anchorRef}
type="button"
role="combobox"
aria-labelledby={`${labelId} ${valueId}`}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-expanded={open}
disabled={disabled}
renderValue={() => (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography noWrap>{selectedName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(selectedDelay)}
color={convertDelayColor(selectedDelay)}
/>
{fixedProxyInUsed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
)}
SelectDisplayProps={{
'aria-controls': open ? listboxId : undefined,
'aria-expanded': open ? 'true' : 'false',
onClick={() => onOpenChange(!open)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
onOpenChange(true)
}
}}
sx={{
width: '100%',
height: 40,
px: 1.75,
borderRadius: 'inherit',
justifyContent: 'space-between',
textAlign: 'left',
color: disabled ? 'text.disabled' : 'text.primary',
}}
>
<MenuItem value={value}>{selectedName}</MenuItem>
</Select>
</FormControl>
<Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
{displayValue}
</Box>
<ArrowDropDown
sx={{
ml: 1,
color: disabled ? 'action.disabled' : 'action.active',
transform: open ? 'rotate(180deg)' : undefined,
}}
/>
</ButtonBase>
<Box
component="fieldset"
sx={{
position: 'absolute',
inset: '-5px 0 0',
m: 0,
px: 1,
minWidth: 0,
border: '1px solid',
borderRadius: 'inherit',
pointerEvents: 'none',
}}
>
<Box
component="legend"
id={labelId}
sx={{
px: 0.5,
typography: 'caption',
lineHeight: '11px',
color: disabled ? 'text.disabled' : 'text.secondary',
}}
>
{label}
</Box>
</Box>
</Box>
<Popper
open={open}
anchorEl={anchorRef.current}
placement="bottom-start"
modifiers={proxyMenuModifiers}
popperOptions={popperOptions}
sx={{
width: anchorRef.current?.clientWidth,
visibility: 'hidden',
zIndex: (theme) => theme.zIndex.modal,
}}
>
<Paper
elevation={8}
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
sx={{
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
overflow: 'auto',
}}
>
<MenuList
ref={listRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
autoFocusItem
variant="selectedMenu"
>
{options.map((option) => {
const selected = option.value === value
const isFixed = option.name === fixed
const delay = option.disabled
? -1
: delayManager.getDelayFix(option.member, groupName)
return (
<MenuItem
key={option.value}
role="option"
aria-selected={selected}
selected={selected}
disabled={option.disabled}
onClick={() => {
if (!option.disabled) onChange(option.value)
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
pr: 1,
}}
>
{isFixed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '5px',
...(!fixedProxyInUsed && {
filter: 'grayscale(1)',
}),
}}
>
📌
</span>
)}
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{option.name}
</Typography>
{!option.disabled && (
<Chip
size="small"
label={delayManager.formatDelay(delay)}
color={convertDelayColor(delay)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})}
{open && renderOptions()}
</MenuList>
</Paper>
</Popper>
@@ -413,6 +415,124 @@ const PersistentProxySelect = ({
)
}
interface PersistentProxySelectProps
extends Omit<PersistentSelectProps, 'displayValue' | 'renderOptions'> {
value: string
selectedName: string
groupName: string
fixed?: string
selectedDelay: number
options: ProxyOption[]
onChange: (value: string) => void
}
const PersistentProxySelect = ({
groupName,
fixed,
selectedName,
selectedDelay,
options,
value,
onChange,
...props
}: PersistentProxySelectProps) => {
const fixedProxyInUsed = selectedName === fixed
return (
<PersistentSelect
{...props}
displayValue={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography noWrap>{selectedName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(selectedDelay)}
color={convertDelayColor(selectedDelay)}
/>
{fixedProxyInUsed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
}
renderOptions={() =>
options.map((option) => {
const selected = option.value === value
const isFixed = option.name === fixed
const delay = option.disabled
? -1
: delayManager.getDelayFix(option.member, groupName)
return (
<MenuItem
key={option.value}
role="option"
aria-selected={selected}
selected={selected}
disabled={option.disabled}
onClick={() => {
if (!option.disabled) onChange(option.value)
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
pr: 1,
}}
>
{isFixed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '5px',
...(!fixedProxyInUsed && {
filter: 'grayscale(1)',
}),
}}
>
📌
</span>
)}
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{option.name}
</Typography>
{!option.disabled && (
<Chip
size="small"
label={delayManager.formatDelay(delay)}
color={convertDelayColor(delay)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})
}
/>
)
}
export const CurrentProxyCard = () => {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -497,6 +617,11 @@ export const CurrentProxyCard = () => {
const [selectedGroupName, setSelectedGroupName] = useState('')
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
useEffect(() => {
setOpenSelect(null)
}, [mode])
const delayButtonRef = useRef<HTMLButtonElement>(null)
const delays = useGroupDelays(selectedGroupName || null)
@@ -621,9 +746,8 @@ export const CurrentProxyCard = () => {
latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => {
(newGroupName: string) => {
if (isGlobalMode || isDirectMode) return
const newGroupName = event.target.value
setSelectedGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
},
@@ -785,24 +909,9 @@ export const CurrentProxyCard = () => {
refreshProxy()
})
const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
(event, reason) => {
if (
reason === 'backdropClick' &&
eventHitsElement(event, delayButtonRef.current)
) {
void handleCheckDelay()
return
}
setOpenSelect(null)
},
[handleCheckDelay],
)
const proxyOptions = useMemo(
() =>
isDirectMode
isDirectMode || openSelect !== 'proxy'
? []
: sortProxyOptions(
unsortedProxyOptions,
@@ -813,6 +922,7 @@ export const CurrentProxyCard = () => {
[
delays,
isDirectMode,
openSelect,
unsortedProxyOptions,
sortType,
defaultLatencyTimeout,
@@ -975,32 +1085,29 @@ export const CurrentProxyCard = () => {
/>
)}
</Box>
<FormControl
fullWidth
variant="outlined"
size="small"
sx={{ mb: 1.5 }}
>
<InputLabel id="proxy-group-select-label">
{t('home.components.currentProxy.labels.group')}
</InputLabel>
<Select
labelId="proxy-group-select-label"
value={selectedGroupName}
onChange={handleGroupChange}
open={openSelect === 'group'}
onOpen={() => setOpenSelect('group')}
<Box sx={{ mb: 1.5 }}>
<PersistentSelect
label={t('home.components.currentProxy.labels.group')}
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
open={openSelect === 'group'}
disabled={isGlobalMode || isDirectMode}
MenuProps={{ onClose: handleGroupMenuClose }}
>
{selectableGroups.map((group) => (
<MenuItem key={group.name} value={group.name}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
keepOpenRef={delayButtonRef}
onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
renderOptions={() =>
selectableGroups.map((group) => (
<MenuItem
key={group.name}
role="option"
aria-selected={group.name === selectedGroupName}
selected={group.name === selectedGroupName}
onClick={() => handleGroupChange(group.name)}
>
<Typography noWrap>{group.name}</Typography>
</MenuItem>
))
}
/>
</Box>
<PersistentProxySelect
label={t('home.components.currentProxy.labels.proxy')}
@@ -1013,12 +1120,11 @@ export const CurrentProxyCard = () => {
}
selectedName={selectedProxyName}
selectedDelay={currentDelay}
options={isDirectMode ? [] : proxyOptions}
options={proxyOptions}
open={openSelect === 'proxy'}
disabled={isDirectMode}
keepOpenRef={delayButtonRef}
onOpen={() => setOpenSelect('proxy')}
onClose={() => setOpenSelect(null)}
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
onChange={handleProxyChange}
/>
</Box>
+2 -1
View File
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context'
import { updateProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
@@ -41,7 +42,7 @@ const round = keyframes`
// 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => {
if (!url) return '-'
if (url.startsWith('http')) return new URL(url).host
if (isValidUrl(url)) return new URL(url).host
return 'local'
}
@@ -20,6 +20,7 @@ import {
type ServiceRequest,
type ServiceRequestReason,
} from '@/services/service-request'
import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'restartOnly'
@@ -98,19 +99,25 @@ export const SysproxyPrivilegeDialog = () => {
setStep('restarting')
await restartCore()
// Verify that restart actually moved the core into the service.
const runState = await getRuntimeState()
if (runState.mode === 'Service') {
// Retry the original request now that the service can apply it.
const usingAdminFallback =
getSystem() === 'windows' &&
runState.mode === 'Sidecar' &&
runState.isAdmin &&
runState.sidecarAllowed &&
runState.service === 'unavailable'
if (runState.mode === 'Service' || usingAdminFallback) {
if (restoring !== undefined) {
setStep('applying')
await patchVergeConfig(restoring)
}
showNotice.success(
restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
if (!usingAdminFallback) {
showNotice.success(
restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
}
close()
} else {
showNotice.error(
@@ -0,0 +1,184 @@
import { move } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragEndEvent,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type CSSProperties, type ReactNode, useRef, useState } from 'react'
import { SortableItem } from '../base'
type GroupedVirtualCategory = 'prepend' | 'original' | 'append'
export interface GroupedVirtualItem<T> {
id: string
category: GroupedVirtualCategory
item: T
}
// eslint-disable-next-line react-refresh/only-export-components
export function buildGroupedItems<T>(
prepend: T[],
original: T[],
append: T[],
getKey: (item: T) => string,
): GroupedVirtualItem<T>[] {
const build = (
category: GroupedVirtualCategory,
list: T[],
): GroupedVirtualItem<T>[] =>
list.map((item) => ({
id: `${category}:${getKey(item)}`,
category,
item,
}))
return [
...build('prepend', prepend),
...build('original', original),
...build('append', append),
]
}
interface GroupedVirtualListProps<T> {
items: GroupedVirtualItem<T>[]
estimateSize?: number
renderItem: (entry: GroupedVirtualItem<T>) => ReactNode
onReorder: (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => void
style?: CSSProperties
}
/**
* 用于编辑规则/节点/代理组功能的 prepend/original/append 分组虚拟化列表
*/
export function GroupedVirtualList<T>(props: GroupedVirtualListProps<T>) {
const {
items: itemsProp,
estimateSize = 56,
renderItem,
onReorder,
style,
} = props
const [items, setItems] = useState(itemsProp)
const [prevItemsProp, setPrevItemsProp] = useState(itemsProp)
const snapshotRef = useRef(itemsProp)
const scrollRef = useRef<HTMLDivElement>(null)
if (itemsProp !== prevItemsProp) {
setPrevItemsProp(itemsProp)
setItems(itemsProp)
}
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize,
overscan: 15,
gap: 8,
getItemKey: (index) => items[index]?.id ?? index,
})
const onDragStart = () => {
snapshotRef.current = items
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (!source || !target) return
const sourceEntry = items.find((entry) => entry.id === source.id)
const targetEntry = items.find((entry) => entry.id === target.id)
if (
!sourceEntry ||
!targetEntry ||
sourceEntry.category !== targetEntry.category
) {
event.preventDefault()
return
}
setItems((current) => move(current, event))
}
const onDragEnd = (event: DragEndEvent) => {
const source = event.operation.source
if (event.canceled) {
setItems(snapshotRef.current)
return
}
if (!isSortable(source)) return
const snapshot = snapshotRef.current
const sourceEntry = snapshot.find((entry) => entry.id === source.id)
if (!sourceEntry) return
const { category } = sourceEntry
if (category !== 'prepend' && category !== 'append') return
const activeIndex = snapshot
.filter((entry) => entry.category === category)
.findIndex((entry) => entry.id === source.id)
const overIndex = items
.filter((entry) => entry.category === category)
.findIndex((entry) => entry.id === source.id)
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) return
onReorder(category, activeIndex, overIndex)
}
return (
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<div ref={scrollRef} style={{ ...style, overflow: 'auto' }}>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
marginRight: '4px',
}}
>
{virtualizer.getVirtualItems().map((vi) => {
const entry = items[vi.index]
if (!entry) return null
return (
<SortableItem
key={vi.key}
id={entry.id}
index={vi.index}
disabled={entry.category === 'original'}
measureElement={virtualizer.measureElement}
dataIndex={vi.index}
style={{
position: 'absolute',
top: `${vi.start}px`,
left: 0,
width: '100%',
}}
>
{renderItem(entry)}
</SortableItem>
)
})}
</div>
</div>
</DragDropProvider>
)
}
+71 -170
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -43,13 +35,7 @@ import {
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
proxiesUid: string
mergeUid: string
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredGroupList,
filteredAppendSeq,
(group) => group.name,
),
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
<GroupItem
type={isDeleted ? 'delete' : 'original'}
group={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
</SortableItem>
)
})}
</>
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
// prevent drag over if source and target are in different groups
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
+2 -3
View File
@@ -43,6 +43,7 @@ import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
@@ -965,9 +966,7 @@ export const ProfileItem = memo(ProfileItemBase)
function parseUrl(url?: string) {
if (!url) return ''
const regex = /https?:\/\/(.+?)\//
const result = url.match(regex)
return result ? result[1] : 'local file'
return isValidUrl(url) ? new URL(url).host : 'local file'
}
function parseExpire(expire?: number) {
+71 -166
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -35,12 +27,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -50,6 +37,12 @@ import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import parseUri from '@/utils/uri-parser'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
profileUid: string
property: string
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredProxyList,
filteredAppendSeq,
(proxy) => proxy.name,
),
[filteredPrependSeq, filteredProxyList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
<ProxyItem
type={isDeleted ? 'delete' : 'original'}
proxy={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
</SortableItem>
)
})}
</>
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = []
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
+83 -177
View File
@@ -1,11 +1,4 @@
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import { arrayMove } from '@dnd-kit/helpers'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -36,13 +29,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
import { isValidIpCidr } from '@/utils/network'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
groupsUid: string
mergeUid: string
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredRuleList,
filteredAppendSeq,
(rule) => rule,
),
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<string>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`prepend:${item}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
<RuleItem
type={isDeleted ? 'delete' : 'original'}
ruleRaw={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item))
} else {
setDeleteSeq((prev) => [...prev, item])
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`append:${item}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<RuleItem
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
onPrepend={() => {
setPrependSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
</SortableItem>
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
})}
</>
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
}
return (
<RuleItem
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
onPrepend={() => {
setPrependSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
}
const reorder = (list: string[], startIndex: number, endIndex: number) => {
const result = Array.from(list)
const [removed] = result.splice(startIndex, 1)
result.splice(endIndex, 0, removed)
return result
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
return
}
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
@@ -854,22 +770,12 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
@@ -17,6 +17,8 @@ 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 { BaseSearchBox, type SearchState } from '../base'
@@ -174,6 +176,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
+6
View File
@@ -18,7 +18,9 @@ import { useTranslation } from 'react-i18next'
import { BaseSearchBox } from '@/components/base'
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 type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -95,6 +97,10 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
+2 -7
View File
@@ -171,11 +171,7 @@ const DEFAULT_DNS_CONFIG = {
],
fallback: [],
'nameserver-policy': {},
'proxy-server-nameserver': [
'https://doh.pub/dns-query',
'https://dns.alidns.com/dns-query',
'tls://223.5.5.5',
],
'proxy-server-nameserver': [],
'direct-nameserver': [],
'direct-nameserver-follow-policy': false,
'fallback-filter': {
@@ -340,8 +336,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
listen: values.listen,
'enhanced-mode': values.enhancedMode,
'fake-ip-range': values.fakeIpRange,
'fake-ip-range6':
values.fakeIpRange6 || DEFAULT_DNS_CONFIG['fake-ip-range6'],
'fake-ip-range6': values.fakeIpRange6,
'fake-ip-filter-mode': values.fakeIpFilterMode,
'prefer-h3': values.preferH3,
'respect-rules': values.respectRules,
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "تم تثبيت الخدمة بنجاح",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Service erfolgreich installiert",
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
"uninstallSuccess": "Service erfolgreich deinstalliert"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Service Installed Successfully",
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
"uninstallSuccess": "Service Uninstalled Successfully"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Servicio instalado con éxito",
"sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.",
"uninstallSuccess": "Servicio desinstalado con éxito"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "سرویس با موفقیت نصب شد",
"sidecarFallback": "سرویس به‌دلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Layanan Berhasil Diinstal",
"sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.",
"uninstallSuccess": "Layanan Berhasil Dicopot"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "サービスのインストールに成功しました。",
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
"uninstallSuccess": "サービスのアンインストールに成功しました。"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} 업데이트 성공",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "서비스가 설치되었습니다",
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
"uninstallSuccess": "서비스가 제거되었습니다"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} успешно обновлён",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Служба успешно установлена",
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
"uninstallSuccess": "Служба успешно удалена"
},
"updater": {
+4 -4
View File
@@ -17,18 +17,18 @@
"type": "Tip"
},
"order": {
"default": "Default",
"default": "Varsayılan",
"uploadSpeed": "Yükleme Hızı",
"downloadSpeed": "İndirme Hızı"
},
"actions": {
"active": "Active",
"closed": "Closed",
"active": "Etkin",
"closed": "Kapatılan",
"closeConnection": "Bağlantıyı Kapat"
},
"columnManager": {
"title": "Sütunlar",
"dragHandle": "Drag handle"
"dragHandle": "Sürükleme tutamacı"
}
}
}
+5 -5
View File
@@ -45,7 +45,7 @@
"fields": {
"coreVersion": "Çekirdek Sürümü",
"systemProxyAddress": "Sistem Vekil Adresi",
"mixedPort": "Mixed Port",
"mixedPort": "Karma Port",
"uptime": "Çalışma Süresi",
"rulesCount": "Kural Sayısı"
}
@@ -127,7 +127,7 @@
"download": "İndirme"
},
"patterns": {
"minutes": "{{time}} Minutes"
"minutes": "{{time}} Dakika"
},
"unknownTime": "Bilinmeyen zaman",
"chartStyles": {
@@ -146,9 +146,9 @@
"direct": "Doğrudan Mod"
},
"descriptions": {
"rule": "Automatically choose proxies according to the rule set.",
"global": "Forward all network requests through the selected proxy.",
"direct": "Bypass the proxy and connect to the internet directly."
"rule": "Vekilleri kural kümesine göre otomatik olarak seçer.",
"global": "Tüm ağ isteklerini seçili vekil üzerinden iletir.",
"direct": "Vekili atlar ve internete doğrudan bağlanır."
}
}
}
+14 -14
View File
@@ -12,23 +12,23 @@
"settings": "Ayarlar"
},
"menu": {
"reorderMode": "Menu reorder mode",
"restoreDefaultOrder": "Restore default order",
"unlock": "Unlock menu order",
"lock": "Lock menu order",
"collapseNavBar": "Collapse navigation bar",
"expandNavBar": "Expand navigation bar"
"reorderMode": "Menü sıralama modu",
"restoreDefaultOrder": "Varsayılan sırayı geri yükle",
"unlock": "Menü sırasının kilidini aç",
"lock": "Menü sırasını kilitle",
"collapseNavBar": "Gezinme çubuğunu daralt",
"expandNavBar": "Gezinme çubuğunu genişlet"
}
},
"serviceMigration": {
"title": "Service update required",
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
"reinstall": "Reinstall service",
"repair": "Repair service",
"continueSidecar": "Continue with Sidecar",
"success": "The Clash Verge service was updated successfully",
"title": "Hizmet güncellemesi gerekli",
"message": "Eski bir Clash Verge hizmet kurulumu algılandı. Güncel güvenli IPC protokolünü kullanmak için hizmeti yeniden kurun ya da bu oturumda Sidecar ile devam edin.",
"checkingMessage": "Sistem hizmetinin durumu ve geçerli çalışma modu denetleniyor. Lütfen bekleyin…",
"unavailableMessage": "Gerekli Clash Verge hizmeti kullanılamıyor. Hizmeti kurun veya onarın ya da bu oturumda Sidecar ile devam edin.",
"reinstall": "Hizmeti yeniden kur",
"repair": "Hizmeti onar",
"continueSidecar": "Sidecar ile devam et",
"success": "Clash Verge hizmeti başarıyla güncellendi",
"errors": {
"actionFailed": "Hizmet işlemi başarısız oldu",
"stateRefreshFailed": "Hizmet durumu yenilenemedi",
+2 -2
View File
@@ -3,7 +3,7 @@
"title": "Günlükler"
},
"actions": {
"showDescending": "Newest first",
"showAscending": "Oldest first"
"showDescending": "Önce en yeni",
"showAscending": "Önce en eski"
}
}
+16 -16
View File
@@ -27,7 +27,7 @@
},
"feedback": {
"errors": {
"invalidUrl": "Invalid profile URL. Please enter a URL starting with http:// or https://",
"invalidUrl": "Geçersiz profil URL'si. Lütfen http:// veya https:// ile başlayan bir URL girin",
"onlyYaml": "Yalnızca YAML Dosyaları Desteklenir",
"createFailed": "Profil oluşturulamadı",
"deleteFailed": "Profil silinemedi",
@@ -42,16 +42,16 @@
"notifications": {
"importRetry": "İçe aktarma başarısız oldu, Clash vekil ile yeniden deneniyor...",
"importFail": "Clash vekil ile bile içe aktarma başarısız oldu",
"importNeedsRefresh": "Profile imported but may need manual refresh",
"importSuccess": "Profile imported successfully, please restart if not visible",
"importNeedsRefresh": "Profil içe aktarıldı ancak elle yenilemek gerekebilir",
"importSuccess": "Profil başarıyla içe aktarıldı, görünmüyorsa lütfen yeniden başlatın",
"profileSwitched": "Profil Değiştirildi",
"profileReactivated": "Profil Yeniden Etkinleştirildi",
"switchBusy": "Profil değiştirme işlemi zaten devam ediyor",
"batchDeleted": "Seçili profiller başarıyla silindi"
},
"notices": {
"forceRefreshCompleted": "Force refresh completed",
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
"forceRefreshCompleted": "Zorunlu yenileme tamamlandı",
"emergencyRefreshFailed": "Acil yenileme başarısız oldu: {{message}}"
},
"tooltips": {
"forceRefreshStaleData": "Profil verileri kullanılamıyor; zorla yenilemek için tıklayın"
@@ -71,7 +71,7 @@
"menu": {
"home": "Ana Sayfa",
"select": "Seç",
"shareQrCode": "Share QR Code",
"shareQrCode": "QR Kodu Paylaş",
"editInfo": "Bilgileri Düzenle",
"editFile": "Dosyayı Düzenle",
"editRules": "Kuralları Düzenle",
@@ -81,22 +81,22 @@
"extendScript": "Betik Genişletme",
"openFile": "Dosyayı Aç",
"update": "Güncelle",
"updateViaProxy": "Update via proxy"
"updateViaProxy": "Vekil üzerinden güncelle"
},
"more": {
"global": {
"merge": "Global Extend Config",
"script": "Global Extend Script"
"merge": "Küresel Yapılandırma Genişletme",
"script": "Küresel Betik Genişletme"
},
"chips": {
"merge": "Merge",
"script": "Script"
"merge": "Birleştirme",
"script": "Betik"
}
},
"profileItem": {
"tooltips": {
"showLast": "Click to show last update time",
"showNext": "Click to show next update"
"showLast": "Son güncelleme zamanını göstermek için tıklayın",
"showNext": "Sonraki güncellemeyi göstermek için tıklayın"
},
"status": {
"lastUpdateFailed": "Son güncelleme başarısız oldu",
@@ -118,12 +118,12 @@
"description": "Açıklamalar",
"subscriptionUrl": "Abonelik URL'si",
"userAgent": "Kullanıcı Aracısı",
"httpTimeout": "HTTP Request Timeout",
"httpTimeout": "HTTP İsteği Zaman Aşımı",
"updateInterval": "Güncelleme Aralığı",
"useSystemProxy": "Sistem Vekil'ini Kullan",
"useClashProxy": "Clash Vekil'ini Kullan",
"acceptInvalidCerts": "Geçersiz Sertifikalara İzin Ver (Tehlikeli)",
"allowAutoUpdate": "Allow Auto Update"
"allowAutoUpdate": "Otomatik Güncellemeye İzin Ver"
},
"types": {
"remote": "Uzak",
@@ -206,7 +206,7 @@
"title": "Betik Konsolu"
},
"qrViewer": {
"title": "Subscription QR Code"
"title": "Abonelik QR Kodu"
}
}
}
+27 -24
View File
@@ -1,16 +1,16 @@
{
"page": {
"modes": {
"rule": "Rule",
"global": "Global",
"direct": "Direct"
"rule": "Kural",
"global": "Küresel",
"direct": "Doğrudan"
},
"actions": {
"toggleChain": "Zincir Proxy",
"connect": "Connect",
"disconnect": "Disconnect",
"connecting": "Connecting...",
"clearChainConfig": "Delete Chain Config"
"connect": "Bağlan",
"disconnect": "Bağlantıyı Kes",
"connecting": "Bağlanıyor...",
"clearChainConfig": "Zincir Yapılandırmasını Sil"
},
"provider": {
"title": "Vekil Sağlayıcısı",
@@ -20,8 +20,8 @@
}
},
"rules": {
"title": "Proxy Rules",
"select": "Select Rules"
"title": "Vekil Kuralları",
"select": "Kural Seç"
},
"labels": {
"proxyCount": "Vekil Sayısı",
@@ -68,15 +68,15 @@
}
},
"chain": {
"header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
"connectFailed": "Failed to connect to proxy chain",
"disconnectFailed": "Failed to disconnect from proxy chain",
"duplicateNode": "Proxy node already exists in chain",
"header": "Zincir Vekil Yapılandırması",
"warning": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"empty": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın",
"minimumNodes": "Zincir vekil en az 2 düğüm gerektirir",
"minimumNodesHint": "Zincir vekil en az 2 düğüm gerektirir. Lütfen bir düğüm daha ekleyin.",
"connectFailed": "Vekil zincirine bağlanılamadı",
"disconnectFailed": "Vekil zinciriyle bağlantı kesilemedi",
"duplicateNode": "Vekil düğümü zincirde zaten var",
"entryNode": "Giriş",
"exitNode": "Çıkış"
},
@@ -85,17 +85,20 @@
},
"title": {
"default": "Vekil Grupları",
"chainMode": "Proxy Chain Mode"
"chainMode": "Vekil Zinciri Modu"
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL'si http:// veya https:// ile başlamalıdır"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
"updateFailed": "Failed to update {{name}}: {{message}}",
"genericError": "Update failed: {{message}}",
"none": "No providers available to update",
"allUpdated": "All providers updated successfully"
"updateSuccess": "{{name}} başarıyla güncellendi",
"updateFailed": "{{name}} güncellenemedi: {{message}}",
"genericError": "Güncelleme başarısız oldu: {{message}}",
"none": "Güncellenecek sağlayıcı yok",
"allUpdated": "Tüm sağlayıcılar başarıyla güncellendi"
}
}
},
+5 -5
View File
@@ -13,11 +13,11 @@
"feedback": {
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
"updateFailed": "Failed to update {{name}}: {{message}}",
"genericError": "Update failed: {{message}}",
"none": "No providers available to update",
"allUpdated": "All providers updated successfully"
"updateSuccess": "{{name}} başarıyla güncellendi",
"updateFailed": "{{name}} güncellenemedi: {{message}}",
"genericError": "Güncelleme başarısız oldu: {{message}}",
"none": "Güncellenecek sağlayıcı yok",
"allUpdated": "Tüm sağlayıcılar başarıyla güncellendi"
}
}
},
+85 -84
View File
@@ -3,7 +3,7 @@
"actions": {
"manual": "Kılavuz",
"telegram": "Telegram Kanalı",
"github": "Github Repo"
"github": "GitHub Deposu"
},
"actionsGroupLabel": "Ayar bağlantıları",
"title": "Ayarlar"
@@ -24,8 +24,8 @@
},
"notifications": {
"tunMode": {
"autoDisabled": "TUN Mode automatically disabled due to service unavailable",
"autoDisableFailed": "Failed to disable TUN Mode automatically"
"autoDisabled": "Hizmet kullanılamadığı için TUN Modu otomatik olarak devre dışı bırakıldı",
"autoDisableFailed": "TUN Modu otomatik olarak devre dışı bırakılamadı"
}
}
},
@@ -33,11 +33,11 @@
"tooltips": {
"systemProxy": "İşletim sisteminin vekil ayarlarını değiştirmek için etkinleştirin. Etkinleştirme başarısız olursa, işletim sisteminin proxy ayarlarını manuel olarak değiştirin",
"tunMode": "Tun (Sanal Ağ Kartı) modu: Tüm sistem trafiğini yakalar, etkinleştirildiğinde sistem vekil'ini etkinleştirmeye gerek yoktur.",
"tunUnavailable": "TUN requires Service Mode or Admin Mode"
"tunUnavailable": "TUN, Hizmet Modu veya Yönetici Modu gerektirir"
},
"actions": {
"installService": "Hizmeti Kur",
"uninstallService": "Uninstall Service",
"uninstallService": "Hizmeti Kaldır",
"switchToServiceMode": "Hizmet moduna geç"
},
"fields": {
@@ -92,9 +92,9 @@
}
},
"appearance": {
"light": "Light",
"dark": "Dark",
"system": "System"
"light": "Açık",
"dark": "Koyu",
"system": "Sistem"
},
"clash": {
"title": "Clash Ayarı",
@@ -144,11 +144,11 @@
},
"options": {
"logLevel": {
"debug": "Debug",
"info": "Info",
"warning": "Warn",
"error": "Error",
"silent": "Silent"
"debug": "Hata Ayıklama",
"info": "Bilgi",
"warning": "Uyarı",
"error": "Hata",
"silent": "Sessiz"
}
}
}
@@ -187,11 +187,11 @@
"liteMode": "GUI'yi kapatın ve yalnızca çekirdeği çalışır durumda tutun"
},
"actions": {
"copyVersion": "Copy Version"
"copyVersion": "Sürümü Kopyala"
},
"notifications": {
"latestVersion": "Şu Anda En Son Sürümdesiniz",
"versionCopied": "Version copied to clipboard"
"versionCopied": "Sürüm panoya kopyalandı"
},
"fields": {
"backupSetting": "Yedekleme Ayarı",
@@ -222,27 +222,27 @@
"cssInjection": "CSS Enjeksiyonu"
},
"actions": {
"editCss": "Edit CSS"
"editCss": "CSS'i Düzenle"
},
"dialogs": {
"editCssTitle": "Edit CSS"
"editCssTitle": "CSS'i Düzenle"
}
},
"layout": {
"title": "Düzen Ayarı",
"fields": {
"preferSystemTitlebar": "Prefer System Titlebar",
"preferSystemTitlebar": "Sistem Başlık Çubuğunu Tercih Et",
"trafficGraph": "Trafik Grafiği",
"memoryUsage": "Çekirdek Kullanımı",
"proxyGroupIcon": "Vekil Grup Simgesi",
"toastPosition": "Toast konumu",
"hoverNavigator": "Hover Jump Navigator",
"hoverNavigatorDelay": "Hover Jump Navigator Delay",
"hoverNavigator": "Üzerine Gelince Atlama Gezgini",
"hoverNavigatorDelay": "Üzerine Gelince Atlama Gecikmesi",
"navIcon": "Gezinme Simgesi",
"collapseNavBar": "Gezinme çubuğunu daralt",
"trayIcon": "Tepsi Simgesi",
"proxyGroupsDisplayMode": "Proxy Groups Display Mode",
"showOutboundModesInline": "Show Outbound Modes Inline",
"proxyGroupsDisplayMode": "Vekil Grupları Görüntüleme Modu",
"showOutboundModesInline": "Çıkış Modlarını Satır İçinde Göster",
"commonTrayIcon": "Genel Tepsi Simgesi",
"systemProxyTrayIcon": "Sistem Vekil Tepsi Simgesi",
"tunTrayIcon": "Tun Tepsi Simgesi",
@@ -251,8 +251,8 @@
"pauseRenderTrafficStatsOnBlur": "Odak kaybolduğunda trafik istatistiklerinin işlenmesini duraklat"
},
"tooltips": {
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
"hoverNavigator": "Alfabe harflerinin üzerine gelindiğinde ilgili vekil grubuna otomatik olarak kaydırır",
"hoverNavigatorDelay": "Üzerine gelindiğinde otomatik kaydırmadan önceki gecikme, milisaniye cinsinden"
},
"options": {
"icon": {
@@ -267,9 +267,9 @@
"bottomRight": "Sağ alt"
},
"proxyGroupsDisplayMode": {
"default": "Default",
"inline": "Inline",
"disable": "Disable"
"default": "Varsayılan",
"inline": "Satır İçi",
"disable": "Devre Dışı"
}
}
}
@@ -289,9 +289,9 @@
"random": "Rastgele Port"
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"portInUse": "{{port}} portu zaten kullanımda",
"saved": "Port ayarları kaydedildi",
"saveFailed": "Port ayarları kaydedilemedi",
"automaticFallback": "{{oldPort}} portu kullanımda olduğu için bu oturum {{newPort}} portunu kullanıyor. Ayarınız değişmedi ve sonraki başlatmada {{oldPort}} yeniden denenecek.",
"automaticFallbackFailed": "Karma proxy portu otomatik olarak değiştirilemedi: {{error}}",
"portTooLow": "Bağlantı noktası 1000 veya daha yüksek olmalıdır",
@@ -325,24 +325,24 @@
"backup": {
"title": "Yedekleme Ayarı",
"tabs": {
"local": "Local backup",
"webdav": "WebDAV backup"
"local": "Yerel yedek",
"webdav": "WebDAV yedeği"
},
"actions": {
"selectTarget": "Select backup target",
"selectTarget": "Yedekleme hedefini seçin",
"backup": "Yedekle",
"export": "Export",
"exportBackup": "Export Backup",
"importBackup": "Import Backup",
"export": "Dışa Aktar",
"exportBackup": "Yedeği Dışa Aktar",
"importBackup": "Yedeği İçe Aktar",
"deleteBackup": "Yedeği Sil",
"restore": "Geri Yükle",
"restoreBackup": "Yedeği Geri Yükle",
"viewHistory": "View history"
"viewHistory": "Geçmişi görüntüle"
},
"fields": {
"webdavUrl": "WebDAV Sunucu URL'si",
"username": "Kullanıcı Adı",
"info": "Backups are stored locally in the application data directory. Use the list below to restore or delete backups."
"info": "Yedekler yerel olarak uygulama veri dizininde saklanır. Yedekleri geri yüklemek veya silmek için aşağıdaki listeyi kullanın."
},
"messages": {
"webdavUrlRequired": "WebDAV URL'si boş olamaz",
@@ -353,60 +353,60 @@
"webdavConfigSaveFailed": "WebDAV yapılandırması kaydedilemedi: {{error}}",
"backupCreated": "Yedek başarıyla oluşturuldu",
"backupFailed": "Yedekleme başarısız oldu: {{error}}",
"localBackupCreated": "Local backup created successfully",
"localBackupFailed": "Local backup failed",
"localBackupCreated": "Yerel yedek başarıyla oluşturuldu",
"localBackupFailed": "Yerel yedekleme başarısız oldu",
"restoreSuccess": "Geri Yükleme Başarılı, Uygulama 1 saniye içinde yeniden başlatılacak",
"localBackupExported": "Local backup exported successfully",
"localBackupExportFailed": "Failed to export local backup",
"localBackupImported": "Local backup imported successfully",
"localBackupImportFailed": "Failed to import local backup: {{error}}",
"webdavRefreshSuccess": "WebDAV refresh succeeded",
"webdavRefreshFailed": "WebDAV refresh failed: {{error}}",
"localBackupExported": "Yerel yedek başarıyla dışa aktarıldı",
"localBackupExportFailed": "Yerel yedek dışa aktarılamadı",
"localBackupImported": "Yerel yedek başarıyla içe aktarıldı",
"localBackupImportFailed": "Yerel yedek içe aktarılamadı: {{error}}",
"webdavRefreshSuccess": "WebDAV yenilemesi başarılı",
"webdavRefreshFailed": "WebDAV yenilemesi başarısız oldu: {{error}}",
"confirmDelete": "Bu yedek dosyasını silmeyi onaylıyor musunuz?",
"confirmRestore": "Bu yedek dosyasını geri yüklemeyi onaylıyor musunuz?"
},
"auto": {
"title": "Automatic backup",
"scheduleLabel": "Enable scheduled backup",
"scheduleHelper": "Create local backups in the background at the configured interval.",
"intervalLabel": "Backup frequency",
"changeLabel": "Backup on critical changes",
"changeHelper": "Automatically backup when Global Extend Config/Script changes.",
"title": "Otomatik yedekleme",
"scheduleLabel": "Zamanlanmış yedeklemeyi etkinleştir",
"scheduleHelper": "Yapılandırılan aralıkta arka planda yerel yedekler oluşturur.",
"intervalLabel": "Yedekleme sıklığı",
"changeLabel": "Kritik değişikliklerde yedekle",
"changeHelper": "Küresel Yapılandırma/Betik Genişletme değiştiğinde otomatik olarak yedekler.",
"options": {
"hours": "Every {{n}} hours",
"days": "Every {{n}} days"
"hours": "{{n}} saatte bir",
"days": "{{n}} günde bir"
}
},
"manual": {
"title": "Manual backup",
"local": "Creates a snapshot on this device, stored under the app data directory.",
"webdav": "Upload a snapshot to your WebDAV server once credentials are set.",
"configureWebdav": "Configure WebDAV"
"title": "Elle yedekleme",
"local": "Bu cihazda, uygulama veri dizini altında saklanan bir anlık görüntü oluşturur.",
"webdav": "Kimlik bilgileri ayarlandıktan sonra WebDAV sunucunuza bir anlık görüntü yükler.",
"configureWebdav": "WebDAV'ı Yapılandır"
},
"history": {
"title": "Backup history",
"summary": "{{count}} backups • latest {{recent}}",
"empty": "No backups available",
"unknownPlatform": "unknown",
"unknownTime": "Unknown time"
"title": "Yedekleme geçmişi",
"summary": "{{count}} yedek • en son {{recent}}",
"empty": "Kullanılabilir yedek yok",
"unknownPlatform": "bilinmiyor",
"unknownTime": "Bilinmeyen zaman"
},
"webdav": {
"title": "WebDAV settings"
"title": "WebDAV ayarları"
},
"table": {
"filename": "Dosya Adı",
"backupTime": "Yedekleme Zamanı",
"actions": "İşlemler",
"noBackups": "Kullanılabilir yedek yok",
"rowsPerPage": "Rows per page"
"rowsPerPage": "Sayfa başına satır"
}
},
"misc": {
"title": "Çeşitli",
"fields": {
"appLogLevel": "Uygulama Günlük Seviyesi",
"appLogMaxSize": "App Log Max Size",
"appLogMaxCount": "App Log Max Count",
"appLogMaxSize": "Uygulama Günlüğü En Büyük Boyutu",
"appLogMaxCount": "Uygulama Günlüğü En Fazla Dosya Sayısı",
"autoCloseConnections": "Bağlantıları Otomatik Kapat",
"autoCheckUpdate": "Otomatik Güncelleme Kontrolü",
"enableBuiltinEnhanced": "Yerleşik Geliştirilmiş Modu Etkinleştir",
@@ -434,7 +434,7 @@
}
},
"update": {
"title": "New Version v{{version}}",
"title": "Yeni Sürüm v{{version}}",
"actions": {
"goToRelease": "Sürüm Sayfasına Git",
"update": "Güncelle"
@@ -486,18 +486,18 @@
"title": "Tun Modu",
"fields": {
"stack": "Tun Yığını",
"device": "Device Name",
"device": "Aygıt Adı",
"autoRoute": "Otomatik Yönlendirme",
"routeExcludeAddress": "Yönlendirme Hariç Adresler",
"strictRoute": "Katı Yönlendirme",
"autoDetectInterface": "Arayüzü Otomatik Algıla",
"dnsHijack": "DNS Ele Geçirme",
"mtu": "Maksimum İletim Birimi",
"autoRedirect": "Auto Redirect"
"autoRedirect": "Otomatik Yönlendirme"
},
"tooltips": {
"dnsHijack": "Please use , to separate multiple DNS servers",
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
"dnsHijack": "Birden fazla DNS sunucusunu ayırmak için lütfen , kullanın",
"autoRedirect": "nftables/iptables TCP yönlendirmelerini otomatik olarak yapılandırır"
},
"messages": {
"applied": "Ayarlar Uygulandı",
@@ -511,11 +511,11 @@
"warning": "Bu ayarlarla ilgili bilginiz yoksa, lütfen bunları değiştirmeyin ve DNS Üzerine Yazma'yı devre dışı tutun"
},
"protection": {
"title": "Enable DNS Overwrite?",
"message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?",
"enableAnyway": "Enable anyway",
"keepDisabled": "Keep disabled",
"autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription."
"title": "DNS Üzerine Yazma etkinleştirilsin mi?",
"message": "Bu abonelik özel DNS ayarları içeriyor. DNS Üzerine Yazma bunların yerine geçebilir; bu da düğümlerin bağlanmasını veya bazı web sitelerinin yüklenmesini engelleyebilir. Yine de etkinleştirilsin mi?",
"enableAnyway": "Yine de etkinleştir",
"keepDisabled": "Devre dışı bırak",
"autoDisabled": "Geçerli abonelikteki özel DNS ayarlarını korumak için DNS Üzerine Yazma devre dışı bırakıldı."
},
"sections": {
"general": "DNS Ayarları",
@@ -535,7 +535,7 @@
},
"preferH3": {
"label": "H3'ü Tercih Et",
"description": "DNS DOH uses HTTP/3"
"description": "DNS DOH, HTTP/3 kullanır"
},
"respectRules": {
"label": "Kurallara Uy",
@@ -601,11 +601,11 @@
},
"messages": {
"saved": "DNS ayarları kaydedildi",
"configError": "DNS configuration error:"
"configError": "DNS yapılandırma hatası:"
},
"errors": {
"invalid": "Invalid configuration",
"invalidYaml": "Invalid YAML format"
"invalid": "Geçersiz yapılandırma",
"invalidYaml": "Geçersiz YAML biçimi"
}
},
"webUI": {
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Hizmet Başarıyla Kuruldu",
"sidecarFallback": "Hizmet, yol izinleri nedeniyle çekirdeği başlatamadı. Çalışmaya devam etmek için yönetici moduna geçildi.",
"uninstallSuccess": "Hizmet Başarıyla Kaldırıldı"
},
"updater": {
@@ -693,12 +694,12 @@
},
"statuses": {
"clash": {
"stopping": "Stopping Core...",
"restarting": "Restarting Core..."
"stopping": "Çekirdek Durduruluyor...",
"restarting": "Çekirdek Yeniden Başlatılıyor..."
},
"clashService": {
"installing": "Hizmet Kuruluyor...",
"uninstalling": "Uninstalling Service..."
"uninstalling": "Hizmet Kaldırılıyor..."
}
}
}
+17 -17
View File
@@ -12,23 +12,23 @@
"restart": "Yeniden Başlat",
"resetToDefault": "Varsayılana Sıfırla",
"refresh": "Yenile",
"retry": "Retry",
"refreshPage": "Refresh Page",
"showDetails": "Show Details",
"hideDetails": "Hide Details",
"retry": "Yeniden Dene",
"refreshPage": "Sayfayı Yenile",
"showDetails": "Ayrıntıları Göster",
"hideDetails": "Ayrıntıları Gizle",
"listView": "Liste Görünümü",
"tableView": "Tablo Görünümü",
"pause": "Duraklat",
"resume": "Sürdür",
"closeAll": "Tümünü Kapat",
"clear": "Temizle",
"previous": "Previous",
"next": "Next",
"previous": "Önceki",
"next": "Sonraki",
"check": "Denetle"
},
"labels": {
"updateAt": "Güncelleme Zamanı",
"timeout": "Timeout",
"timeout": "Zaman Aşımı",
"icon": "Simge",
"name": "İsim",
"readOnly": "Salt Okunur",
@@ -37,14 +37,14 @@
"usedTotal": "Kullanılan / Toplam",
"from": "Kaynak",
"password": "Şifre",
"retryAttempts": "Retry attempts",
"retryAttempts": "Yeniden deneme sayısı",
"downloaded": "İndirilen",
"uploaded": "Yüklenen"
},
"statuses": {
"enabled": "Etkin",
"disabled": "Devre Dışı",
"saving": "Saving...",
"saving": "Kaydediliyor...",
"empty": "Boş",
"loading": "Yükleniyor..."
},
@@ -54,7 +54,7 @@
"minutes": "dakika",
"hours": "saat",
"kilobytes": "KB",
"files": "Files"
"files": "Dosya"
},
"placeholders": {
"resetInput": "Giriş alanını temizle",
@@ -64,7 +64,7 @@
"useRegex": "Düzenli İfade Kullan"
},
"validation": {
"invalidRegex": "Invalid regular expression"
"invalidRegex": "Geçersiz düzenli ifade"
},
"window": {
"maximize": "Büyüt",
@@ -84,8 +84,8 @@
"componentStack": "Bileşen yığını",
"trafficUnavailable": "Trafik verileri kullanılamıyor",
"operationFailed": "İşlem başarısız oldu",
"trafficStats": "Traffic Statistics Error",
"trafficStatsDescription": "The traffic statistics component encountered an error and has been disabled to prevent crashes."
"trafficStats": "Trafik İstatistikleri Hatası",
"trafficStatsDescription": "Trafik istatistikleri bileşeni bir hatayla karşılaştı ve çökmeleri önlemek için devre dışı bırakıldı."
},
"notices": {
"raw": "{{message}}",
@@ -95,12 +95,12 @@
"importSuccess": "Profil Başarıyla İçe Aktarıldı",
"importSubscriptionSuccess": "Abonelik içe aktarımı başarılı",
"importWithClashProxy": "Profil Clash vekil ile içe aktarıldı",
"updateAvailable": "Update Available",
"saved": "Saved successfully",
"updateAvailable": "Güncelleme Mevcut",
"saved": "Başarıyla kaydedildi",
"common": {
"copySuccess": "Kopyalama Başarılı",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"saveSuccess": "Yapılandırma başarıyla kaydedildi",
"saveFailed": "Yapılandırma kaydedilemedi",
"refreshFailed": "Yenileme başarısız"
}
},
+2 -2
View File
@@ -49,10 +49,10 @@
"actions": {
"testing": "Test Ediliyor..."
},
"empty": "No unlock test items",
"empty": "Kilit açma testi öğesi yok",
"messages": {
"detectionFailedWithName": "{{name}} için tespit başarısız oldu",
"detectionTimeout": "Detection timeout or failed"
"detectionTimeout": "Algılama zaman aşımına uğradı veya başarısız oldu"
},
"title": "Kilit Açma Testi"
}
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Сервис уңышлы урнаштырылды",
"sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.",
"uninstallSuccess": "Сервис уңышлы салдырылды"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} 更新成功",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "已成功安装服务",
"sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。",
"uninstallSuccess": "已成功卸载服务"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "已成功安裝服務",
"sidecarFallback": "服務因路徑權限問題無法啟動核心,已切回管理員模式繼續執行。",
"uninstallSuccess": "已成功解除安裝服務"
},
"updater": {
@@ -13,7 +13,10 @@ vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() }))
vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() }))
vi.mock('@/services/cmds', () => ({ takeDnsOverrideNotice: vi.fn() }))
vi.mock('@/services/cmds', () => ({
takeDnsOverrideNotice: vi.fn(),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/services/notice-service', () => ({
showNotice: { info: vi.fn() },
}))
@@ -64,6 +64,7 @@ export const useLayoutEvents = (
() => {
revalidateKeys(['getRuntimeState', 'getVergeConfig'])
handleNotice(['dns_override::auto_disabled', ''])
handleNotice(['service_core::sidecar_fallback', ''])
},
)
}, [handleNotice])
@@ -1,4 +1,7 @@
import { takeDnsOverrideNotice } from '@/services/cmds'
import {
takeDnsOverrideNotice,
takeServiceFallbackNotice,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
type NavigateFunction = (path: string, options?: any) => void
@@ -25,6 +28,22 @@ export const handleNoticeMessage = (
showNotice.error(msg)
},
'set_config::error': () => showNotice.error(msg),
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((pending) => {
if (pending) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service fallback notice',
error,
)
})
},
'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice()
.then((pending) => {
+2 -1
View File
@@ -59,6 +59,7 @@ import {
useThemeMode,
} from '@/services/states'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
// 与 src-tauri/src/main.rs 的 worker_limit 上限(8)保持一致,避免前后端更新风暴不对齐
const PROFILE_UPDATE_WORKER_LIMIT = 8
@@ -214,7 +215,7 @@ const ProfilePage = () => {
const onImport = async () => {
if (!url) return
if (!/^https?:\/\//i.test(url)) {
if (!isValidUrl(url)) {
showNotice.error('profiles.page.feedback.errors.invalidUrl')
return
}
+4
View File
@@ -182,6 +182,10 @@ export async function takeDnsOverrideNotice() {
return invoke<boolean>('take_dns_override_notice')
}
export async function takeServiceFallbackNotice() {
return invoke<boolean>('take_service_fallback_notice')
}
export async function getSystemProxy() {
return invoke<{
enable: boolean
+6
View File
@@ -12,6 +12,7 @@ import {
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
import { isValidUrl } from '@/utils/network'
export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number
@@ -152,6 +153,11 @@ class DelayManager {
}
setUrl(group: string, url: string) {
if (!isValidUrl(url)) {
debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`)
this.urlMap.delete(group)
return
}
debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`)
this.urlMap.set(group, url)
}
+2
View File
@@ -324,6 +324,7 @@ export const translationKeys = [
'proxies.page.messages.directMode',
'proxies.page.title.default',
'proxies.page.title.chainMode',
'proxies.feedback.warnings.invalidTestUrl',
'proxies.feedback.notifications.provider.updateSuccess',
'proxies.feedback.notifications.provider.updateFailed',
'proxies.feedback.notifications.provider.genericError',
@@ -793,6 +794,7 @@ export const translationKeys = [
'settings.feedback.notifications.clash.changeFailed',
'settings.feedback.notifications.clash.geoDataUpdated',
'settings.feedback.notifications.clashService.installSuccess',
'settings.feedback.notifications.clashService.sidecarFallback',
'settings.feedback.notifications.clashService.uninstallSuccess',
'settings.feedback.notifications.updater.withClashProxySuccess',
'settings.feedback.notifications.updater.withClashProxyFailed',
+4
View File
@@ -500,6 +500,9 @@ export interface TranslationResources {
updateSuccess: string
}
}
warnings: {
invalidTestUrl: string
}
}
page: {
actions: {
@@ -831,6 +834,7 @@ export interface TranslationResources {
}
clashService: {
installSuccess: string
sidecarFallback: string
uninstallSuccess: string
}
updater: {