mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
Compare commits
18
Commits
83a06e5716
...
v2.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13676d3335 | ||
|
|
2a7972fa5b | ||
|
|
19334368cd | ||
|
|
73ea0fbd80 | ||
|
|
9f97d938f5 | ||
|
|
04a2a09368 | ||
|
|
94491b4ba4 | ||
|
|
78516dc1dc | ||
|
|
8f6fd3bbf9 | ||
|
|
c599f5547e | ||
|
|
f6fdb34745 | ||
|
|
65ed8d3cf2 | ||
|
|
2baa629ac4 | ||
|
|
c7ffb21294 | ||
|
|
0d0ed72467 | ||
|
|
a36dcd319f | ||
|
|
9a879dd8d8 | ||
|
|
20141e791e |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+111
-108
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.4.1
|
||||
pnpm 12.5.1
|
||||
rust 1.98.1
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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 +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
@@ -144,5 +144,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.4.1"
|
||||
"packageManager": "pnpm@12.5.1"
|
||||
}
|
||||
|
||||
Generated
+61
-61
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(()),
|
||||
};
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "تم تثبيت الخدمة بنجاح",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
|
||||
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "سرویس با موفقیت نصب شد",
|
||||
"sidecarFallback": "سرویس بهدلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
|
||||
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "サービスのインストールに成功しました。",
|
||||
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
|
||||
"uninstallSuccess": "サービスのアンインストールに成功しました。"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} 업데이트 성공",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "서비스가 설치되었습니다",
|
||||
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
|
||||
"uninstallSuccess": "서비스가 제거되었습니다"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} успешно обновлён",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Служба успешно установлена",
|
||||
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
|
||||
"uninstallSuccess": "Служба успешно удалена"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"title": "Günlükler"
|
||||
},
|
||||
"actions": {
|
||||
"showDescending": "Newest first",
|
||||
"showAscending": "Oldest first"
|
||||
"showDescending": "Önce en yeni",
|
||||
"showAscending": "Önce en eski"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Сервис уңышлы урнаштырылды",
|
||||
"sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.",
|
||||
"uninstallSuccess": "Сервис уңышлы салдырылды"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} 更新成功",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "已成功安装服务",
|
||||
"sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。",
|
||||
"uninstallSuccess": "已成功卸载服务"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user