Compare commits

..
1 Commits
Author SHA1 Message Date
Tunglies 83a06e5716 feat(app): upgrade to tauri 3.0.0-alpha
Move the app onto the tauri v3 alpha line: runtime selection is now
explicit via tauri-runtime-wry, the Wry generics give way to the
DynRuntime defaults, and every official plugin rides 3.0.0-alpha.0
alongside the matching npm packages; pnpm moves to 12.4.2. The
tray-icon patch is dropped because upstream 0.25.1 ships the macOS 27
click-reentrancy fix. perf-harness is temporarily hollowed out until
tauri-plugin-wdio-webdriver publishes a tauri 3 release.
2026-09-16 17:41:52 +08:00
185 changed files with 3719 additions and 6174 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.8.1"
"pnpmVersion": "12.3.3"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1",
+11 -41
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.21": {
"github/gh-aw-actions/setup@v0.89.1": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.21",
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
}
},
"containers": {
@@ -22,16 +22,6 @@
"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/agent:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
},
"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",
@@ -42,16 +32,6 @@
"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/api-proxy:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -62,30 +42,20 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"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-firewall/squid:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
"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-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
},
"ghcr.io/github/github-mcp-server:v1.12.2": {
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
"ghcr.io/github/github-mcp-server:v1.12.1": {
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
}
}
}
+4 -4
View File
@@ -3,8 +3,8 @@ name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 10,22 * * *'
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: '0 4,10 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
@@ -101,7 +101,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -580,7 +580,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+2 -2
View File
@@ -31,6 +31,6 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
with:
version: v0.89.15
version: v0.89.1
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -144,7 +144,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -691,7 +691,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.8.1
pnpm 12.4.1
rust 1.98.1
Generated
+265 -441
View File
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -46,12 +46,6 @@ inherits = "fast-release"
codegen-units = 1
split-debuginfo = "unpacked"
[patch.crates-io]
# macOS Golden Gate fix for tray-icon click reentrancy issue
# Remove the following patch once the fix is merged upstream(tauri)
# https://github.com/tauri-apps/tray-icon/pull/365
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
[workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
@@ -63,8 +57,9 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tauri = { version = "2.11.6" }
tauri-plugin-clipboard-manager = "2.3.3"
tauri = { version = "3.0.0-alpha.1" }
tauri-runtime-wry = "3.0.0-alpha.1"
tauri-plugin-clipboard-manager = "3.0.0-alpha.0"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
@@ -105,7 +100,6 @@ windows-sys = { version = "0.61.2", features = [
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
] }
# *********************************
+79 -11
View File
@@ -1,35 +1,103 @@
## v2.5.7
## v2.5.4
> [!IMPORTANT]
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
**🍎 macOS**
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: على وشك الخروج
body: Clash Verge على وشك الخروج.
appHidden:
title: تم إخفاء التطبيق
body: Clash Verge يعمل في الخلفية.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Beenden steht bevor
body: Clash Verge wird gleich beendet.
appHidden:
title: Anwendung ausgeblendet
body: Clash Verge läuft im Hintergrund.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: A punto de salir
body: Clash Verge está a punto de salir.
appHidden:
title: Aplicación oculta
body: Clash Verge se está ejecutando en segundo plano.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: در آستانه خروج
body: Clash Verge در آستانه خروج است.
appHidden:
title: برنامه پنهان شد
body: Clash Verge در پس‌زمینه در حال اجراست.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Akan Keluar
body: Clash Verge akan keluar.
appHidden:
title: Aplikasi Disembunyikan
body: Clash Verge berjalan di latar belakang.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 終了間近
body: Clash Verge はまもなく終了します。
appHidden:
title: アプリが非表示
body: Clash Verge はバックグラウンドで実行中です。
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 곧 종료
body: Clash Verge가 곧 종료됩니다.
appHidden:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Скорый выход
body: Clash Verge скоро завершит работу.
appHidden:
title: Приложение скрыто
body: Clash Verge работает в фоновом режиме.
updateReady:
title: Обновление Clash Verge
body: Новая версия (v{version}) скачана и готова к установке.
+11 -8
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
'on': Sistem vekili etkinleştirildi.
'off': Sistem vekili devre dışı bırakıldı.
'on': System proxy has been enabled.
'off': System proxy has been disabled.
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 modu etkinleştirildi.
'off': TUN modu devre dışı bırakıldı.
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
lightweightModeEntered:
title: Hafif Mod
body: Hafif moda geçildi.
@@ -29,11 +29,14 @@ notifications:
appQuit:
title: Çıkış Yapılmak Üzere
body: Clash Verge kapanmak üzere.
appHidden:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Güncellemesi
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Şimdi Kur
later: Daha Sonra
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
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.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Чыгар алдыннан
body: Clash Verge чыгарга җыена.
appHidden:
title: Кушымта яшерелде
body: Clash Verge фон режимында эшли.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 即将退出
body: Clash Verge 即将退出。
appHidden:
title: 应用已隐藏
body: Clash Verge 正在后台运行。
updateReady:
title: Clash Verge 更新
body: 新版本 (v{version}) 已下载完成,是否立即安装?
+3
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 即將退出
body: Clash Verge 即將退出。
appHidden:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
updateReady:
title: Clash Verge 更新
body: 新版本(v{version})已下載完成,是否立即安裝?
-177
View File
@@ -1,180 +1,3 @@
## v2.5.6
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
**🖥️ Windows**
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
**🍎 macOS**
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
</details>
## v2.5.5
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
**🐧 Linux**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
</details>
## v2.5.4
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
**🍎 macOS**
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
## v2.5.2
### 🐞 修复问题
+3 -3
View File
@@ -51,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
## Promotion
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### **Core Advantages:**
@@ -67,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
- 🎬 Unlocks **streaming platforms and mainstream AI services**
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
+3 -3
View File
@@ -51,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
## Promociones
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
- Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
+3 -3
View File
@@ -50,17 +50,17 @@
## تبلیغات
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دوره‌های آزمایشی رایگان، طرح‌های تخفیف‌دار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
- بسته تخفیف‌دار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره می‌شود
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینت‌های قدیمی)، تأخیر فوق‌العاده کم، پخش روان 4K
- اولین ارائه‌دهنده جهانی با **پروتکل QUIC**، اکنون با پروتکل‌های سریع‌تر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
+3 -3
View File
@@ -51,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
## プロモーション
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
- ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
+3 -3
View File
@@ -51,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
## 프로모션
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
- 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
+3 -3
View File
@@ -47,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
## Продвижение
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
- Команда за рубежом, без риска побега, до 50% кэшбэка
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
+46 -46
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.7",
"version": "2.5.4",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -49,88 +49,88 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-fs": "^2.5.2",
"@tauri-apps/plugin-http": "~2.6.1",
"@tauri-apps/plugin-opener": "2.5.5",
"@tauri-apps/plugin-updater": "2.12.0",
"ahooks": "^3.10.0",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-virtual": "^3.13.25",
"@tauri-apps/api": "3.0.0-alpha.0",
"@tauri-apps/plugin-clipboard-manager": "^3.0.0-alpha.0",
"@tauri-apps/plugin-dialog": "^3.0.0-alpha.0",
"@tauri-apps/plugin-fs": "^3.0.0-alpha.0",
"@tauri-apps/plugin-http": "~3.0.0-alpha.0",
"@tauri-apps/plugin-opener": "3.0.0-alpha.0",
"@tauri-apps/plugin-updater": "3.0.0-alpha.0",
"ahooks": "^3.9.7",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
"dayjs": "1.11.23",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^26.4.2",
"js-yaml": "^5.4.2",
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^5.0.0",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.31",
"meta-json-schema": "^1.19.25",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.5",
"react-hook-form": "^7.88.0",
"react-hook-form": "^7.76.1",
"react-i18next": "17.0.14",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"react-router": "^8.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1",
"swr": "^2.4.2",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
"validator": "^13.15.35"
},
"devDependencies": {
"@actions/github": "^9.1.1",
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@biomejs/biome": "^2.4.15",
"@eslint-react/eslint-plugin": "^5.8.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.11.4",
"@tauri-apps/cli": "3.0.0-alpha.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/node": "^24.12.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"cli-color": "^2.0.4",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.11.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-es-x": "^10.0.1",
"eslint-plugin-import-x": "^4.17.1",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-sukka": "^10.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"globals": "^17.6.0",
"https-proxy-agent": "^9.0.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.35.1",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
"terser": "^5.48.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"vitest": "^5.0.0",
"webdriverio": "9.31.9",
"ws": "8.21.3"
},
@@ -144,5 +144,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.8.1"
"packageManager": "pnpm@12.4.2"
}
+984 -1040
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -10,3 +10,15 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- '@tauri-apps/cli-darwin-arm64@3.0.0-alpha.1'
- '@tauri-apps/cli-darwin-x64@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-arm-gnueabihf@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-arm64-gnu@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-arm64-musl@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-riscv64-gnu@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-x64-gnu@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-x64-musl@3.0.0-alpha.1'
- '@tauri-apps/cli-win32-arm64-msvc@3.0.0-alpha.1'
- '@tauri-apps/cli-win32-ia32-msvc@3.0.0-alpha.1'
- '@tauri-apps/cli-win32-x64-msvc@3.0.0-alpha.1'
- '@tauri-apps/cli@3.0.0-alpha.1'
+1 -17
View File
@@ -56,12 +56,6 @@
"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"],
@@ -84,7 +78,7 @@
},
{
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm"],
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
"groupName": "pnpm"
},
{
@@ -137,16 +131,6 @@
],
"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"],
+141 -85
View File
@@ -1,23 +1,36 @@
import { execFileSync, spawn } from 'node:child_process'
import { access } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
export const developmentServiceWatchPaths = [
'src',
'resources',
'Cargo.toml',
'Cargo.lock',
].map((name) => join(serviceRepository, name))
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
@@ -26,15 +39,10 @@ function run(command, args, options = {}) {
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => {
stdout += chunk
})
child.once('error', reject)
child.once('close', (code, signal) => {
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise(stdout)
resolvePromise()
return
}
const error = new Error(
@@ -46,86 +54,134 @@ function run(command, args, options = {}) {
})
}
export async function prepareDevelopmentService() {
await access(serviceManifest)
const output = await run(
'cargo',
[
'build',
'--manifest-path',
serviceManifest,
'--target-dir',
process.env.CARGO_TARGET_DIR ||
join(serviceRepository, 'target', 'development'),
'--features',
'standalone,client,development-channel',
'--bins',
'--message-format=json-render-diagnostics',
],
{ stdio: ['inherit', 'pipe', 'inherit'] },
)
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
const artifacts = new Map()
for (const line of output.split(/\r?\n/).filter(Boolean)) {
const artifact = JSON.parse(line)
if (
artifact.reason === 'compiler-artifact' &&
resolve(artifact.manifest_path) === serviceManifest &&
artifact.target.kind.includes('bin') &&
artifact.executable
) {
artifacts.set(artifact.target.name, resolve(artifact.executable))
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
const executables = [
'clash-verge-service',
'clash-verge-service-install',
'clash-verge-service-uninstall',
'service-integration-driver',
].map((name) => {
const executable = artifacts.get(name)
if (!executable)
throw new Error(`Cargo did not report the ${name} executable`)
return executable
})
const serviceDirectory = dirname(executables[0])
for (const executable of executables) {
if (dirname(executable) !== serviceDirectory) {
throw new Error(
'Development service tools were built in different directories',
)
}
await access(executable)
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
function windowsElevationInvocation(installer, environment = process.env) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
return serviceDirectory
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService()
const extension = platform === 'win32' ? '.exe' : ''
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const installer = join(
serviceDirectory,
`clash-verge-service-install${extension}`,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
)
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
/^host: (.+)$/m,
)?.[1]
if (!host) throw new Error('rustc did not report its host target')
const args = ['--prepare-install', '--ensure']
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
args.push(
'--core',
`${name}${extension}`,
join(
repositoryRoot,
'src-tauri',
'sidecar',
`${name}-${host}${extension}`,
),
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
}
await run(installer, args)
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
return serviceDirectory
}
-4
View File
@@ -10,7 +10,6 @@ import {
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
developmentServiceWatchPaths,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
@@ -32,9 +31,6 @@ export function buildTauriInvocation(
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
for (const path of developmentServiceWatchPaths) {
args.push('--additional-watch-folders', path)
}
return {
command: 'pnpm',
args,
+11 -53
View File
@@ -1,9 +1,9 @@
import { execFileSync, execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import zlib from 'node:zlib'
import { execFileSync, execSync } from 'child_process'
import { createHash } from 'crypto'
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import zlib from 'zlib'
import AdmZip from 'adm-zip'
import { glob } from 'glob'
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
const zip = new AdmZip(tempZip)
zip
.getEntries()
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
zip.extractAllTo(tempDir, true)
if (fs.existsSync(tempDll)) {
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
function serviceFileInfo(name) {
const ext = platform === 'win32' ? '.exe' : ''
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
return {
sourceFile: `${name}${ext}`,
targetFile: `${name}${suffix}${ext}`,
@@ -586,41 +586,6 @@ async function resolveServiceBundle() {
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const serviceDependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
if (sourcePath) {
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
execFileSync(
'cargo',
[
'build',
'--manifest-path',
manifest,
'--target-dir',
targetDirectory,
'--target',
SIDECAR_HOST,
'--release',
'--features',
'standalone,client',
'--bins',
],
{ stdio: 'inherit' },
)
await fsp.mkdir(SERVICE_DIR, { recursive: true })
for (const { sourceFile, targetPath } of files) {
await fsp.copyFile(
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
targetPath,
)
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
await updateHashCache(targetPath)
}
return
}
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
@@ -639,9 +604,8 @@ async function resolveServiceBundle() {
const zip = new AdmZip(tempArchive)
zip
.getEntries()
.forEach(
(entry) =>
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
.forEach((entry) =>
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
)
zip.extractAllTo(tempDir, true)
} else {
@@ -688,7 +652,7 @@ async function resolveCoreHashes() {
.digest('hex')
lines.push(`!define ${define} "${digest}"`)
}
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
log_success(`Generated ${CORE_HASHES_NSH}`)
}
@@ -697,11 +661,6 @@ const resolveMmdb = () =>
file: 'Country.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
})
const resolveASNMmdb = () =>
resolveResource({
file: 'ASN.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
})
const resolveGeosite = () =>
resolveResource({
file: 'geosite.dat',
@@ -747,7 +706,6 @@ const tasks = [
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
{ name: 'service', func: resolveServiceBundle, retry: 5 },
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
{ name: 'geosite', func: resolveGeosite, retry: 5 },
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
{
+15 -50
View File
@@ -1,5 +1,5 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
@@ -108,20 +108,17 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ',
packageJson.version,
)
const versionWithoutV = newVersion.startsWith('v')
packageJson.version = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
)
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
@@ -156,34 +153,6 @@ 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')
@@ -199,16 +168,13 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
)
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}`,
)
@@ -273,7 +239,6 @@ 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) {
+12 -3
View File
@@ -14,13 +14,22 @@ async function resolveUpdater() {
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const tag = { name: latestRelease.tag_name }
const { data: tags } = await github.rest.repos.listTags({
...options,
per_page: 10,
page: 1,
})
const tag = tags.find((t) => t.name.startsWith('v'))
console.log(tag)
console.log()
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
})
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name), // use Changelog.md
+2 -3
View File
@@ -40,11 +40,10 @@ async function resolveUpdater() {
const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`)
const stableTagRegex = /^v\d+\.\d+\.\d+$/
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const stableTag = { name: latestRelease.tag_name }
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
console.log('All tags:', tags.map((t) => t.name).join(', '))
+21 -20
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.7"
version = "2.5.4"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -23,13 +23,15 @@ tauri-dev = ["dep:tauri-plugin-devtools"]
tokio-trace = ["clash-verge-logging/tokio-trace"]
chrome-trace = ["clash-verge-logging/chrome-trace"]
clippy = ["tauri/test"]
perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
# The perf-harness feature and its main.rs wiring are parked until
# tauri-plugin-wdio-webdriver publishes a tauri 3 release; restore both
# from git history together with the crate dependency.
[package.metadata.bundle]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
tauri-build = { version = "3.0.0-alpha.0", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
@@ -42,12 +44,12 @@ tauri-plugin-clash-verge-sysinfo = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
tauri = { workspace = true, features = [
"protocol-asset",
"devtools",
"tracing",
"tray-icon",
"image-ico",
"image-png",
] }
tauri-runtime-wry = { workspace = true, features = ["devtools"] }
parking_lot = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -74,13 +76,13 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.6"
tauri-plugin-opener = "2.5.5"
tauri-plugin-dialog = "2.7.3"
tauri-plugin-fs = "2.5.2"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.10"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-shell = "3.0.0-alpha.0"
tauri-plugin-opener = "3.0.0-alpha.0"
tauri-plugin-dialog = "3.0.0-alpha.0"
tauri-plugin-fs = "3.0.0-alpha.0"
tauri-plugin-process = "3.0.0-alpha.0"
tauri-plugin-deep-link = "3.0.0-alpha.0"
tauri-plugin-window-state = "3.0.0-alpha.0"
zip = "8.6.0"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
@@ -91,15 +93,15 @@ getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.4.0"
tauri-plugin-notification = "3.0.0-alpha.0"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.6.1"
tauri-plugin-http = "3.0.0-alpha.0"
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" }
tauri-plugin-devtools = { version = "3.0.0-alpha.0", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "feat/tauri-v3" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
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 = [
"client",
] }
arc-swap = "1.9.2"
@@ -114,7 +116,6 @@ tokio-util = { version = "0.7.19", features = ["time"] }
libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
@@ -146,9 +147,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = "2.12.0"
tauri-plugin-autostart = "3.0.0-alpha.0"
tauri-plugin-global-shortcut = "3.0.0-alpha.0"
tauri-plugin-updater = "3.0.0-alpha.0"
[dev-dependencies]
criterion = { workspace = true }
-1
View File
@@ -70,7 +70,6 @@
"dialog:allow-open",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"process:allow-exit",
@@ -1,38 +0,0 @@
#!/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
+6 -21
View File
@@ -133,12 +133,8 @@ pub fn take_dns_override_notice() -> bool {
}
#[tauri::command]
pub async fn set_dns_override(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(profile_uid, enabled, confirmation)
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(enabled, confirmation)
.await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
}
@@ -149,26 +145,16 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
if !dns_path.exists() {
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
logging!(warn, Type::Config, "DNS config file not found");
return Err("DNS config file not found".into());
}
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
logging!(
error,
Type::Config,
"Failed to read DNS config {}: {e}",
dns_path.display()
);
logging!(error, Type::Config, "Failed to read DNS config: {e}");
})?;
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(
error,
Type::Config,
"Failed to parse DNS config {}: {e}",
dns_path.display()
);
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
})?;
let mut patch = serde_yaml_ng::Mapping::new();
@@ -224,8 +210,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
}
// A fragment, not a runnable config; the merged result is validated on apply.
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
.await
.stringify_err()
}
-5
View File
@@ -32,11 +32,6 @@ pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
}
#[tauri::command]
pub fn take_discarded_keys_notice() -> Option<String> {
crate::enhance::take_discarded_keys_notice()
}
#[tauri::command]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
let runtime = Config::runtime().await;
+17 -162
View File
@@ -8,81 +8,26 @@ use crate::{
},
};
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome {
Installed,
#[cfg(any(windows, test))]
Sidecar {
reason: String,
},
}
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
let manager = CoreManager::global();
let result = {
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER.handle_service_status(status).await
};
#[cfg(windows)]
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
#[cfg(not(windows))]
result
.map(|()| ServiceInstallOutcome::Installed)
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER
.handle_service_status(status)
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))
}
#[cfg(any(windows, test))]
async fn finish_service_installation<Fallback, FallbackFuture>(
installation: anyhow::Result<()>,
error_code: &str,
fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome>
where
Fallback: FnOnce() -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{
let Err(error) = installation else {
return Ok(ServiceInstallOutcome::Installed);
};
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
else {
return Err(proxy_aware_coded_error(&error, error_code));
};
let reasons = failure
.status
.cores
.iter()
.filter_map(|core| match &core.availability {
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
_ => None,
})
.collect::<Vec<_>>();
if reasons.is_empty() {
return Err(proxy_aware_coded_error(&error, error_code));
}
let reason = reasons.join("\n");
fallback().await.map_err(|error| {
proxy_aware_coded_error(
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
"SERVICE_SIDECAR_FAILED",
)
})?;
Ok(ServiceInstallOutcome::Sidecar { reason })
}
#[tauri::command]
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
pub async fn install_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
@@ -95,12 +40,12 @@ pub async fn uninstall_service() -> CmdResult {
}
#[tauri::command]
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
pub async fn reinstall_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
pub async fn repair_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
@@ -112,99 +57,9 @@ 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() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error()
}
#[tauri::command]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{ServiceInstallOutcome, finish_service_installation};
use clash_verge_service_ipc::{
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
};
use std::cell::Cell;
#[tokio::test]
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
let reason = "core path C:\\ is writable by Everyone";
for (availability, fallback_ready) in [
(None, false),
(Some(CoreAvailability::Missing), false),
(Some(CoreAvailability::DigestMismatch), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
] {
let fallback_called = Cell::new(false);
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
let error = match availability {
Some(availability) => anyhow::Error::new(InstallationVerificationError {
status: InstallationStatus {
service_sha256: String::new(),
protocol: ProtocolInfo::current(),
cores: vec![CoreInspection {
name: "verge-mihomo.exe".into(),
availability,
}],
core_busy: false,
},
}),
None => anyhow::anyhow!("{reason}"),
}
.context("install service failed");
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
fallback_called.set(true);
anyhow::ensure!(fallback_ready, "another core is running");
Ok(())
})
.await;
assert_eq!(fallback_called.get(), rejected);
if rejected && fallback_ready {
assert_eq!(
result,
Ok(ServiceInstallOutcome::Sidecar {
reason: format!("verge-mihomo.exe: {reason}")
})
);
} else {
let error = result.expect_err("an unavailable fallback must remain an error");
assert_eq!(
error.code.as_deref(),
Some(if rejected {
"SERVICE_SIDECAR_FAILED"
} else {
"SERVICE_INSTALL_FAILED"
})
);
if rejected {
assert!(error.detail.contains(reason));
assert!(error.detail.contains("another core is running"));
}
}
}
}
}
-2
View File
@@ -6,7 +6,6 @@ use crate::{
CoreManager,
handle::{self, Handle},
listener::MIXED_PORT_KEY,
runtime_bundle::resolve_provider_path_conflicts,
tray,
validate::CoreConfigValidator,
},
@@ -229,7 +228,6 @@ impl Config {
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?;
sanitize_tunnels_proxy(&mut config);
// Apply only to generated core config so the saved choice survives the next launch.
if let Some(port) = MixedPort::session_fallback() {
+37 -135
View File
@@ -1,6 +1,5 @@
use anyhow::Result;
use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use smartstring::alias::String;
@@ -55,7 +54,6 @@ pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result
#[derive(Debug, Clone)]
pub(crate) struct DnsOverrideState {
profile_uid: String,
pub source: Option<String>,
pub enabled: bool,
requested: bool,
@@ -63,10 +61,9 @@ pub(crate) struct DnsOverrideState {
}
impl DnsOverrideState {
pub fn new(profile_uid: &str, source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
pub fn new(source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
let enabled = requested && (source.is_none() || source == confirmation);
Self {
profile_uid: profile_uid.into(),
source,
enabled,
requested,
@@ -75,55 +72,24 @@ impl DnsOverrideState {
}
fn apply_to(&self, verge: &mut IVerge) -> bool {
if self.profile_uid.is_empty() {
return false;
}
let settings = verge.dns_settings_for(&self.profile_uid);
// A later settings write must not be overwritten by an earlier runtime generation.
if (settings.enabled, settings.confirmation.as_ref()) != (self.requested, self.confirmation.as_ref()) {
return false;
}
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
if self.enabled == self.requested
&& !clear_confirmation
&& verge.profile_dns_settings.contains_key(&self.profile_uid)
if verge.enable_dns_settings.unwrap_or(false) != self.requested
|| verge.dns_override_confirmation != self.confirmation
{
return false;
}
verge.profile_dns_settings.insert(
self.profile_uid.clone(),
ProfileDnsSettings {
enabled: self.enabled,
confirmation: if clear_confirmation {
None
} else {
self.confirmation.clone()
},
},
);
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
if self.enabled == self.requested && !clear_confirmation {
return false;
}
verge.enable_dns_settings = Some(self.enabled);
if clear_confirmation {
verge.dns_override_confirmation = None;
}
true
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileDnsSettings {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirmation: Option<String>,
}
impl IVerge {
pub(crate) fn dns_settings_for(&self, profile_uid: &str) -> ProfileDnsSettings {
self.profile_dns_settings
.get(profile_uid)
.cloned()
.unwrap_or(ProfileDnsSettings {
enabled: self.enable_dns_settings.unwrap_or(false),
confirmation: None,
})
}
}
impl Config {
pub(crate) async fn sync_dns_override() -> Result<()> {
let _config_write = Self::lock_config_write().await;
@@ -150,7 +116,7 @@ impl Config {
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{DnsOverrideState, ProfileDnsSettings, dns_override_source};
use super::{DnsOverrideState, dns_override_source};
use crate::config::IVerge;
use anyhow::Result;
use serde_yaml_ng::Mapping;
@@ -186,46 +152,35 @@ mod tests {
let reordered =
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
assert_eq!(source, dns_override_source("one", &reordered)?);
assert!(DnsOverrideState::new("one", source.clone(), true, source.clone()).enabled);
assert!(!DnsOverrideState::new("two", dns_override_source("two", &original)?, true, source.clone()).enabled);
assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled);
assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled);
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
assert!(!DnsOverrideState::new("one", dns_override_source("one", &changed)?, true, source).enabled);
assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled);
Ok(())
}
#[test]
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> Result<()> {
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
let source = Some("provider-dns".into());
let confirmed = IVerge {
profile_dns_settings: [(
"one".into(),
ProfileDnsSettings {
enabled: true,
confirmation: source.clone(),
},
)]
.into(),
enable_dns_settings: Some(true),
dns_override_confirmation: source.clone(),
..IVerge::default()
};
let saved = serde_yaml_ng::to_string(&confirmed)?;
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut restarted));
for (saved, source) in [
(saved, Some("updated".into())),
(
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
source,
),
assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled);
for saved in [
serde_yaml_ng::to_string(&confirmed)?,
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
] {
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
let state = DnsOverrideState::new(
source.clone(),
restarted.enable_dns_settings.unwrap_or(false),
restarted.dns_override_confirmation.clone(),
);
assert!(!state.enabled, "a previous session must not bypass startup protection");
assert!(state.apply_to(&mut restarted));
assert!(!restarted.dns_settings_for("one").enabled);
assert_eq!(restarted.enable_dns_settings, Some(false));
}
Ok(())
}
@@ -236,74 +191,21 @@ mod tests {
enable_dns_settings: Some(true),
..IVerge::default()
};
let state = DnsOverrideState::new("one", Some("provider-dns".into()), true, None);
let state = DnsOverrideState::new(Some("provider-dns".into()), true, None);
assert!(state.apply_to(&mut verge));
assert!(!verge.dns_settings_for("one").enabled);
assert_eq!(verge.enable_dns_settings, Some(false));
assert!(!state.apply_to(&mut verge));
let disabled = DnsOverrideState::new("one", None, false, None);
let disabled = DnsOverrideState::new(None, false, None);
assert!(!disabled.apply_to(&mut verge));
assert!(!disabled.enabled);
verge.profile_dns_settings.insert(
"one".into(),
ProfileDnsSettings {
enabled: true,
confirmation: Some("provider-dns".into()),
},
);
verge.enable_dns_settings = Some(true);
verge.dns_override_confirmation = Some("provider-dns".into());
assert!(!state.apply_to(&mut verge));
let settings = verge.dns_settings_for("one");
assert!(settings.enabled);
assert_eq!(settings.confirmation.as_deref(), Some("provider-dns"));
let leaving = DnsOverrideState::new("one", None, true, settings.confirmation);
assert!(leaving.apply_to(&mut verge));
assert!(verge.dns_settings_for("one").confirmation.is_none());
}
#[test]
fn automatic_disable_is_saved_only_for_the_affected_profile() -> Result<()> {
let mut verge = IVerge {
enable_dns_settings: Some(true),
..IVerge::default()
};
let first = DnsOverrideState::new("one", None, true, None);
assert!(first.apply_to(&mut verge));
let protected = DnsOverrideState::new("two", Some("provider-dns".into()), true, None);
assert!(protected.apply_to(&mut verge));
assert!(verge.dns_settings_for("one").enabled);
assert!(!verge.dns_settings_for("two").enabled);
assert!(verge.dns_settings_for("unvisited").enabled);
assert_eq!(verge.enable_dns_settings, Some(true));
let restarted: IVerge = serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&verge)?)?;
assert!(restarted.dns_settings_for("one").enabled);
assert!(!restarted.dns_settings_for("two").enabled);
Ok(())
}
#[test]
fn switching_profiles_preserves_each_confirmation_until_its_source_changes() {
let mut verge = IVerge::default();
for uid in ["one", "two"] {
verge.profile_dns_settings.insert(
uid.into(),
ProfileDnsSettings {
enabled: true,
confirmation: Some(uid.into()),
},
);
}
for uid in ["one", "two", "one"] {
let settings = verge.dns_settings_for(uid);
let state = DnsOverrideState::new(uid, Some(uid.into()), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut verge));
}
let settings = verge.dns_settings_for("two");
let updated = DnsOverrideState::new("two", Some("updated".into()), settings.enabled, settings.confirmation);
assert!(updated.apply_to(&mut verge));
assert!(!verge.dns_settings_for("two").enabled);
assert!(verge.dns_settings_for("two").confirmation.is_none());
assert!(verge.dns_settings_for("one").enabled);
assert_eq!(verge.dns_settings_for("one").confirmation.as_deref(), Some("one"));
assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns"));
let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone());
assert!(leaving.apply_to(&mut verge));
assert!(verge.dns_override_confirmation.is_none());
}
}
+2 -8
View File
@@ -68,14 +68,8 @@ impl ProfileDeletePlan {
return;
};
for file in self.files {
let path = dir.join(file.as_str());
if let Err(error) = path.remove_if_exists().await {
logging!(
warn,
Type::Config,
"Failed to remove deleted profile file {}: {error:#}",
path.display()
);
if let Err(error) = dir.join(file.as_str()).remove_if_exists().await {
logging!(warn, Type::Config, "清理已删除订阅文件失败: {file} - {error}");
}
}
}
+4 -3
View File
@@ -80,11 +80,12 @@ pub struct IVerge {
pub enable_bypass_check: Option<bool>,
/// Initial DNS override preference for profiles without a saved setting.
/// enable dns settings - this controls whether dns_config.yaml is applied
pub enable_dns_settings: Option<bool>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
// Force-enable confirmation is valid only for the current app session.
#[serde(skip)]
pub dns_override_confirmation: Option<String>,
pub use_default_bypass: Option<bool>,
+1
View File
@@ -200,6 +200,7 @@ impl Hotkey {
HotkeyFunction::Hide => {
AsyncHandler::spawn(async move || {
feat::hide().await;
notify_event(NotificationEvent::AppHidden).await;
});
}
}
+14 -171
View File
@@ -62,11 +62,7 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
| ServiceStatus::NeedsReinstall
| ServiceStatus::InstallRequired
| ServiceStatus::Unavailable(_)
| ServiceStatus::SidecarAllowed
) | (
RunningMode::Sidecar,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
)
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
)
}
@@ -189,60 +185,18 @@ where
apply_proxy().await
}
#[cfg(any(target_os = "windows", test))]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
start_service: Start,
confirm_idle: Check,
start_sidecar: Fallback,
record_unavailable: impl FnOnce(std::string::String),
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Check: FnOnce() -> CheckFuture,
CheckFuture: 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(());
};
let refusal = error.downcast_ref::<crate::core::service::ServiceStartRefusal>();
let location_refused = refusal.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if refusal.is_some() && !location_refused {
return Err(error);
}
// A lost start response can hide a running core; the shared check must resolve occupancy.
if let Err(probe_error) = confirm_idle().await {
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
record_unavailable(format!("{error:#}"));
return Err(error);
}
logging!(
warn,
Type::Core,
"Service could not start the core; falling back to Sidecar: {error:#}"
);
let reason = format!("{error:#}");
start_sidecar(error)
.await
.map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
clear_proxy: Clear,
terminate_sidecar: Terminate,
) -> Result<()>
where
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Terminate: FnOnce() -> TerminateFuture,
TerminateFuture: std::future::Future<Output = Result<()>>,
Terminate: FnOnce(),
{
clear_proxy().await?;
terminate_sidecar().await
terminate_sidecar();
Ok(())
}
async fn run_service_config_replacement_transition<
@@ -381,13 +335,7 @@ impl CoreManager {
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
}
#[cfg(target_os = "windows")]
if matches!(*mode, RunningMode::NotRunning) {
clash_verge_service_ipc::execution::check_sidecar_available().await?;
}
if !matches!(status, ServiceStatus::SidecarAllowed) {
SERVICE_MANAGER.allow_sidecar_for_session()?;
}
SERVICE_MANAGER.allow_sidecar_for_session()?;
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
@@ -594,8 +542,6 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
#[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?;
return Ok(());
}
if Handle::global().is_exiting() {
@@ -604,23 +550,7 @@ impl CoreManager {
}
let result = match startup {
StartupDecision::Service => {
#[cfg(target_os = "windows")]
{
run_service_start_with_sidecar_fallback(
|| self.start_core_by_service(),
clash_verge_service_ipc::execution::check_sidecar_available,
|error| self.start_sidecar_after_service_failure(error),
|reason| {
crate::core::runstate::RUN_STATE
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
},
)
.await
}
#[cfg(not(target_os = "windows"))]
self.start_core_by_service().await
}
StartupDecision::Service => self.start_core_by_service().await,
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
StartupDecision::Wait => Ok(()),
};
@@ -639,50 +569,6 @@ impl CoreManager {
result
}
#[cfg(target_os = "windows")]
async fn try_sidecar_after_service_unavailable(&self) -> Result<()> {
use crate::core::runstate::{RUN_STATE, ServiceHealth};
let state = RUN_STATE.state();
if state.pending.is_some() || state.op_in_flight || Handle::global().is_exiting() {
return Ok(());
}
let reason = match state.health {
ServiceHealth::Unavailable(reason) => reason,
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
_ => return Ok(()),
};
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
logging!(
warn,
Type::Core,
"Service unavailable ({reason}); Sidecar fallback withheld: {error:#}"
);
return Ok(());
}
self.start_sidecar_after_service_failure(anyhow::anyhow!(reason)).await
}
#[cfg(target_os = "windows")]
async fn start_sidecar_after_service_failure(&self, error: anyhow::Error) -> Result<()> {
use crate::core::runstate::RUN_STATE;
if Handle::global().is_exiting() {
return Err(error.context("application exited before Sidecar fallback"));
}
let reason = format!("{error:#}");
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
let result = self.start_core_by_sidecar().await;
if result.is_err() {
SERVICE_MANAGER.withdraw_sidecar_allowance();
self.rollback_failed_start().await;
} else {
crate::core::service::notify_service_fallback(&reason);
}
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
}
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
pub async fn stop_core(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
@@ -718,7 +604,10 @@ impl CoreManager {
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::Sidecar => {
self.stop_core_by_sidecar_unprepared();
Ok(())
}
RunningMode::NotRunning => Ok(()),
}
}
@@ -1219,10 +1108,9 @@ mod tests {
let result = run_sidecar_termination_transition(
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|| async {
|| {
sidecar_alive.store(false, Ordering::Release);
calls.lock().push("handoff-sidecar-stop");
Ok(())
},
)
.await;
@@ -1480,18 +1368,12 @@ mod tests {
ServiceStatus::NeedsReinstall,
ServiceStatus::InstallRequired,
ServiceStatus::Unavailable("offline".into()),
ServiceStatus::SidecarAllowed,
];
for status in &allowed_statuses {
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
if !matches!(
status,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
) {
if !matches!(status, ServiceStatus::InstallRequired) {
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
} else {
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
}
}
@@ -1501,52 +1383,13 @@ mod tests {
ServiceStatus::UninstallRequired,
ServiceStatus::ReinstallRequired,
ServiceStatus::ForceReinstallRequired,
ServiceStatus::SidecarAllowed,
];
for status in &rejected_statuses {
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
}
}
#[tokio::test]
async fn blocked_windows_fallback_records_failure_without_starting_sidecar() {
use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth};
for idle in [false, true] {
let store = RunStateStore::new(FakeEnv::new());
store.observe(ServiceHealth::Ready);
let started = AtomicBool::new(false);
let result = super::run_service_start_with_sidecar_fallback(
|| async {
Err(crate::core::service::ServiceStartRefusal {
code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16,
core_path: "verge-mihomo.exe".into(),
message: "no administrator-approved copy is installed".into(),
}
.into())
},
|| async move {
anyhow::ensure!(idle, "another core is running");
Ok(())
},
|_| async {
started.store(true, Ordering::SeqCst);
Ok(())
},
|reason| store.observe(ServiceHealth::Unavailable(reason)),
)
.await;
assert_eq!(started.load(Ordering::SeqCst), idle);
assert_eq!(result.is_ok(), idle);
assert!(!store.state().sidecar_allowed);
if !idle {
assert!(store.state().service_needs_attention());
assert!(!store.state().service_usable());
assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running")));
}
}
}
#[test]
fn failed_service_install_can_continue_with_an_existing_sidecar() {
assert!(can_allow_sidecar_for_session(
-51
View File
@@ -74,14 +74,6 @@ impl fmt::Display for RunningMode {
}
}
/// Why the Core is not running, kept until a Core starts again.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
pub enum CoreFailure {
StartFailed(String),
ServiceCoreStopped(String),
}
#[derive(Debug)]
pub struct CoreManager {
/// The Run State this manager reports transitions to.
@@ -95,8 +87,6 @@ pub struct CoreManager {
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
core_readiness_state: AtomicU64,
startup_error: parking_lot::Mutex<Option<CoreFailure>>,
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
// 串行化 start/stop/restart 和 sidecar→service 交接。
// 锁序固定为 config_update_in_progress → lifecycle_lock。
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
@@ -124,8 +114,6 @@ impl Default for CoreManager {
job_handle: ArcSwapOption::new(None),
config_update_in_progress: AtomicBool::new(false),
core_readiness_state: AtomicU64::new(0),
startup_error: parking_lot::Mutex::new(None),
sidecar_exit: tokio::sync::Mutex::new(None),
lifecycle_lock: tokio::sync::Mutex::new(()),
#[cfg(target_os = "windows")]
handoff_watcher_running: AtomicBool::new(false),
@@ -151,17 +139,6 @@ impl CoreManager {
self.run_state.mode_arc()
}
pub(crate) fn record_startup_error(&self, failure: CoreFailure) {
let mut startup_error = self.startup_error.lock();
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
*startup_error = Some(failure);
}
}
pub(crate) fn get_startup_error(&self) -> Option<CoreFailure> {
self.startup_error.lock().clone()
}
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
self.state
.load()
@@ -187,9 +164,7 @@ impl CoreManager {
if previous != mode {
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
}
let mut startup_error = self.startup_error.lock();
self.run_state.core_started(mode);
*startup_error = None;
}
/// The Core is no longer running, for any reason.
@@ -330,29 +305,3 @@ impl CoreManager {
}
singleton!(CoreManager, CORE_MANAGER);
#[cfg(test)]
mod startup_error_tests {
use super::{CoreFailure, CoreManager, RunningMode};
#[test]
fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() {
for mode in [RunningMode::Sidecar, RunningMode::Service] {
let manager = CoreManager::isolated();
manager.record_startup_error(CoreFailure::StartFailed("startup failed".into()));
manager.core_started(mode);
manager.core_stopped();
assert_eq!(manager.get_startup_error(), None);
}
}
#[test]
fn unresolved_startup_error_survives_repeated_reads() {
let manager = CoreManager::isolated();
let failure = CoreFailure::StartFailed("startup failed".into());
manager.record_startup_error(failure.clone());
for _ in 0..2 {
assert_eq!(manager.get_startup_error().as_ref(), Some(&failure));
}
}
}
+59 -130
View File
@@ -11,67 +11,13 @@ use crate::{
use anyhow::{Context as _, Result};
use clash_verge_logging::Type;
use log::Level;
use scopeguard::defer;
use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
#[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
use serde_yaml_ng::{Mapping, Value};
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
let tun = config
.entry(Value::from("tun"))
.or_insert_with(|| Value::Mapping(Mapping::new()));
if !tun.is_mapping() {
*tun = Value::Mapping(Mapping::new());
}
tun.as_mapping_mut()
.context("invalid Sidecar TUN configuration")?
.insert(Value::from("enable"), Value::Bool(false));
Ok(serde_yaml_ng::to_string(&config)?)
}
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.
@@ -152,25 +98,12 @@ impl CoreManager {
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
self.wait_for_sidecar_exit().await?;
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
.await
.inspect_err(service::record_residual_service)?;
self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?;
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
#[cfg(target_os = "windows")]
let config_file = if crate::core::runstate::RUN_STATE.state().is_admin {
Config::generate_file().await?
} else {
// Reconciliation persists the preference later; the first spawn must already have TUN off.
let yaml = sidecar_config_without_tun(&Config::runtime_config_yaml().await?)?;
Config::write_runtime_file(&yaml).await?
};
#[cfg(not(target_os = "windows"))]
let config_file = Config::generate_file().await?;
let app_handle = handle::Handle::app_handle();
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
@@ -208,8 +141,6 @@ impl CoreManager {
config_dir.display()
)
})?;
let (terminated, termination) = tokio::sync::oneshot::channel();
*self.sidecar_exit.lock().await = Some(execution.release_after_exit(child.pid(), termination));
#[cfg(target_os = "windows")]
let job = {
match create_and_assign_sidecar_job(child.pid()) {
@@ -239,40 +170,6 @@ impl CoreManager {
let pid = child.pid();
tracing::Span::current().record("pid", pid);
// 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();
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy(&line).into_owned();
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let _ = terminated.send(());
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
} else {
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
}
}
});
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
@@ -296,15 +193,48 @@ impl CoreManager {
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
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 {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy(&line).into_owned();
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
} else {
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
}
}
});
Ok(())
}
/// Terminates the sidecar after its caller has successfully cleared the
/// system proxy.
pub(super) async fn stop_core_by_sidecar_unprepared(&self) -> Result<()> {
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
defer! {
self.core_stopped();
}
if let Some(child) = self.take_child_sidecar() {
let pid = child.pid();
@@ -320,22 +250,6 @@ impl CoreManager {
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
}
}
let result = self.wait_for_sidecar_exit().await;
self.core_stopped();
result
}
async fn wait_for_sidecar_exit(&self) -> Result<()> {
let mut exit = self.sidecar_exit.lock().await;
if let Some(task) = exit.as_mut() {
tokio::time::timeout(std::time::Duration::from_secs(5), task)
.await
.context("Sidecar has not exited; core handoff is blocked")?
.context("Sidecar exit monitoring failed")?;
exit.take();
}
drop(exit);
Ok(())
}
pub(super) async fn start_core_by_service(&self) -> Result<()> {
@@ -355,15 +269,30 @@ impl CoreManager {
#[cfg(target_os = "windows")]
{
use crate::constants::timing;
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(())
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")))
}
#[cfg(not(target_os = "windows"))]
+1 -1
View File
@@ -12,7 +12,7 @@ pub(crate) mod owner_identity;
pub mod proxy_control;
pub mod proxy_view;
pub mod runstate;
pub(crate) mod runtime_bundle;
mod runtime_bundle;
pub mod service;
pub mod sysopt;
pub mod timer;
+6 -3
View File
@@ -392,10 +392,13 @@ mod windows_owner {
}
pub(super) fn repair_root_owner(root: &Path) -> Result<()> {
// Elevated Windows processes can create directories owned by Administrators.
std::fs::create_dir_all(root).context("failed to create the application data root before owner repair")?;
// The Service canonicalizes before checking, so resolve junctions to the same object.
let root = std::fs::canonicalize(root).context("failed to canonicalize the application data root")?;
let root = match std::fs::canonicalize(root) {
Ok(path) => path,
// Nothing to repair: this process creates the root and owns it by construction.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error).context("failed to canonicalize the application data root"),
};
let root = root.as_path();
let sid = current_sid()?;
+2 -46
View File
@@ -27,42 +27,8 @@ pub struct RealEnv;
impl RunStateEnv for RealEnv {
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
// The IPC client would otherwise back off for about two minutes before failing.
#[cfg(windows)]
if tokio::task::spawn_blocking(crate::core::service::service_stopped)
.await
.context("service status probe did not finish")??
{
anyhow::bail!("the Windows service is not running");
}
let response = clash_verge_service_ipc::get_version().await?;
let core = if response.code == 0
&& response.data.as_ref().is_some_and(|info| {
info.supports_client(
clash_verge_service_ipc::ProtocolVersion::current(),
clash_verge_service_ipc::MIN_REQUIRED_SERVICE_REVISION,
)
}) {
let name = format!(
"{}{}",
crate::config::Config::verge().await.latest_arc().get_valid_clash_core(),
std::env::consts::EXE_SUFFIX
);
let status = clash_verge_service_ipc::inspect_installation(&[clash_verge_service_ipc::CoreRequirement {
name: name.clone(),
sha256: None,
}])
.await?;
status
.cores
.into_iter()
.find(|core| core.name == name)
.map(|core| core.availability)
} else {
None
};
Ok(ServiceVersionReply {
core,
code: response.code,
message: response.message,
protocol: response.data,
@@ -70,17 +36,9 @@ impl RunStateEnv for RealEnv {
}
async fn trusted_install_evidence(&self) -> Result<bool> {
let registered = tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
.await
.context("service registration probe did not finish")??;
// A helper that outlived its registration is a broken Service, not an absent one.
#[cfg(unix)]
if !registered && let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
return Ok(error
.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>()
.is_some());
}
Ok(registered)
.context("service registration probe did not finish")?
}
fn is_elevated(&self) -> bool {
@@ -161,7 +119,6 @@ mod fake {
#[must_use]
pub fn service_ready(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -171,7 +128,6 @@ mod fake {
#[must_use]
pub fn service_version_mismatch(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: None,
+2 -25
View File
@@ -28,7 +28,7 @@ pub use env::FakeEnv;
pub use env::{RealEnv, RunStateEnv};
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
use crate::core::manager::RunningMode;
use health::StoredService;
@@ -152,10 +152,6 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.observe(ServiceHealth::Ready);
Ok(self.state())
}
ServiceVersionCheck::CoreUnavailable(error) => {
self.observe(ServiceHealth::Unavailable(error.clone()));
bail!(error)
}
ServiceVersionCheck::NeedsReinstall(error) => {
self.observe(ServiceHealth::VersionMismatch);
bail!(error)
@@ -182,10 +178,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
match self.env.probe_service_version().await {
Ok(reply) => match classify_service_version_reply(&reply) {
ServiceVersionCheck::CoreUnavailable(reason) => ServiceHealth::Unavailable(reason),
_ => classify_service_health(probe_outcome(&reply), has_marker, ""),
},
Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""),
Err(error) => {
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
classify_service_health(
@@ -330,21 +323,6 @@ 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();
@@ -494,7 +472,6 @@ mod tests {
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
+13 -50
View File
@@ -4,9 +4,7 @@
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
//! systemd, SCM or launchd.
use clash_verge_service_ipc::{
MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion, VERSION as SERVICE_VERSION,
};
use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion};
use super::health::ServiceHealth;
@@ -16,7 +14,6 @@ pub struct ServiceVersionReply {
pub code: u16,
pub message: String,
pub protocol: Option<ProtocolInfo>,
pub core: Option<clash_verge_service_ipc::CoreAvailability>,
}
/// The verdict on a [`ServiceVersionReply`].
@@ -24,12 +21,8 @@ pub struct ServiceVersionReply {
pub enum ServiceVersionCheck {
Ready,
NeedsReinstall(String),
CoreUnavailable(String),
}
/// Leads the Unavailable reason when the Service refused the approved core itself.
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
/// What a live probe of the currently-installed Service told us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentServiceProbe {
@@ -39,30 +32,22 @@ pub enum CurrentServiceProbe {
Unavailable,
}
/// Require the bundled Service version as well as a usable protocol.
/// Decide whether the Service we reached speaks a protocol this client can use.
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
let client = ProtocolVersion::current();
if reply.code == 0
&& reply.protocol.as_ref().is_some_and(|info| {
info.build_version == SERVICE_VERSION && info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)
})
&& reply
.protocol
.as_ref()
.is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION))
{
return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
),
};
return ServiceVersionCheck::Ready;
}
let detail = if reply.code == 0 {
match reply.protocol.as_ref() {
Some(info) => format!(
"client requires service version {} with epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
SERVICE_VERSION,
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
client.epoch,
MIN_REQUIRED_SERVICE_REVISION,
info.protocol.epoch,
@@ -78,16 +63,16 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
)
};
ServiceVersionCheck::NeedsReinstall(format!(
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
))
}
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
match classify_service_version_reply(reply) {
ServiceVersionCheck::Ready => CurrentServiceProbe::Ready,
ServiceVersionCheck::NeedsReinstall(_) => CurrentServiceProbe::VersionMismatch,
ServiceVersionCheck::CoreUnavailable(_) => CurrentServiceProbe::Unavailable,
if classify_service_version_reply(reply) == ServiceVersionCheck::Ready {
CurrentServiceProbe::Ready
} else {
CurrentServiceProbe::VersionMismatch
}
}
@@ -117,7 +102,6 @@ mod tests {
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -133,26 +117,9 @@ mod tests {
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
}
#[test]
fn compatible_service_with_missing_core_is_unavailable() {
for core in [
None,
Some(clash_verge_service_ipc::CoreAvailability::Missing),
Some(clash_verge_service_ipc::CoreAvailability::DigestMismatch),
] {
let reply = ServiceVersionReply { core, ..ready_reply() };
assert!(matches!(
classify_service_version_reply(&reply),
ServiceVersionCheck::CoreUnavailable(_)
));
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::Unavailable);
}
}
#[test]
fn a_reply_without_protocol_information_needs_reinstall() {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: None,
@@ -170,7 +137,6 @@ mod tests {
#[test]
fn a_nonzero_code_needs_reinstall() {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 7,
message: "nope".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -186,7 +152,6 @@ mod tests {
let mut info = ProtocolInfo::current();
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
@@ -207,7 +172,6 @@ mod tests {
let mut info = ProtocolInfo::current();
info.protocol.revision = revision;
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
@@ -226,7 +190,6 @@ mod tests {
for protocol in [None, Some(old_epoch)] {
for code in [0, 1] {
let reply = ServiceVersionReply {
core: None,
code,
message: "unsupported command".to_owned(),
protocol: protocol.clone(),
+2 -176
View File
@@ -2,13 +2,11 @@ use anyhow::{Context as _, Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
"ASN.mmdb",
"geoip.dat",
"geosite.dat",
"geoip.metadb",
@@ -55,94 +53,6 @@ pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Resul
Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers)
}
#[derive(Default)]
struct ProviderPathOwners {
remote: BTreeMap<String, Vec<(&'static str, Value)>>,
local: bool,
}
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
let config_root = std::fs::canonicalize(config_root)?;
let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
for section in ["proxy-providers", "rule-providers"] {
let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
continue;
};
for (name, provider) in providers {
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
continue;
};
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
let destination = if is_remote {
provider_destination(&config_root, raw_path)
} else {
local_provider_source(&config_root, raw_path)
.and_then(|source| destination_below_root(&config_root, &source))
.or_else(|_| provider_destination(&config_root, raw_path))
};
let Ok(destination) = destination else {
continue;
};
reserved.insert(destination.clone());
let owners = paths.entry(destination).or_default();
match (is_remote, provider.get("url").and_then(Value::as_str)) {
(true, Some(url)) => owners
.remote
.entry(url.to_owned())
.or_default()
.push((section, name.clone())),
_ => owners.local = true,
}
}
}
for (destination, owners) in paths {
if owners.local || owners.remote.len() < 2 {
continue;
}
// Allocate every source in a conflict group, so declaration order cannot select a cache's owner.
for (url, providers) in owners.remote {
let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
for (section, name) in providers {
config[section][&name]["path"] = Value::String(replacement.clone());
logging!(
warn,
Type::Config,
"provider {section}/{name:?} shared cache {destination:?} with another source; using {replacement:?}"
);
}
}
}
Ok(())
}
fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut HashSet<String>) -> String {
let mut digest = Sha256::new();
digest.update(destination.as_bytes());
digest.update([0]);
digest.update(url.as_bytes());
let digest: String = digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
let path = Path::new(destination);
let extension = path
.extension()
.map(|ext| format!(".{}", ext.to_string_lossy()))
.unwrap_or_default();
let mut suffix = String::new();
let mut sequence = 0;
loop {
let candidate = path
.with_file_name(format!("cvr-{digest}{suffix}{extension}"))
.to_string_lossy()
.replace('\\', "/");
if reserved.insert(candidate.clone()) {
return candidate;
}
sequence += 1;
suffix = format!("-{sequence}");
}
}
fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<RuntimeParts> {
let config_root = std::fs::canonicalize(config_root)?;
let mut assets = Vec::new();
@@ -344,91 +254,7 @@ fn normalized_destination(relative: &Path) -> Result<String> {
#[cfg(test)]
mod tests {
use super::{collect_runtime_bundle, remote_providers_of, resolve_provider_path_conflicts};
#[tokio::test]
async fn conflicting_sources_get_separate_paths_in_config_bundle_and_readback() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("clash-verge-provider-paths-{}", std::process::id()));
std::fs::create_dir_all(&root)?;
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"rule-providers:\n AppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: ./providers/rule/AppleDev.yaml\n mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
)?;
let original = config.clone();
let root = std::fs::canonicalize(root)?;
let mut reordered = config.clone();
reordered["rule-providers"] = serde_yaml_ng::from_str(&format!(
"mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: {}\nAppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
root.join("providers/rule/AppleDev.yaml").display(),
))?;
resolve_provider_path_conflicts(&mut config, &root)?;
let config_file = root.join("config.yaml");
std::fs::write(&config_file, serde_yaml_ng::to_string(&config)?)?;
let bundle = collect_runtime_bundle(&config_file, &root.join("mihomo")).await?;
let paths = &config["rule-providers"];
assert_ne!(paths["AppleDev"]["path"], paths["mirror"]["path"]);
resolve_provider_path_conflicts(&mut reordered, &root)?;
assert_eq!(
config, reordered,
"path allocation must not depend on declaration order or path spelling"
);
let resolved = config.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
assert_eq!(
config, resolved,
"resolving an already generated config must be idempotent"
);
assert_eq!(serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&bundle.yaml)?, config);
let readback = remote_providers_of(&config, &root)?;
assert_eq!(bundle.remote_providers.len(), 2);
for declared in readback {
assert!(bundle.remote_providers.contains(&declared.provider));
assert_eq!(
config[declared.section][&declared.name]["path"],
declared.provider.destination
);
assert_eq!(
config[declared.section][&declared.name]["url"],
original[declared.section][&declared.name]["url"]
);
}
std::fs::remove_dir_all(root)?;
Ok(())
}
#[test]
fn allocated_provider_paths_do_not_claim_another_declared_path() -> anyhow::Result<()> {
let root = std::env::temp_dir();
let original: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"proxy-providers:\n a:\n type: http\n url: https://one.example/p.yaml\n path: ./providers/shared.yaml\nrule-providers:\n b:\n type: http\n url: https://two.example/r.yaml\n path: ./providers/shared.yaml\n",
)?;
let mut config = original.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
let allocated = config["proxy-providers"]["a"]["path"].clone();
let mut config = original;
config["rule-providers"]["reserved"] = serde_yaml_ng::from_str("type: file\npath: placeholder")?;
config["rule-providers"]["reserved"]["path"] = allocated.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
assert_ne!(config["proxy-providers"]["a"]["path"], allocated);
assert_ne!(
config["proxy-providers"]["a"]["path"],
config["rule-providers"]["b"]["path"]
);
assert_eq!(config["rule-providers"]["reserved"]["path"], allocated);
Ok(())
}
#[test]
fn provider_path_resolution_leaves_shared_sources_and_local_files_unchanged() -> anyhow::Result<()> {
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"rule-providers:\n a:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n b:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n local:\n type: file\n path: ./rules/local.yaml\n remote:\n type: http\n url: https://two.example/r.yaml\n path: ./rules/local.yaml\n mirror:\n type: http\n url: https://three.example/r.yaml\n path: ./rules/local.yaml\n",
)?;
let original = config.clone();
resolve_provider_path_conflicts(&mut config, &std::env::temp_dir())?;
assert_eq!(config, original);
Ok(())
}
use super::collect_runtime_bundle;
#[tokio::test]
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
File diff suppressed because it is too large Load Diff
+34 -32
View File
@@ -24,7 +24,7 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::time::Duration;
use tauri::{
AppHandle, Wry,
AppHandle, DynRuntime,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
@@ -35,7 +35,7 @@ use menu_def::{MenuIds, MenuTexts};
// TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑)
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
type ProxyMenuItem = (Option<Submenu<DynRuntime>>, Vec<Box<dyn IsMenuItem<DynRuntime>>>);
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
pub const TRAY_ID: &str = "clash-verge-rev-tray";
@@ -440,7 +440,7 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
fn create_profile_menu_item(
app_handle: &AppHandle,
profiles_preview: Vec<IProfilePreview<'_>>,
) -> Result<Vec<CheckMenuItem<Wry>>> {
) -> Result<Vec<CheckMenuItem<DynRuntime>>> {
profiles_preview
.into_iter()
.map(|profile| {
@@ -462,9 +462,9 @@ fn create_subcreate_proxy_menu_item(
proxy_mode: &str,
proxy_group_order_map: Option<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
) -> Vec<Submenu<Wry>> {
let proxy_submenus: Vec<Submenu<Wry>> = {
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new();
) -> Vec<Submenu<DynRuntime>> {
let proxy_submenus: Vec<Submenu<DynRuntime>> = {
let mut submenus: Vec<(String, usize, Submenu<DynRuntime>)> = Vec::new();
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
if let Some(proxy_nodes_data) = proxy_nodes_data {
@@ -484,7 +484,7 @@ fn create_subcreate_proxy_menu_item(
let now_proxy = group_data.now.as_deref().unwrap_or_default();
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
let group_items: Vec<CheckMenuItem<DynRuntime>> = all_proxies
.iter()
.filter_map(|proxy_str| {
let is_selected = *proxy_str == now_proxy;
@@ -515,8 +515,10 @@ fn create_subcreate_proxy_menu_item(
let group_display_name = group_name.to_string();
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
let group_items_refs: Vec<&dyn IsMenuItem<DynRuntime>> = group_items
.iter()
.map(|item| item as &dyn IsMenuItem<DynRuntime>)
.collect();
if let Ok(submenu) = Submenu::with_id_and_items(
app_handle,
@@ -552,7 +554,7 @@ fn create_subcreate_proxy_menu_item(
fn create_proxy_menu_item(
app_handle: &AppHandle,
show_proxy_groups_inline: bool,
proxy_submenus: Vec<Submenu<Wry>>,
proxy_submenus: Vec<Submenu<DynRuntime>>,
proxies_text: &str,
) -> Result<ProxyMenuItem> {
let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline {
@@ -560,13 +562,13 @@ fn create_proxy_menu_item(
None,
proxy_submenus
.into_iter()
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<Wry>>)
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<DynRuntime>>)
.collect(),
)
} else if !proxy_submenus.is_empty() {
let proxy_submenu_refs: Vec<&dyn IsMenuItem<Wry>> = proxy_submenus
let proxy_submenu_refs: Vec<&dyn IsMenuItem<DynRuntime>> = proxy_submenus
.iter()
.map(|submenu| submenu as &dyn IsMenuItem<Wry>)
.map(|submenu| submenu as &dyn IsMenuItem<DynRuntime>)
.collect();
(
@@ -593,7 +595,7 @@ async fn create_tray_menu(
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> {
) -> Result<tauri::menu::Menu<DynRuntime>> {
let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc();
@@ -647,12 +649,12 @@ async fn create_tray_menu(
let hotkeys = create_hotkeys(&verge_settings.hotkeys);
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?;
let profile_menu_items: Vec<CheckMenuItem<DynRuntime>> = create_profile_menu_item(app_handle, profiles_preview)?;
let texts = MenuTexts::new();
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
let profile_menu_items_refs: Vec<&dyn IsMenuItem<DynRuntime>> = profile_menu_items
.iter()
.map(|item| item as &dyn IsMenuItem<Wry>)
.map(|item| item as &dyn IsMenuItem<DynRuntime>)
.collect();
let open_window = &MenuItem::with_id(
@@ -705,9 +707,9 @@ async fn create_tray_menu(
outbound_modes_label.as_str(),
true,
&[
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
rule_mode as &dyn IsMenuItem<DynRuntime>,
global_mode as &dyn IsMenuItem<DynRuntime>,
direct_mode as &dyn IsMenuItem<DynRuntime>,
],
)?)
};
@@ -812,7 +814,7 @@ async fn create_tray_menu(
&texts.more,
true,
&[
copy_env as &dyn IsMenuItem<Wry>,
copy_env as &dyn IsMenuItem<DynRuntime>,
close_all_connections,
restart_clash,
restart_app,
@@ -829,13 +831,13 @@ async fn create_tray_menu(
let separator = &PredefinedMenuItem::separator(app_handle)?;
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, separator];
let mut menu_items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![open_window, separator];
if show_outbound_modes_inline {
menu_items.extend_from_slice(&[
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
rule_mode as &dyn IsMenuItem<DynRuntime>,
global_mode as &dyn IsMenuItem<DynRuntime>,
direct_mode as &dyn IsMenuItem<DynRuntime>,
]);
} else if let Some(ref outbound_modes) = outbound_modes {
menu_items.push(outbound_modes);
@@ -845,7 +847,7 @@ async fn create_tray_menu(
match tray_proxy_groups_display_mode {
"default" => {
menu_items.extend(proxies_menu.iter().map(|item| item as &dyn IsMenuItem<_>));
menu_items.extend(proxies_menu.iter().map(|item| item as &dyn IsMenuItem<DynRuntime>));
}
"inline" if !inline_proxy_items.is_empty() => {
menu_items.extend(inline_proxy_items.iter().map(|item| item.as_ref()));
@@ -855,14 +857,14 @@ async fn create_tray_menu(
menu_items.extend_from_slice(&[
separator,
system_proxy as &dyn IsMenuItem<Wry>,
tun_mode as &dyn IsMenuItem<Wry>,
system_proxy as &dyn IsMenuItem<DynRuntime>,
tun_mode as &dyn IsMenuItem<DynRuntime>,
separator,
lightweight_mode as &dyn IsMenuItem<Wry>,
open_dir as &dyn IsMenuItem<Wry>,
more as &dyn IsMenuItem<Wry>,
lightweight_mode as &dyn IsMenuItem<DynRuntime>,
open_dir as &dyn IsMenuItem<DynRuntime>,
more as &dyn IsMenuItem<DynRuntime>,
separator,
quit as &dyn IsMenuItem<Wry>,
quit as &dyn IsMenuItem<DynRuntime>,
]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
+14 -29
View File
@@ -1,5 +1,5 @@
use crate::{config::Config, singleton, utils::dirs};
use anyhow::{Context as _, Result};
use anyhow::Result;
use chrono::Utc;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
@@ -47,20 +47,17 @@ impl SilentUpdater {
fn write_cache(bytes: &[u8], version: &str) -> Result<()> {
let cache_dir = Self::cache_dir()?;
std::fs::create_dir_all(&cache_dir)
.with_context(|| format!("failed to create update cache directory {}", cache_dir.display()))?;
std::fs::create_dir_all(&cache_dir)?;
let bin_path = cache_dir.join("pending_update.bin");
std::fs::write(&bin_path, bytes)
.with_context(|| format!("failed to write update cache {}", bin_path.display()))?;
std::fs::write(&bin_path, bytes)?;
let meta = UpdateCacheMeta {
version: version.to_string(),
downloaded_at: Utc::now().to_rfc3339(),
};
let meta_path = cache_dir.join("pending_update.json");
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)
.with_context(|| format!("failed to write update metadata {}", meta_path.display()))?;
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
logging!(
info,
@@ -74,7 +71,7 @@ impl SilentUpdater {
fn read_cache_bytes() -> Result<Vec<u8>> {
let bin_path = Self::cache_dir()?.join("pending_update.bin");
std::fs::read(&bin_path).with_context(|| format!("failed to read update cache {}", bin_path.display()))
Ok(std::fs::read(bin_path)?)
}
fn read_cache_meta() -> Result<UpdateCacheMeta> {
@@ -88,12 +85,7 @@ impl SilentUpdater {
&& cache_dir.exists()
{
if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
logging!(
warn,
Type::System,
"Failed to delete update cache {}: {e}",
cache_dir.display()
);
logging!(warn, Type::System, "Failed to delete update cache: {e}");
} else {
logging!(info, Type::System, "Update cache deleted");
}
@@ -101,22 +93,15 @@ impl SilentUpdater {
}
}
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
return false;
};
!build.is_empty() && !base.contains('-') && base == remote.trim_start_matches('v')
}
/// Compares numeric version components after stripping version suffixes.
/// Compares numeric version components after stripping `v` and prerelease suffixes.
fn version_lte(a: &str, b: &str) -> bool {
let parse = |v: &str| -> Vec<u64> {
v.trim_start_matches('v')
.split(['-', '+'])
.next()
.unwrap_or("0")
.split('.')
.filter_map(|part| part.parse::<u64>().ok())
.filter_map(|part| {
let numeric = part.split('-').next().unwrap_or("0");
numeric.parse::<u64>().ok()
})
.collect()
};
@@ -159,7 +144,7 @@ impl SilentUpdater {
let cached_version = &meta.version;
if !is_build_to_stable(current_version, cached_version) && version_lte(cached_version, current_version) {
if version_lte(cached_version, current_version) {
logging!(
info,
Type::System,
@@ -191,7 +176,7 @@ impl SilentUpdater {
logging!(
warn,
Type::System,
"Failed to read cached update bytes: {e:#}, cleaning up"
"Failed to read cached update bytes: {e}, cleaning up"
);
Self::delete_cache();
return false;
@@ -471,7 +456,7 @@ impl SilentUpdater {
.await?;
if let Err(e) = Self::write_cache(&bytes, &version) {
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
logging!(warn, Type::System, "Silent updater: failed to write cache: {e}");
}
*self.pending_bytes.write() = Some(bytes);
+4 -4
View File
@@ -226,7 +226,7 @@ impl CoreConfigValidator {
Ok(content) => content,
Err(err) => {
let error_msg: String = format!("Failed to read file: {err}").into();
logging!(error, Type::Validate, "Failed to read file {config_path}: {err}");
logging!(error, Type::Validate, "无法读取文件: {}", error_msg);
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
@@ -238,7 +238,7 @@ impl CoreConfigValidator {
}
Err(err) => {
let error_msg: String = format!("YAML syntax error: {err}").into();
logging!(error, Type::Validate, "YAML syntax error in {config_path}: {err}");
logging!(error, Type::Validate, "YAML语法错误: {}", error_msg);
Ok(ValidationOutcome::invalid_from_message(error_msg))
}
}
@@ -249,7 +249,7 @@ impl CoreConfigValidator {
Ok(content) => content,
Err(err) => {
let error_msg: String = format!("Failed to read script file: {err}").into();
logging!(warn, Type::Validate, "Failed to read script file {path}: {err}");
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
@@ -291,7 +291,7 @@ impl CoreConfigValidator {
}
Err(err) => {
let error_msg: String = format!("Script syntax error: {err}").into();
logging!(warn, Type::Validate, "Script syntax error in {path}: {err}");
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
Ok(ValidationOutcome::invalid_from_message(error_msg))
}
}
+17 -45
View File
@@ -1,3 +1,5 @@
use clash_verge_logging::{Type, logging};
use super::field::use_lowercase;
use serde_yaml_ng::{self, Mapping, Value};
@@ -16,19 +18,20 @@ fn deep_merge(a: &mut Value, b: Value) {
}
}
pub fn use_merge(merge: &Mapping, mut config: Mapping) -> Mapping {
for (key, value) in use_lowercase(merge) {
match (config.get_mut(&key), value) {
(Some(Value::Mapping(existing)), Value::Mapping(overlay)) if key.as_str() == Some("dns") => {
existing.extend(overlay);
}
(Some(existing), value) if key.as_str() != Some("hosts") => deep_merge(existing, value),
(_, value) => {
config.insert(key, value);
}
}
}
config
pub fn use_merge(merge: &Mapping, config: Mapping) -> Mapping {
let mut config = Value::from(config);
let merge = use_lowercase(merge);
deep_merge(&mut config, Value::from(merge));
config.as_mapping().cloned().unwrap_or_else(|| {
logging!(
error,
Type::Core,
"Failed to convert merged config to mapping, using empty mapping"
);
Mapping::new()
})
}
#[test]
@@ -51,49 +54,18 @@ fn test_merge() -> anyhow::Result<()> {
enable: true
dns:
enable: true
nameserver-policy:
new.example: 1.1.1.1
fallback-filter:
geoip: false
hosts:
new.example: 192.0.2.2
";
let config = r"
rules:
- aaaaa
script1: test
tun:
mtu: 1500
dns:
nameserver: [9.9.9.9]
nameserver-policy:
old.example: 8.8.8.8
fallback-filter:
geoip: true
domain: [old.example]
hosts:
old.example: 192.0.2.1
";
let merge = serde_yaml_ng::from_str::<Mapping>(merge)?;
let config = serde_yaml_ng::from_str::<Mapping>(config)?;
let result = use_merge(&merge, config);
assert_eq!(result["dns"]["nameserver-policy"], merge["dns"]["nameserver-policy"]);
assert_eq!(result["dns"]["fallback-filter"], merge["dns"]["fallback-filter"]);
assert_eq!(result["hosts"], merge["hosts"]);
assert_eq!(result["dns"]["nameserver"], serde_yaml_ng::to_value(["9.9.9.9"])?);
assert_eq!(result["tun"]["mtu"], Value::from(1500));
assert_eq!(result["rules"], merge["rules"]);
assert_eq!(result["script1"], Value::from("test"));
let cleared = serde_yaml_ng::from_str::<Mapping>(
"dns: {enable: false, nameserver-policy: {}, fallback-filter: {}, nameserver: []}\nhosts: {}",
)?;
let result = use_merge(&cleared, result);
assert_eq!(result["dns"], cleared["dns"]);
assert_eq!(result["hosts"], cleared["hosts"]);
let _ = serde_yaml_ng::to_string(&use_merge(&merge, config))?;
Ok(())
}
+97 -322
View File
@@ -14,7 +14,6 @@ use self::{
tun::use_tun,
};
use crate::config::dns::{DnsOverrideState, dns_override_source};
use crate::core::handle::Handle;
use crate::utils::dirs;
use crate::{
config::{Config, IProfiles, IVerge, PrfItem},
@@ -23,7 +22,6 @@ use crate::{
};
use anyhow::{Context as _, Result};
use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
@@ -105,7 +103,7 @@ async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce
}
}
async fn get_config_values(profile_uid: &str) -> ConfigValues {
async fn get_config_values() -> ConfigValues {
let clash = Config::clash().await;
let clash_arc = clash.latest_arc();
let clash_config = clash_arc.0.clone();
@@ -120,12 +118,12 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
ref enable_builtin_enhanced,
ref verge_socks_enabled,
ref verge_http_enabled,
ref enable_dns_settings,
ref enable_external_controller,
..
} = **verge_arc;
let enable_external_controller = enable_external_controller.unwrap_or(false);
let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation;
let dns_override_confirmation = verge_arc.dns_override_confirmation.clone();
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
@@ -133,7 +131,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
verge_http_enabled.unwrap_or(false),
dns_settings.enabled,
enable_dns_settings.unwrap_or(false),
);
#[cfg(not(target_os = "windows"))]
@@ -249,33 +247,16 @@ async fn process_global_items(
global_merge: ChainItem,
global_script: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(global_merge.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = global_script.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(global_script.uid, logs);
}
@@ -303,7 +284,8 @@ fn process_seq_items(
config
}
/// App-owned control-plane keys; DNS settings are captured separately.
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
const CONTROL_PLANE_KEYS: &[&str] = &[
"external-controller",
#[cfg(unix)]
@@ -326,117 +308,32 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
"unified-delay",
];
/// App-owned fields: captured after the app derives them, enforced after every override.
/// 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.
struct AuthoritativeFields {
control_plane: Mapping,
tun: Mapping,
dns: Mapping,
hosts: Option<Value>,
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
dns_ipv6: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], mut dns_settings: Mapping) -> Self {
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys),
dns: take_mapping(&mut dns_settings, "dns"),
hosts: dns_settings.remove("hosts"),
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
}
}
fn enforce(self, config: Mapping) -> Mapping {
let config = enforce_control_plane(config, self.control_plane);
let mut config = enforce_tun(config, self.tun);
if !self.dns.is_empty() {
let mut dns = take_mapping(&mut config, "dns");
dns.extend(self.dns);
config.insert("dns".into(), dns.into());
}
if let Some(hosts) = self.hosts {
config.insert("hosts".into(), hosts);
}
config
}
/// The owned values as `config` holds them now, for diffing one override.
fn current(&self, config: &Mapping) -> Self {
let tun_keys: Vec<Value> = self.tun.keys().cloned().collect();
let dns = config.get("dns").and_then(Value::as_mapping);
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, &tun_keys),
dns: self
.dns
.keys()
.filter_map(|key| Some((key.clone(), dns?.get(key)?.clone())))
.collect(),
hosts: self.hosts.as_ref().and_then(|_| config.get("hosts")).cloned(),
}
}
/// Owned keys an override moved off the app value; `enforce` discards those writes.
fn overridden(&self, before: &Self, after: &Self) -> Vec<String> {
let mut keys = Vec::new();
for &key in CONTROL_PLANE_KEYS {
if before.control_plane.get(key) != after.control_plane.get(key)
&& after.control_plane.get(key) != self.control_plane.get(key)
{
keys.push(key.into());
}
}
for key in self.tun.keys() {
if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) {
keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into());
}
}
for key in self.dns.keys() {
if before.dns.get(key) != after.dns.get(key) && after.dns.get(key) != self.dns.get(key) {
keys.push(format!("dns.{}", key.as_str().unwrap_or_default()).into());
}
}
if self.hosts.is_some() && before.hosts != after.hosts && after.hosts != self.hosts {
keys.push("hosts".into());
}
keys
}
}
fn discarded_note(key: String) -> (String, String) {
(
"warn".into(),
format!("`{key}` is managed by Settings; the value written here was discarded").into(),
)
}
struct DiscardedKeysNotice {
pending: Option<String>,
last: Vec<String>,
}
static DISCARDED_KEYS_NOTICE: Mutex<DiscardedKeysNotice> = Mutex::new(DiscardedKeysNotice {
pending: None,
last: Vec::new(),
});
pub(crate) fn take_discarded_keys_notice() -> Option<String> {
DISCARDED_KEYS_NOTICE.lock().pending.take()
}
/// One notice per distinct set, so regenerations with unchanged extensions stay quiet.
fn notify_discarded_keys(keys: Vec<String>) {
let mut notice = DISCARDED_KEYS_NOTICE.lock();
if notice.last == keys {
return;
}
let should_notify = !keys.is_empty();
if should_notify {
notice.pending = Some(keys.join(", ").into());
}
notice.last = keys;
drop(notice);
if should_notify {
Handle::notice_message("enhance::discarded_keys", "");
let config = enforce_tun(config, self.tun);
enforce_dns_ipv6(config, self.dns_ipv6)
}
}
@@ -504,6 +401,21 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
config
}
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned()
}
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> Mapping {
if let Some(dns_ipv6) = dns_ipv6
&& let Some(Value::Mapping(dns)) = config.get_mut("dns")
{
dns.insert(Value::from("ipv6"), dns_ipv6);
}
config
}
fn is_loopback_bind_address(addr: &str) -> bool {
let addr = addr.trim();
let addr = addr
@@ -553,40 +465,27 @@ async fn process_profile_items(
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
/// 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.
fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
@@ -784,7 +683,9 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
config
}
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value;
@@ -807,78 +708,45 @@ fn ensure_fake_ip_range6(dns: &mut 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,
}
}
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()
}
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, Mapping) {
let mut applied = Mapping::new();
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts")
&& !hosts.is_empty()
{
applied.insert("hosts".into(), hosts.clone().into());
config.insert("hosts".into(), hosts.into());
logging!(debug, Type::Core, "apply hosts configuration");
}
// 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(mut dns_override) = dns_override {
dns_override.retain(|_, value| is_set(value));
if !dns_override.is_empty() {
applied.insert("dns".into(), dns_override.clone().into());
}
let mut dns = take_mapping(&mut config, "dns");
dns.extend(dns_override);
ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
(config, applied)
}
/// Returns the merged config and the non-empty fields owned by the DNS page.
async fn apply_dns_settings(config: Mapping, enable_dns_settings: bool) -> (Mapping, Mapping) {
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
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::<Mapping>(&dns_yaml)
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
{
return merge_dns_config(config, dns_config);
if let Some(hosts_value) = dns_config.get("hosts")
&& hosts_value.is_mapping()
{
config.insert("hosts".into(), hosts_value.clone());
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());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
}
}
(config, Mapping::new())
config
}
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
pub async fn enhance(
profiles: &IProfiles,
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
let profile_uid = profiles.current.as_deref().unwrap_or_default();
let cfg_vals = get_config_values(profile_uid).await;
let cfg_vals = get_config_values().await;
let ConfigValues {
clash_config,
clash_core,
@@ -897,8 +765,7 @@ pub async fn enhance(
let profile = collect_profile_items(profiles).await?;
let dns_override = DnsOverrideState::new(
profile_uid,
dns_override_source(profile_uid, &profile.config)?,
dns_override_source(profiles.current.as_deref().unwrap_or_default(), &profile.config)?,
enable_dns_settings,
dns_override_confirmation,
);
@@ -933,9 +800,9 @@ pub async fn enhance(
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
let config = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, enable_dns_settings);
let (config, exists_keys, result_map) = process_global_items(
config,
@@ -944,22 +811,12 @@ pub async fn enhance(
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, result_map) = process_profile_items(
config,
exists_keys,
result_map,
merge_item,
script_item,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, result_map) =
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
let config = authoritative.enforce(config);
let config = ensure_lan_bind_address(config);
@@ -1007,7 +864,8 @@ mod fake_ip_tests {
#[test]
fn a_hand_edited_empty_range_counts_as_missing() {
// Hand-edited YAML may leave the key present but empty.
// The reason this is not just a `contains_key` check: YAML edited by hand often
// leaves the key present with nothing after the colon.
for blank in ["", " "] {
let mut config = dns(&[
("ipv6", Value::from(true)),
@@ -1146,7 +1004,8 @@ mod use_tun_tests {
#[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() {
// TUN off must not rewrite the DNS the profile chose.
// 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.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false);
@@ -1179,7 +1038,8 @@ mod use_sort_tests {
#[test]
fn the_bulky_list_fields_are_written_last() {
// Bulky list fields go last so the top of the file stays readable.
// 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.
let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule"));
@@ -1269,7 +1129,8 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
// 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.
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))]);
@@ -1291,7 +1152,8 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
// Blanked, not removed: an absent key makes mihomo listen on its default.
// 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.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false);
@@ -1361,7 +1223,7 @@ mod authoritative_field_tests {
#[test]
fn an_override_cannot_change_a_field_the_app_owns() {
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
let result = authoritative.enforce(overridden);
@@ -1374,7 +1236,7 @@ mod authoritative_field_tests {
fn an_override_cannot_introduce_a_field_the_app_left_out() {
// The app decided not to expose the external controller; a profile must not re-add it.
let derived = Mapping::new();
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
let result = authoritative.enforce(overridden);
@@ -1385,7 +1247,7 @@ mod authoritative_field_tests {
#[test]
fn fields_the_app_does_not_own_survive_an_override() {
let derived = config_with(&[("mode", Value::from("rule"))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
let result = authoritative.enforce(overridden);
@@ -1397,7 +1259,7 @@ mod authoritative_field_tests {
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let owned = AuthoritativeFields::capture(&derived, &[], derived.clone());
let owned = AuthoritativeFields::capture(&derived, &[], true);
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
@@ -1405,7 +1267,7 @@ mod authoritative_field_tests {
"with the DNS page on, the app's value wins"
);
let unowned = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let unowned = AuthoritativeFields::capture(&derived, &[], false);
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
@@ -1415,13 +1277,14 @@ mod authoritative_field_tests {
}
#[test]
fn restoring_dns_settings_reinstates_a_removed_dns_block() {
fn restoring_dns_ipv6_never_invents_a_dns_block() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], derived.clone());
let authoritative = AuthoritativeFields::capture(&derived, &[], true);
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
let result = authoritative.enforce(Mapping::new());
assert_eq!(result["dns"], derived["dns"]);
assert!(!result.contains_key(Value::from("dns")));
}
}
@@ -1429,8 +1292,8 @@ mod authoritative_field_tests {
#[cfg(test)]
mod tests {
use super::{
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
process_global_items, process_profile_items, use_keys,
ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
process_profile_items, use_keys,
};
use std::collections::HashMap;
@@ -1438,89 +1301,6 @@ mod tests {
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
}
#[tokio::test]
async fn dns_settings_override_only_filled_fields_after_merge_and_script() {
let profile = mapping(
"dns: {nameserver: [9.9.9.9], nameserver-policy: {profile.example: 9.9.9.9}}\n\
hosts: {profile.example: 192.0.2.1}",
);
let settings = mapping(
"dns: {ipv6: true, nameserver: [8.8.8.8], nameserver-policy: {settings.example: 8.8.8.8},\n\
proxy-server-nameserver: [], fallback: null, listen: ' '}\n\
hosts: {settings.example: 192.0.2.2}",
);
let merge = mapping(
"dns: {ipv6: false, nameserver-policy: {merge.example: 1.1.1.1}}\n\
hosts: {merge.example: 192.0.2.3}",
);
let script = r#"function main(config) {
config.dns = {
ipv6: false,
nameserver: ["4.4.4.4"],
"nameserver-policy": {"script.example": "4.4.4.4"},
"proxy-server-nameserver": ["4.4.4.4"],
fallback: ["4.4.4.4"],
listen: "127.0.0.1:1053"
};
config.hosts = {"script.example": "192.0.2.4"};
return config;
}"#;
for enabled in [false, true] {
let (config, dns_settings) = if enabled {
super::merge_dns_config(profile.clone(), settings.clone())
} else {
(profile.clone(), Mapping::new())
};
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
let merge_item = |uid: &str| ChainItem {
uid: uid.into(),
data: ChainType::Merge(merge.clone()),
};
let profile_name = "test-profile".into();
let (config, keys, logs) = process_global_items(
config,
vec![],
HashMap::new(),
merge_item("global-merge"),
ChainItem::to_script("global-script", script),
&profile_name,
&authoritative,
)
.await;
let (config, _, logs) = process_profile_items(
config,
keys,
logs,
merge_item("profile-merge"),
ChainItem::to_script("profile-script", script),
&profile_name,
&authoritative,
)
.await;
let manual = config.clone();
let result = authoritative.enforce(config);
let source = if enabled { &settings } else { &manual };
assert_eq!(result["dns"]["nameserver-policy"], source["dns"]["nameserver-policy"]);
assert_eq!(result["hosts"], source["hosts"]);
assert_eq!(result["dns"]["nameserver"], source["dns"]["nameserver"]);
assert_eq!(result["dns"]["ipv6"], serde_yaml_ng::Value::from(enabled));
for key in ["proxy-server-nameserver", "fallback", "listen"] {
assert_eq!(result["dns"][key], manual["dns"][key]);
}
if enabled {
for uid in ["global-merge", "global-script", "profile-merge", "profile-script"] {
assert!(
logs[uid]
.iter()
.any(|(_, message)| message.contains("dns.nameserver-policy"))
);
}
}
}
}
#[tokio::test]
async fn manual_overrides_follow_expected_priority() {
let mut config = mapping(
@@ -1565,7 +1345,6 @@ mod tests {
);
let profile_name = "test-profile".into();
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
@@ -1573,7 +1352,6 @@ mod tests {
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, _) = process_profile_items(
@@ -1583,7 +1361,6 @@ mod tests {
profile_merge,
profile_script,
&profile_name,
&authoritative,
)
.await;
@@ -1721,21 +1498,19 @@ mod tests {
}
#[test]
fn empty_dns_settings_leave_inherited_fields_overridable() {
fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() {
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
let (config, dns_settings) =
super::merge_dns_config(app_config, mapping("dns: {ipv6: false, proxy-server-nameserver: []}"));
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
let dns_ipv6 = super::snapshot_dns_ipv6(&app_config);
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
let result = authoritative.enforce(hijacked);
let result = super::enforce_dns_ipv6(hijacked, dns_ipv6);
assert_eq!(
result
.get("dns")
.and_then(|value| value.get("ipv6"))
.and_then(serde_yaml_ng::Value::as_bool),
Some(true)
Some(false)
);
assert_eq!(
result
+7 -25
View File
@@ -1,14 +1,10 @@
use anyhow::{Result, ensure};
use anyhow::Result;
use clash_verge_draft::DraftTransaction;
use serde::Serialize;
use smartstring::alias::String;
use crate::{
config::{
Config,
dns::{ProfileDnsSettings, dns_override_source},
profiles::PROFILE_WRITE_LOCK,
},
config::{Config, dns::dns_override_source, profiles::PROFILE_WRITE_LOCK},
constants::files::DNS_CONFIG,
core::{CoreManager, handle::Handle},
utils::dirs,
@@ -21,18 +17,10 @@ pub enum DnsOverrideOutcome {
ConfirmationRequired { source: String },
}
pub async fn set_dns_override(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> Result<DnsOverrideOutcome> {
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Result<DnsOverrideOutcome> {
let _profile_write = PROFILE_WRITE_LOCK.lock().await;
let _config_write = Config::lock_config_write().await;
let profiles = Config::profiles().await.data_arc();
ensure!(
profiles.current.as_ref() == Some(&profile_uid),
"The active profile changed; retry the DNS setting"
);
let source = dns_override_source(
profiles.current.as_deref().unwrap_or_default(),
&profiles.current_mapping().await?,
@@ -49,13 +37,8 @@ pub async fn set_dns_override(
let verge = Config::verge().await;
let transaction = DraftTransaction::begin(vec![&verge])?;
verge.edit_draft(|draft| {
draft.profile_dns_settings.insert(
profile_uid.clone(),
ProfileDnsSettings {
enabled,
confirmation: if enabled { source } else { None },
},
);
draft.enable_dns_settings = Some(enabled);
draft.dns_override_confirmation = if enabled { source } else { None };
});
CoreManager::global().update_config_checked().await?;
@@ -64,9 +47,8 @@ pub async fn set_dns_override(
// A subscription update can replace the file while Mihomo is validating it.
if enabled && let Some(state) = state.filter(|state| !state.enabled) {
verge.edit_draft(|draft| {
draft
.profile_dns_settings
.insert(profile_uid, ProfileDnsSettings::default());
draft.enable_dns_settings = Some(false);
draft.dns_override_confirmation = None;
});
if let Some(source) = state.source {
outcome = DnsOverrideOutcome::ConfirmationRequired { source };
+4 -16
View File
@@ -39,19 +39,12 @@ mod app_init {
}
/// Setup plugins for the Tauri builder
pub fn setup_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
pub fn setup_plugins(builder: tauri::Builder) -> tauri::Builder {
#[allow(unused_mut)]
let mut builder = builder
.plugin(tauri_plugin_clash_verge_sysinfo::init())
.plugin(tauri_plugin_notification::init())
.plugin(
tauri_plugin_updater::Builder::new()
.default_version_comparator(|current, release| {
release.version > current
|| core::updater::is_build_to_stable(&current.to_string(), &release.version.to_string())
})
.build(),
)
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
@@ -123,7 +116,7 @@ mod app_init {
Ok(())
}
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static {
tauri::generate_handler![
tauri_plugin_clash_verge_sysinfo::commands::get_system_info,
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
@@ -170,11 +163,6 @@ mod app_init {
cmd::apply_dns_config,
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
@@ -267,7 +255,7 @@ pub fn run() -> std::process::ExitCode {
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_wayland_webkit_fix();
let builder = app_init::setup_plugins(tauri::Builder::default())
let builder = app_init::setup_plugins(tauri::Builder::default().runtime(tauri_runtime_wry::Wry::default()))
.setup(|app| {
// Logger may not be ready yet, so mirror setup panics to stderr.
fn log_setup_panic(stage: &str, panic: Box<dyn std::any::Any + Send>) {
-11
View File
@@ -1,19 +1,8 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::ExitCode;
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
#[path = "../../scripts/perf/app.rs"]
mod perf;
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
fn main() -> ExitCode {
perf::run()
}
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode {
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let worker_limit = std::cmp::min(default_parallelism, 8);
+2 -6
View File
@@ -16,13 +16,9 @@ pub async fn read_yaml<T: DeserializeOwned>(path: &Path) -> Result<T> {
bail!("file not found \"{}\"", path.display());
}
let yaml_str = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("failed to read YAML file {}", path.display()))?;
let yaml_str = tokio::fs::read_to_string(path).await?;
with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) })
.await
.with_context(|| format!("failed to parse YAML file {}", path.display()))
Ok(with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) }).await?)
}
pub async fn read_mapping(path: &Path) -> Result<Mapping> {
+29 -35
View File
@@ -9,7 +9,7 @@ use crate::{
help,
},
};
use anyhow::{Context as _, Result};
use anyhow::Result;
use chrono::{Local, TimeZone as _};
use clash_verge_logging::Type;
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -28,15 +28,8 @@ async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
];
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
let mut entries = fs::read_dir(temp_dir)
.await
.with_context(|| format!("failed to read log snapshot directory {}", temp_dir.display()))?;
while let Some(entry) = entries.next_entry().await.with_context(|| {
format!(
"failed to read next entry in log snapshot directory {}",
temp_dir.display()
)
})? {
let mut entries = fs::read_dir(temp_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("log") {
let _ = path.remove_if_exists().await;
@@ -116,28 +109,15 @@ pub async fn delete_log() -> Result<()> {
};
if log_dir.exists() {
let mut log_read_dir = fs::read_dir(&log_dir)
.await
.with_context(|| format!("failed to read log directory {}", log_dir.display()))?;
while let Some(entry) = log_read_dir
.next_entry()
.await
.with_context(|| format!("failed to read next entry in log directory {}", log_dir.display()))?
{
let mut log_read_dir = fs::read_dir(&log_dir).await?;
while let Some(entry) = log_read_dir.next_entry().await? {
std::mem::drop(process_file(entry).await);
}
}
if service_log_dir.exists() {
let mut service_log_read_dir = fs::read_dir(&service_log_dir)
.await
.with_context(|| format!("failed to read service log directory {}", service_log_dir.display()))?;
while let Some(entry) = service_log_read_dir.next_entry().await.with_context(|| {
format!(
"failed to read next entry in service log directory {}",
service_log_dir.display()
)
})? {
let mut service_log_read_dir = fs::read_dir(service_log_dir).await?;
while let Some(entry) = service_log_read_dir.next_entry().await? {
std::mem::drop(process_file(entry).await);
}
}
@@ -248,21 +228,27 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
Ok(())
}
/// Once per install; the marker is written even when no interval needed raising.
/// 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.
pub async fn migrate_short_update_intervals() -> Result<()> {
let marker = dirs::update_interval_migrated_path()?;
if fs::try_exists(&marker).await? {
return Ok(());
}
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
// 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.
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 {
// A failed profiles load (bare default) must not burn the one-shot marker.
// `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.
if profiles.items.is_none() {
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
}
@@ -384,7 +370,14 @@ 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![])),
(
"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()),
]),
),
("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
(
@@ -516,7 +509,7 @@ pub async fn init_resources() -> Result<()> {
std::mem::drop(fs::create_dir_all(&res_dir).await);
}
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
for file in file_list.iter() {
let src_path = res_dir.join(file);
@@ -644,9 +637,10 @@ async fn handle_copy(src: &PathBuf, dest: &PathBuf, file: &str) {
logging!(
error,
Type::Setup,
"failed to copy resource {} to {}: {err}",
src.display(),
dest.display()
"failed to copy resources '{}' to '{:?}', {}",
file,
dest,
err
);
}
};
+8
View File
@@ -18,6 +18,8 @@ pub enum NotificationEvent<'a> {
LightweightModeEntered,
ProfilesReactivated,
AppQuit,
#[cfg(target_os = "macos")]
AppHidden,
}
/// Whether no app window can currently show the failure.
@@ -93,5 +95,11 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
let body = clash_verge_i18n::t!("notifications.appQuit.body");
notify(title, body);
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
let title = clash_verge_i18n::t!("notifications.appHidden.title");
let body = clash_verge_i18n::t!("notifications.appHidden.body");
notify(title, body);
}
}
}
+5 -6
View File
@@ -1,4 +1,3 @@
use anyhow::Context as _;
use clash_verge_logging::{Type, logging};
#[cfg(target_os = "macos")]
use std::path::Path;
@@ -10,7 +9,7 @@ const DNS_STATE_FILE: &str = ".original_dns.txt";
fn dns_state_dir() -> anyhow::Result<PathBuf> {
// The DNS scripts persist .original_dns.txt relative to their working directory.
let dir = crate::utils::dirs::app_home_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?;
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
@@ -38,14 +37,14 @@ pub async fn set_public_dns(dns_server: String) {
};
let script = resource_dir.join("set_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "DNS script not found: {}", script.display());
logging!(error, Type::Config, "set_dns.sh not found");
return;
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
return;
}
};
@@ -86,14 +85,14 @@ pub async fn restore_public_dns() {
};
let script = resource_dir.join("unset_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "DNS script not found: {}", script.display());
logging!(error, Type::Config, "unset_dns.sh not found");
return;
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
return;
}
};
-3
View File
@@ -9,7 +9,6 @@ use crate::{
handle::Handle,
hotkey::Hotkey,
logger,
manager::CoreFailure,
service::{SERVICE_MANAGER, ServiceManager},
tray::Tray,
},
@@ -222,8 +221,6 @@ async fn init_core_manager() -> bool {
Ok(initialized) => initialized,
Err(error) => {
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}")));
Handle::notice_message("core_start::error", "");
false
}
}
+4 -1
View File
@@ -132,7 +132,10 @@ pub fn take_webview_needs_reload() -> bool {
/// reload visible webviews immediately, and defer a hidden main-window reload until activation.
/// Registering this callback replaces Tauri's default automatic reload.
#[cfg(target_os = "macos")]
pub fn on_web_content_process_terminated(webview: &tauri::Webview) {
pub fn on_web_content_process_terminated(
webview: &tauri::Webview,
_termination: &tauri::webview::WebContentProcessTermination,
) {
if handle::Handle::global().is_exiting() {
return;
}
+4 -10
View File
@@ -1,5 +1,5 @@
use crate::utils::dirs::{self, PathBufExec as _};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Result, anyhow};
use clash_verge_logging::{Type, logging};
use std::fs;
use std::os::windows::process::CommandExt as _;
@@ -100,14 +100,8 @@ async fn cleanup_legacy_shortcuts() -> Result<()> {
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
let new_shortcut = startup_dir.join("Clash Verge.lnk");
old_shortcut
.remove_if_exists()
.await
.with_context(|| format!("failed to remove startup shortcut {}", old_shortcut.display()))?;
new_shortcut
.remove_if_exists()
.await
.with_context(|| format!("failed to remove startup shortcut {}", new_shortcut.display()))?;
old_shortcut.remove_if_exists().await?;
new_shortcut.remove_if_exists().await?;
Ok(())
}
@@ -333,7 +327,7 @@ pub async fn set_auto_launch(is_enable: bool, is_admin: bool) -> Result<()> {
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
if let Err(err) = cleanup_legacy_shortcuts().await {
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {err:#}");
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {}", err);
}
if is_enable {
+1 -1
View File
@@ -14,7 +14,7 @@ pub(crate) fn report_error(error: &anyhow::Error) {
eprintln!("[clash-verge] startup failed: {detail}");
let log_result = startup_log_path().and_then(|path| {
append_error(&path, &detail).with_context(|| format!("startup log {}", path.display()))?;
append_error(&path, &detail)?;
Ok(path)
});
let message = match log_result {
+9 -9
View File
@@ -4,7 +4,7 @@ use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use std::pin::Pin;
use std::time::Duration;
use tauri::{Manager as _, WebviewWindow, Wry};
use tauri::{Manager as _, WebviewWindow};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowOperationResult {
@@ -50,7 +50,7 @@ impl WindowManager {
handle::Handle::global().set_activation_policy_regular();
}
fn get_main_window_with_state() -> (Option<WebviewWindow<Wry>>, WindowState) {
fn get_main_window_with_state() -> (Option<WebviewWindow>, WindowState) {
let Some(window) = Self::get_main_window() else {
return (None, WindowState::NotExist);
};
@@ -76,7 +76,7 @@ impl WindowManager {
Self::get_main_window_with_state().1
}
pub fn get_main_window() -> Option<WebviewWindow<Wry>> {
pub fn get_main_window() -> Option<WebviewWindow> {
let app_handle = handle::Handle::app_handle();
app_handle.get_webview_window("main")
}
@@ -147,7 +147,7 @@ impl WindowManager {
}
}
fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
fn hide_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
logging!(debug, Type::Window, "窗口可见,将隐藏窗口");
if let Some(window) = window {
match window.close() {
@@ -166,7 +166,7 @@ impl WindowManager {
}
}
fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
fn activate_existing_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
logging!(debug, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
if let Some(window) = window {
Self::activate_window(window)
@@ -176,7 +176,7 @@ impl WindowManager {
}
}
fn activate_window(window: &WebviewWindow<Wry>) -> WindowOperationResult {
fn activate_window(window: &WebviewWindow) -> WindowOperationResult {
logging!(debug, Type::Window, "开始激活窗口");
#[cfg(target_os = "macos")]
Self::set_macos_activation_policy_regular();
@@ -233,15 +233,15 @@ impl WindowManager {
}
}
pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool {
pub fn is_main_window_visible(window: Option<&WebviewWindow>) -> bool {
window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
}
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
pub fn is_main_window_focused(window: Option<&WebviewWindow>) -> bool {
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
}
fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
fn is_main_window_minimized(window: Option<&WebviewWindow>) -> bool {
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.7",
"version": "2.5.4",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+1 -2
View File
@@ -15,12 +15,11 @@
},
"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-rpm.sh",
"postInstallScript": "./packages/linux/post-install.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
}
},
+3 -16
View File
@@ -13,24 +13,12 @@ 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,
measureElement,
dataIndex,
children,
style,
} = props
const { id, index, group, disabled, children, style } = props
const { ref, handleRef, sortable, isDragging } = useSortable({
id,
index,
@@ -42,9 +30,8 @@ export const SortableItem = (props: SortableItemProps) => {
(element: HTMLDivElement | null) => {
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
ref(element)
measureElement?.(element)
},
[handleRef, ref, measureElement],
[handleRef, ref],
)
const mergedStyle: CSSProperties = {
@@ -66,7 +53,7 @@ export const SortableItem = (props: SortableItemProps) => {
}
return (
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
<div ref={setRef} style={mergedStyle}>
{children}
</div>
)
+214 -320
View File
@@ -1,7 +1,6 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import {
AccessTimeRounded,
ArrowDropDown,
ChevronRight,
NetworkCheckRounded,
WifiOff as SignalError,
@@ -16,22 +15,25 @@ import {
import {
Box,
Button,
ButtonBase,
Chip,
ClickAwayListener,
FormControl,
IconButton,
InputLabel,
type MenuProps,
MenuItem,
MenuList,
Paper,
Popper,
type PopperProps,
Select,
type SelectChangeEvent,
Tooltip,
Typography,
alpha,
useTheme,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
@@ -66,40 +68,6 @@ 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
@@ -195,6 +163,34 @@ 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,
@@ -220,71 +216,58 @@ const sortProxyOptions = (
.map(({ option }) => option)
}
interface PersistentSelectProps {
interface PersistentProxySelectProps {
label: string
displayValue: React.ReactNode
groupName: string
fixed?: string
value: string
selectedName: string
selectedDelay: number
options: ProxyOption[]
open: boolean
disabled: boolean
keepOpenRef: React.RefObject<HTMLElement | null>
onOpenChange: (open: boolean) => void
renderOptions: () => React.ReactNode
onOpen: () => void
onClose: () => void
onChange: (value: string) => void
}
const PersistentSelect = ({
const PersistentProxySelect = ({
label,
displayValue,
groupName,
fixed,
value,
selectedName,
selectedDelay,
options,
open,
disabled,
keepOpenRef,
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
},
}),
[],
)
onOpen,
onClose,
onChange,
}: PersistentProxySelectProps) => {
const anchorRef = useRef<HTMLDivElement>(null)
const listboxId = 'current-proxy-node-listbox'
const labelId = 'proxy-select-label'
const fixedProxyInUsed = selectedName === fixed
useEffect(() => {
if (!open) return
const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
onOpenChange(false)
onClose()
} else if (event.key === 'Escape') {
onOpenChange(false)
anchorRef.current?.focus({ preventScroll: true })
onClose()
anchorRef.current
?.querySelector<HTMLElement>('[role="combobox"]')
?.focus()
}
}
document.addEventListener('keydown', closeFromKeyboard)
return () => document.removeEventListener('keydown', closeFromKeyboard)
}, [onOpenChange, open])
}, [onClose, open])
return (
<ClickAwayListener
@@ -294,119 +277,134 @@ const PersistentSelect = ({
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
return
}
onOpenChange(false)
onClose()
}}
>
<Box>
<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}
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
value={value}
open={false}
onOpen={onOpen}
label={label}
disabled={disabled}
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',
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',
}}
>
<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>
<MenuItem value={value}>{selectedName}</MenuItem>
</Select>
</FormControl>
<Popper
open={open}
anchorEl={anchorRef.current}
placement="bottom-start"
modifiers={proxyMenuModifiers}
popperOptions={popperOptions}
sx={{
visibility: 'hidden',
width: anchorRef.current?.clientWidth,
zIndex: (theme) => theme.zIndex.modal,
}}
>
<Paper
elevation={8}
sx={{
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
overflow: 'auto',
}}
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
>
<MenuList
ref={listRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
autoFocusItem
variant="selectedMenu"
>
{open && 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>
)
})}
</MenuList>
</Paper>
</Popper>
@@ -415,124 +413,6 @@ const PersistentSelect = ({
)
}
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()
@@ -617,11 +497,6 @@ 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)
@@ -746,8 +621,9 @@ export const CurrentProxyCard = () => {
latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback(
(newGroupName: string) => {
(event: SelectChangeEvent<string>) => {
if (isGlobalMode || isDirectMode) return
const newGroupName = event.target.value
setSelectedGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
},
@@ -909,9 +785,24 @@ 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 || openSelect !== 'proxy'
isDirectMode
? []
: sortProxyOptions(
unsortedProxyOptions,
@@ -922,7 +813,6 @@ export const CurrentProxyCard = () => {
[
delays,
isDirectMode,
openSelect,
unsortedProxyOptions,
sortType,
defaultLatencyTimeout,
@@ -1085,29 +975,32 @@ export const CurrentProxyCard = () => {
/>
)}
</Box>
<Box sx={{ mb: 1.5 }}>
<PersistentSelect
label={t('home.components.currentProxy.labels.group')}
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
<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')}
label={t('home.components.currentProxy.labels.group')}
disabled={isGlobalMode || isDirectMode}
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>
MenuProps={{ onClose: handleGroupMenuClose }}
>
{selectableGroups.map((group) => (
<MenuItem key={group.name} value={group.name}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
<PersistentProxySelect
label={t('home.components.currentProxy.labels.proxy')}
@@ -1120,11 +1013,12 @@ export const CurrentProxyCard = () => {
}
selectedName={selectedProxyName}
selectedDelay={currentDelay}
options={proxyOptions}
options={isDirectMode ? [] : proxyOptions}
open={openSelect === 'proxy'}
disabled={isDirectMode}
keepOpenRef={delayButtonRef}
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
onOpen={() => setOpenSelect('proxy')}
onClose={() => setOpenSelect(null)}
onChange={handleProxyChange}
/>
</Box>
+1 -2
View File
@@ -27,7 +27,6 @@ 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'
@@ -42,7 +41,7 @@ const round = keyframes`
// 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => {
if (!url) return '-'
if (isValidUrl(url)) return new URL(url).host
if (url.startsWith('http')) return new URL(url).host
return 'local'
}
+1 -14
View File
@@ -4,7 +4,6 @@ import {
AdminPanelSettingsOutlined,
DnsOutlined,
ExtensionOutlined,
PauseCircleOutlined,
} from '@mui/icons-material'
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
import { useLockFn } from 'ahooks'
@@ -30,8 +29,7 @@ export const SystemInfoCard = () => {
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const navigate = useNavigate()
const { runningMode, isAdminMode, isSidecarMode, mutateSystemState } =
useSystemState()
const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState()
const { installServiceAndRestartCore } = useServiceInstaller()
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
@@ -137,14 +135,6 @@ export const SystemInfoCard = () => {
)
const getModeIcon = () => {
if (runningMode === 'NotRunning') {
return (
<PauseCircleOutlined
sx={{ color: 'text.secondary', fontSize: 16 }}
titleAccess={t('home.components.systemInfo.badges.notRunning')}
/>
)
}
if (isAdminMode) {
if (!isSidecarMode) {
return (
@@ -184,9 +174,6 @@ export const SystemInfoCard = () => {
}
const getModeText = () => {
if (runningMode === 'NotRunning') {
return t('home.components.systemInfo.badges.notRunning')
}
if (isAdminMode) {
if (!isSidecarMode) {
return t('home.components.systemInfo.badges.adminServiceMode')
+6 -11
View File
@@ -100,18 +100,13 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
const item = navItemMap.get(path)
if (!item) return null
// A registered sortable, even a disabled one, gets the drag-and-drop ARIA
// attributes, so screen readers announce every item as a disabled draggable.
if (!menuUnlocked) {
return (
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
{t(item.label)}
</LayoutItem>
)
}
return (
<SortableItem key={item.path} id={item.path} index={index}>
<SortableItem
key={item.path}
id={item.path}
index={index}
disabled={!menuUnlocked}
>
{(sortable) => (
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
{t(item.label)}
-64
View File
@@ -3,7 +3,6 @@ import {
Snackbar,
Alert,
IconButton,
Link,
Box,
Stack,
type SnackbarOrigin,
@@ -19,14 +18,6 @@ import {
showNotice,
} from '@/services/notice-service'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { openExternalUrl } from '@/utils/open-external-url'
const SERVICE_PERMISSION_NOTICE =
'settings.feedback.notifications.clashService.permissionFallback'
const SERVICE_PERMISSION_GUIDE =
'https://clash-verge-rev.github.io/faq/windows.html#service-core-permissions'
const SERVICE_OWNER_NOTICE =
'settings.feedback.notifications.clashService.appDataNotOwned'
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
@@ -68,55 +59,6 @@ const resolveNoticeMessage = (
const i18n = notice.i18n
if (!i18n) return bound(notice.message)
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
return (
<>
{t(SERVICE_PERMISSION_NOTICE, {
reason: t(
'settings.feedback.notifications.clashService.permissionRejectedReason',
),
})}
<Box sx={{ mt: 1 }}>
<Link
href={SERVICE_PERMISSION_GUIDE}
color="inherit"
underline="always"
onClick={(event) => {
event.preventDefault()
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
showNotice.error,
)
}}
>
{t(
'settings.feedback.notifications.clashService.permissionRepairGuide',
)}
</Link>
</Box>
</>
)
}
if (i18n.key === SERVICE_OWNER_NOTICE) {
return (
<>
{t(SERVICE_OWNER_NOTICE)}
<Box
component="code"
sx={{
display: 'block',
mt: 1,
userSelect: 'text',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{String(i18n.params?.command ?? '')}
</Box>
</>
)
}
const source = (i18n.params ?? {}) as Record<string, unknown>
// Bound both parameters and their final interpolation.
const params =
@@ -190,12 +132,6 @@ const resolveNoticeCopyText = (
notice: NoticeItem,
t: TranslationFn,
): string | undefined => {
if (notice.i18n?.key === SERVICE_PERMISSION_NOTICE) {
return extractNoticeCopyText(notice.i18n.params?.reason)
}
if (notice.i18n?.key === SERVICE_OWNER_NOTICE) {
return extractNoticeCopyText(notice.i18n.params?.command)
}
if (
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
notice.i18n?.key === 'shared.feedback.notices.raw'
@@ -1,106 +0,0 @@
import { useState } from 'react'
import { beforeEach, expect, it, vi } from 'vitest'
import {
getRuntimeState,
installService,
restartCore,
type RunState,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { ServiceMigrationDialog } from './service-migration-dialog'
vi.mock('react', () => ({ useState: vi.fn() }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@mui/material', () => ({ Alert: 'div' }))
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/cmds', () => ({
getRuntimeState: vi.fn(),
installService: vi.fn(),
restartCore: vi.fn(),
}))
vi.mock('@/services/notice-service', () => ({
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/services/query-client', () => ({
useQuery: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useState)
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([true, vi.fn()])
})
it.each([
{ service: 'ready', mode: 'NotRunning' },
{ service: 'unknown', mode: 'NotRunning' },
{ service: 'unavailable', mode: 'Service' },
{ service: 'unavailable', mode: 'NotRunning', opInFlight: true },
{ service: 'unavailable', mode: 'NotRunning', pendingAction: 'reinstall' },
] satisfies Partial<RunState>[])(
'does not offer Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
const dialog = ServiceMigrationDialog()
expect(dialog.props.open).toBe(true)
expect(dialog.props.disableCancel).toBe(true)
},
)
it('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
vi.mocked(useQuery).mockReturnValue({
data: { service: 'notInstalled', mode: 'NotRunning' },
} as ReturnType<typeof useQuery>)
const reason = 'core path C:\\ is writable by Everyone'
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
vi.mocked(getRuntimeState).mockResolvedValue({
service: 'unavailable',
mode: 'Sidecar',
sidecarAllowed: true,
serviceNeedsAttention: false,
} as RunState)
const dialog = ServiceMigrationDialog()
dialog.props.onOk()
await vi.waitFor(() => {
expect(showNotice.warning).toHaveBeenCalledWith(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason },
0,
)
})
expect(restartCore).not.toHaveBeenCalled()
expect(showNotice.error).not.toHaveBeenCalled()
expect(showNotice.success).not.toHaveBeenCalled()
expect(vi.mocked(useState).mock.results[2].value[1]).toHaveBeenLastCalledWith(
false,
)
})
it.each([
{ service: 'notInstalled', mode: 'NotRunning' },
{ service: 'versionMismatch', mode: 'NotRunning' },
{ service: 'unavailable', mode: 'NotRunning' },
{ service: 'notInstalled', mode: 'Sidecar', pendingAction: 'install' },
] satisfies Partial<RunState>[])(
'offers Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
},
)
@@ -13,7 +13,6 @@ import {
repairService,
restartCore,
type RunState,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { setCacheData, useQuery } from '@/services/query-client'
@@ -45,24 +44,7 @@ export const ServiceMigrationDialog = () => {
? 'install'
: 'reinstall'
const open = loading || workflowIncomplete || needsDecision
const checking =
loading ||
!runState ||
runState.opInFlight ||
runState.service === 'unknown'
const showCheckingMessage = checking || !needsDecision
const canContinue = Boolean(
runState &&
!stateRefreshFailed &&
(runState.pendingAction === 'install'
? runState.mode === 'NotRunning' || runState.mode === 'Sidecar'
: !runState.pendingAction &&
!runState.sidecarAllowed &&
runState.mode === 'NotRunning' &&
(runState.service === 'notInstalled' ||
runState.service === 'versionMismatch' ||
runState.service === 'unavailable')),
)
const showCheckingMessage = loading || !needsDecision
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
@@ -80,15 +62,16 @@ export const ServiceMigrationDialog = () => {
const handleServiceAction = async () => {
setLoading(true)
setWorkflowIncomplete(true)
let outcome: ServiceInstallOutcome | undefined
let actionSucceeded = false
try {
if (remedy === 'install') {
outcome = await installService()
await installService()
} else if (remedy === 'repair') {
outcome = await repairService()
await repairService()
} else {
outcome = await reinstallService()
await reinstallService()
}
actionSucceeded = true
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.actionFailed',
@@ -106,20 +89,10 @@ export const ServiceMigrationDialog = () => {
error,
)
}
if (!outcome || !initialRefreshSucceeded) {
if (!actionSucceeded || !initialRefreshSucceeded) {
setLoading(false)
return
}
if (outcome.status === 'sidecar') {
setWorkflowIncomplete(false)
setLoading(false)
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
0,
)
return
}
let restartSucceeded = false
try {
@@ -192,8 +165,8 @@ export const ServiceMigrationDialog = () => {
: 'layout.components.serviceMigration.reinstall',
)}
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
disableOk={checking}
disableCancel={checking || !canContinue}
disableOk={loading}
disableCancel={loading}
loading={loading}
onOk={() => void handleServiceAction()}
onCancel={() => void handleContinue()}
@@ -8,11 +8,9 @@ import {
getRuntimeState,
installService,
patchVergeConfig,
reinstallService,
restartCore,
type FailedOperation,
type PendingFailure,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import {
@@ -22,31 +20,23 @@ import {
type ServiceRequest,
type ServiceRequestReason,
} from '@/services/service-request'
import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
type Remedy = 'installAndRestart' | 'restartOnly'
const remedyFor = (reason: ServiceRequestReason): Remedy =>
reason === 'serviceLocationRefused'
? 'reinstallAndRestart'
: reason === 'sysproxySidecarReady'
? 'restartOnly'
: 'installAndRestart'
reason === 'sysproxySidecarReady' ? 'restartOnly' : 'installAndRestart'
const EXPLANATION = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
sysproxySidecarReady:
'layout.components.sysproxyPrivilege.serviceReadyMessage',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
serviceLocationRefused:
'layout.components.serviceMigration.locationRefusedMessage',
} as const
const TITLE = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
serviceLocationRefused: 'layout.components.serviceMigration.repair',
} as const
const stateToRestore = (
@@ -79,6 +69,7 @@ const STEP_MESSAGE = {
applying: 'layout.components.sysproxyPrivilege.applying',
} as const
/** Guide recovery from a refused system-proxy write. */
export const SysproxyPrivilegeDialog = () => {
const { t } = useTranslation()
const { failure, dismiss } = useDialogFailure()
@@ -100,48 +91,26 @@ export const SysproxyPrivilegeDialog = () => {
const handleFix = async () => {
try {
let outcome: ServiceInstallOutcome | undefined
if (remedy === 'reinstallAndRestart') {
if (remedy === 'installAndRestart') {
setStep('installing')
outcome = await reinstallService()
} else if (remedy === 'installAndRestart') {
setStep('installing')
outcome = await installService()
}
if (outcome?.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
0,
)
close()
return
await installService()
}
setStep('restarting')
await restartCore()
// Verify that restart actually moved the core into the service.
const runState = await getRuntimeState()
const usingAdminFallback =
remedy !== 'reinstallAndRestart' &&
getSystem() === 'windows' &&
runState.mode === 'Sidecar' &&
runState.isAdmin &&
runState.sidecarAllowed &&
runState.service === 'unavailable'
if (runState.mode === 'Service' || usingAdminFallback) {
if (runState.mode === 'Service') {
// Retry the original request now that the service can apply it.
if (restoring !== undefined) {
setStep('applying')
await patchVergeConfig(restoring)
}
if (!usingAdminFallback) {
showNotice.success(
remedy === 'reinstallAndRestart'
? 'layout.components.serviceMigration.success'
: restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
}
showNotice.success(
restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
close()
} else {
showNotice.error(
@@ -160,11 +129,9 @@ export const SysproxyPrivilegeDialog = () => {
open={Boolean(request)}
title={t(TITLE[reason])}
okBtn={t(
remedy === 'reinstallAndRestart'
? 'layout.components.serviceMigration.reinstall'
: remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
)}
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
// Keep the primary spinner visible; only cancellation is unavailable.
+3 -1
View File
@@ -1,5 +1,6 @@
import { Button } from '@mui/material'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
@@ -12,6 +13,7 @@ interface Props {
export const UpdateButton = (props: Props) => {
const { className } = props
const { t } = useTranslation()
const viewerRef = useRef<DialogRef>(null)
const { updateInfo } = useUpdate()
@@ -29,7 +31,7 @@ export const UpdateButton = (props: Props) => {
className={className}
onClick={() => viewerRef.current?.open()}
>
New
{t('shared.actions.new')}
</Button>
</>
)
-3
View File
@@ -33,7 +33,6 @@ type EditorLanguage = 'yaml' | 'javascript' | 'css'
export interface EditorViewerProps {
open: boolean
title?: string | ReactNode
description?: ReactNode
value: string
language: EditorLanguage
path: string
@@ -51,7 +50,6 @@ export interface EditorViewerProps {
export const EditorViewer = ({
open,
title,
description,
value,
language,
path,
@@ -209,7 +207,6 @@ export const EditorViewer = ({
overflow: 'hidden',
}}
>
{description}
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
<BaseLoadingOverlay isLoading={loading} />
{!loading && (
-53
View File
@@ -1,53 +0,0 @@
import { Alert, Box, Typography } from '@mui/material'
import { Trans, useTranslation } from 'react-i18next'
const stageHints = {
globalMerge: 'profiles.modals.editor.enhance.globalMerge',
globalScript: 'profiles.modals.editor.enhance.globalScript',
profileMerge: 'profiles.modals.editor.enhance.profileMerge',
profileScript: 'profiles.modals.editor.enhance.profileScript',
} as const
interface Props {
stage: keyof typeof stageHints
}
export const EnhanceHint = ({ stage }: Props) => {
const { t } = useTranslation()
const orderComponents = Object.fromEntries(
Object.keys(stageHints).map((key) => [
key,
<Box
key={key}
component={key === stage ? 'strong' : 'span'}
sx={
key === stage
? {
fontWeight: 'bold',
color: 'primary.contrastText',
bgcolor: 'primary.main',
borderRadius: 0.5,
px: 0.5,
boxDecorationBreak: 'clone',
}
: undefined
}
/>,
]),
)
return (
<Alert severity="info" sx={{ mb: 1.5, flexShrink: 0 }}>
<Typography variant="body2">{t(stageHints[stage])}</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
<Trans
defaults={t('profiles.modals.editor.enhance.order')}
components={orderComponents}
/>
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
{t('profiles.modals.editor.enhance.settingsPriority')}
</Typography>
</Alert>
)
}
@@ -1,184 +0,0 @@
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>
)
}
+170 -71
View File
@@ -1,4 +1,12 @@
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,7 +43,13 @@ import {
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
@@ -49,12 +63,6 @@ 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
@@ -195,71 +203,106 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match],
)
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)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<GroupItem
type={isDeleted ? 'delete' : 'original'}
group={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
<>
{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'
}
}}
/>
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>
)
}
if (category === 'prepend') {
} else {
return (
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
<>
{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>
)
})}
</>
)
}
return (
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
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)
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,
)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -268,9 +311,55 @@ export const GroupsEditorViewer = (props: Props) => {
return
}
setList(arrayMove(list, activeRealIndex, overRealIndex))
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))
}
// 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)
@@ -1063,12 +1152,22 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
<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>
</List>
</>
) : (
+3 -7
View File
@@ -43,11 +43,9 @@ 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'
import { EnhanceHint } from './enhance-hint'
import { ProfileBox } from './profile-box'
import { ProxiesEditorViewer } from './proxies-editor-viewer'
import { QrViewer } from './qr-viewer'
@@ -911,8 +909,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
{mergeOpen && (
<EditorViewer
open={true}
title={t('profiles.components.menu.extendConfig')}
description={<EnhanceHint stage="profileMerge" />}
value={mergeDocument.value}
language="yaml"
path={`merge:${option?.merge ?? ''}.yaml`}
@@ -926,8 +922,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
{scriptOpen && (
<EditorViewer
open={true}
title={t('profiles.components.menu.extendScript')}
description={<EnhanceHint stage="profileScript" />}
value={scriptDocument.value}
language="javascript"
path={`script:${option?.script ?? ''}.js`}
@@ -971,7 +965,9 @@ export const ProfileItem = memo(ProfileItemBase)
function parseUrl(url?: string) {
if (!url) return ''
return isValidUrl(url) ? new URL(url).host : 'local file'
const regex = /https?:\/\/(.+?)\//
const result = url.match(regex)
return result ? result[1] : 'local file'
}
function parseExpire(expire?: number) {
-6
View File
@@ -17,7 +17,6 @@ import { useEditorDocument } from '@/hooks/use-editor-document'
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { EnhanceHint } from './enhance-hint'
import { LogViewer } from './log-viewer'
import { ProfileBox } from './profile-box'
@@ -214,11 +213,6 @@ export const ProfileMore = (props: Props) => {
<EditorViewer
open={true}
title={t(globalTitles[id])}
description={
<EnhanceHint
stage={id === 'Merge' ? 'globalMerge' : 'globalScript'}
/>
}
value={document.value}
language={id === 'Merge' ? 'yaml' : 'javascript'}
path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
+166 -71
View File
@@ -1,4 +1,12 @@
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,
@@ -27,7 +35,12 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor } from '@/components/base'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
VirtualList,
} from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -37,12 +50,6 @@ 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
@@ -100,71 +107,106 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match],
)
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)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<ProxyItem
type={isDeleted ? 'delete' : 'original'}
proxy={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
<>
{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'
}
}}
/>
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>
)
}
if (category === 'prepend') {
} else {
return (
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
<>
{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>
)
})}
</>
)
}
return (
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
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)
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,
)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -173,9 +215,52 @@ export const ProxiesEditorViewer = (props: Props) => {
return
}
setList(arrayMove(list, activeRealIndex, overRealIndex))
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))
}
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[] = []
@@ -437,12 +522,22 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
<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>
</List>
</>
) : (
+180 -93
View File
@@ -1,4 +1,11 @@
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,
@@ -29,7 +36,13 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -41,12 +54,6 @@ 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
@@ -295,83 +302,117 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match],
)
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)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<RuleItem
type={isDeleted ? 'delete' : 'original'}
ruleRaw={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item))
} else {
setDeleteSeq((prev) => [...prev, item])
}
}}
/>
)
}
if (category === 'prepend') {
return (
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
<>
{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>
)
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
})}
</>
)
} 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'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
</Box>
)
} else {
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>
)
})}
</>
)
}
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 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)
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,
)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -380,9 +421,52 @@ export const RulesEditorViewer = (props: Props) => {
return
}
setList(arrayMove(list, activeRealIndex, overRealIndex))
setPrependSeq(reorder(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(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)
@@ -681,13 +765,6 @@ export const RulesEditorViewer = (props: Props) => {
autoComplete="new-password"
size="small"
sx={{ minWidth: '240px' }}
slotProps={{
htmlInput: {
autoCorrect: 'off',
autoCapitalize: 'off',
spellCheck: false,
},
}}
value={ruleContent}
required={ruleType.required ?? true}
error={(ruleType.required ?? true) && !ruleContent}
@@ -777,12 +854,22 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
<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>
</List>
</>
) : (
@@ -17,8 +17,6 @@ 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'
@@ -176,10 +174,6 @@ 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()
}}
>

Some files were not shown because too many files have changed in this diff Show More