mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 22:00:56 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ad95657bd | ||
|
|
a544990f9f | ||
|
|
e35a239a06 | ||
|
|
143ed48aa9 |
@@ -5,7 +5,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.5.1"
|
||||
"pnpmVersion": "12.3.3"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.98.1",
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.89.15": {
|
||||
"github/gh-aw-actions/setup@v0.89.1": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.89.15",
|
||||
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
|
||||
"version": "v0.89.1",
|
||||
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
@@ -22,11 +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/api-proxy:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
||||
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||
@@ -37,11 +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/squid:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||
@@ -52,15 +42,10 @@
|
||||
"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-mcpg:v0.4.21": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
|
||||
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
|
||||
"ghcr.io/github/gh-aw-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",
|
||||
|
||||
@@ -30,19 +30,11 @@ jobs:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
DRAFT: ${{ github.event.pull_request.draft }}
|
||||
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
run: |
|
||||
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
||||
echo "Draft or no-issue-needed; linkage check skipped."
|
||||
exit 0
|
||||
fi
|
||||
# OWNER/MEMBER/COLLABORATOR and bots mirror the slop review's
|
||||
# skip-roles/skip-bots trust tier.
|
||||
if [ "$AUTHOR_TYPE" = "Bot" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
|
||||
echo "Bot or trusted author ($ASSOCIATION); linkage check skipped."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
|
||||
echo "Issue reference found."
|
||||
else
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
|
||||
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
|
||||
with:
|
||||
version: v0.89.15
|
||||
version: v0.89.1
|
||||
|
||||
+108
-111
File diff suppressed because one or more lines are too long
@@ -17,3 +17,4 @@ target
|
||||
.vfox
|
||||
.claude
|
||||
.zed
|
||||
/src-tauri/generated/widget/
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.5.1
|
||||
pnpm 12.4.1
|
||||
rust 1.98.1
|
||||
|
||||
Generated
+614
-458
File diff suppressed because it is too large
Load Diff
+9
-20
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/tauri-plugin-clash-verge-sysinfo",
|
||||
"crates/clash-verge-i18n",
|
||||
"crates/clash-verge-limiter",
|
||||
"crates/clash-verge-widget",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -46,12 +47,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" }
|
||||
@@ -59,12 +54,13 @@ clash-verge-logging = { path = "crates/clash-verge-logging" }
|
||||
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
||||
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
||||
clash-verge-widget = { path = "crates/clash-verge-widget" }
|
||||
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
|
||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||
tracing = "0.1.44"
|
||||
tracing = "0.1"
|
||||
|
||||
tauri = { version = "2.11.6" }
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.104"
|
||||
chrono = "0.4.45"
|
||||
@@ -76,15 +72,9 @@ tokio = { version = "1.53.1", features = [
|
||||
"sync",
|
||||
] }
|
||||
flexi_logger = "0.31.10"
|
||||
log = "0.4.34"
|
||||
log = "0.4.33"
|
||||
regex = "1.13.1"
|
||||
reqwest = { version = "0.13.5", features = [
|
||||
"json",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"rustls",
|
||||
"form",
|
||||
] }
|
||||
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
|
||||
rust_iso3166 = "0.2.0"
|
||||
|
||||
smartstring = { version = "1.0.1" }
|
||||
@@ -92,7 +82,7 @@ smartstring = { version = "1.0.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_yaml_ng = { version = "0.10.0" }
|
||||
bitflags = { version = "2.13.2" }
|
||||
bitflags = { version = "2.13.1" }
|
||||
socket2 = "0.6.5"
|
||||
|
||||
# *** For Windows platform only ***
|
||||
@@ -105,8 +95,7 @@ windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_JobObjects"
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
|
||||
+82
-13
@@ -1,31 +1,100 @@
|
||||
## v2.5.6
|
||||
## v2.5.4
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||
- 修复重启内核后未恢复上次所选节点的问题
|
||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||
- 修复切换节点时应用可能卡住的问题
|
||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
|
||||
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
|
||||
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
|
||||
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
|
||||
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
|
||||
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
|
||||
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
|
||||
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
|
||||
- 修复 macOS 内核更新失败后无法启动的问题
|
||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
- 新增手动取消节点固定功能
|
||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||
- 新增前置规则与后置规则互相移动功能
|
||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 新增 macOS Liquid Glass 风格图标
|
||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
|
||||
</details>
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.2.2"
|
||||
rust-i18n = "4.2.1"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: وكيل النظام
|
||||
tun: TUN
|
||||
profile: ملف تعريفي
|
||||
widget:
|
||||
unavailable: افتح Clash Verge
|
||||
failed: فشل التحديث؛ تحقق من Clash Verge
|
||||
'on': مفعّل
|
||||
'off': معطّل
|
||||
unknown: غير معروف
|
||||
name: توجيه Clash Verge
|
||||
description: التحكم في وضع التوجيه وTUN ووكيل النظام.
|
||||
update: تحديث Clash Verge
|
||||
setting: الإعداد
|
||||
value: القيمة
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: Systemproxy
|
||||
tun: TUN
|
||||
profile: Profil
|
||||
widget:
|
||||
unavailable: Clash Verge öffnen
|
||||
failed: Aktualisierung fehlgeschlagen; Clash Verge prüfen
|
||||
'on': Ein
|
||||
'off': Aus
|
||||
unknown: Unbekannt
|
||||
name: Clash Verge Routing
|
||||
description: Routingmodus, TUN und Systemproxy steuern.
|
||||
update: Clash Verge aktualisieren
|
||||
setting: Einstellung
|
||||
value: Wert
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: System Proxy
|
||||
tun: TUN
|
||||
profile: Profile
|
||||
widget:
|
||||
unavailable: Open Clash Verge
|
||||
failed: Update failed; check Clash Verge
|
||||
'on': 'On'
|
||||
'off': 'Off'
|
||||
unknown: Unknown
|
||||
name: Clash Verge Routing
|
||||
description: Routing mode, TUN and system proxy controls.
|
||||
update: Update Clash Verge
|
||||
setting: Setting
|
||||
value: Value
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: Proxy del sistema
|
||||
tun: TUN
|
||||
profile: Perfil
|
||||
widget:
|
||||
unavailable: Abre Clash Verge
|
||||
failed: Error al actualizar; revisa Clash Verge
|
||||
'on': Activado
|
||||
'off': Desactivado
|
||||
unknown: Desconocido
|
||||
name: Enrutamiento de Clash Verge
|
||||
description: Controles del modo de enrutamiento, TUN y proxy del sistema.
|
||||
update: Actualizar Clash Verge
|
||||
setting: Ajuste
|
||||
value: Valor
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: پروکسی سیستم
|
||||
tun: TUN
|
||||
profile: پروفایل
|
||||
widget:
|
||||
unavailable: Clash Verge را باز کنید
|
||||
failed: بهروزرسانی ناموفق بود؛ Clash Verge را بررسی کنید
|
||||
'on': روشن
|
||||
'off': خاموش
|
||||
unknown: نامشخص
|
||||
name: مسیریابی Clash Verge
|
||||
description: کنترل حالت مسیریابی، TUN و پراکسی سیستم.
|
||||
update: بهروزرسانی Clash Verge
|
||||
setting: تنظیم
|
||||
value: مقدار
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: Proksi Sistem
|
||||
tun: TUN
|
||||
profile: Profil
|
||||
widget:
|
||||
unavailable: Buka Clash Verge
|
||||
failed: Pembaruan gagal; periksa Clash Verge
|
||||
'on': Aktif
|
||||
'off': Nonaktif
|
||||
unknown: Tidak diketahui
|
||||
name: Perutean Clash Verge
|
||||
description: Kontrol mode perutean, TUN, dan proksi sistem.
|
||||
update: Perbarui Clash Verge
|
||||
setting: Pengaturan
|
||||
value: Nilai
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: システムプロキシ
|
||||
tun: TUN
|
||||
profile: プロファイル
|
||||
widget:
|
||||
unavailable: Clash Verge を開いてください
|
||||
failed: 更新失敗。Clash Verge を確認してください
|
||||
'on': オン
|
||||
'off': オフ
|
||||
unknown: 不明
|
||||
name: Clash Verge ルーティング
|
||||
description: ルーティングモード、TUN、システムプロキシの操作。
|
||||
update: Clash Verge を更新
|
||||
setting: 設定
|
||||
value: 値
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: 시스템 프록시
|
||||
tun: TUN
|
||||
profile: 프로필
|
||||
widget:
|
||||
unavailable: Clash Verge를 여세요
|
||||
failed: 업데이트 실패. Clash Verge를 확인하세요
|
||||
'on': 켜짐
|
||||
'off': 꺼짐
|
||||
unknown: 알 수 없음
|
||||
name: Clash Verge 라우팅
|
||||
description: 라우팅 모드, TUN 및 시스템 프록시 제어.
|
||||
update: Clash Verge 업데이트
|
||||
setting: 설정
|
||||
value: 값
|
||||
|
||||
@@ -29,6 +29,9 @@ notifications:
|
||||
appQuit:
|
||||
title: Скорый выход
|
||||
body: Clash Verge скоро завершит работу.
|
||||
appHidden:
|
||||
title: Приложение скрыто
|
||||
body: Clash Verge работает в фоновом режиме.
|
||||
updateReady:
|
||||
title: Обновление Clash Verge
|
||||
body: Новая версия (v{version}) скачана и готова к установке.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: Системный прокси
|
||||
tun: TUN
|
||||
profile: Профиль
|
||||
widget:
|
||||
unavailable: Откройте Clash Verge
|
||||
failed: Ошибка обновления; проверьте Clash Verge
|
||||
'on': Вкл.
|
||||
'off': Выкл.
|
||||
unknown: Неизвестно
|
||||
name: Маршрутизация Clash Verge
|
||||
description: Управление режимом маршрутизации, TUN и системным прокси.
|
||||
update: Обновить Clash Verge
|
||||
setting: Настройка
|
||||
value: Значение
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: Sistem Vekil'i
|
||||
tun: TUN
|
||||
profile: Profil
|
||||
widget:
|
||||
unavailable: Clash Verge uygulamasını açın
|
||||
failed: Güncelleme başarısız; Clash Verge uygulamasını kontrol edin
|
||||
'on': Açık
|
||||
'off': Kapalı
|
||||
unknown: Bilinmiyor
|
||||
name: Clash Verge Yönlendirme
|
||||
description: Yönlendirme modu, TUN ve sistem vekili denetimleri.
|
||||
update: Clash Verge güncelle
|
||||
setting: Ayar
|
||||
value: Değer
|
||||
|
||||
@@ -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.
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: Системалы прокси
|
||||
tun: TUN
|
||||
profile: Профиль
|
||||
widget:
|
||||
unavailable: Clash Verge ачыгыз
|
||||
failed: Яңарту уңышсыз; Clash Verge тикшерегез
|
||||
'on': Кабызылган
|
||||
'off': Сүндерелгән
|
||||
unknown: Билгесез
|
||||
name: Clash Verge маршрутлау
|
||||
description: Маршрутлау режимы, TUN һәм система проксие белән идарә итү.
|
||||
update: Clash Verge яңарту
|
||||
setting: Көйләү
|
||||
value: Кыйммәт
|
||||
|
||||
@@ -29,6 +29,9 @@ notifications:
|
||||
appQuit:
|
||||
title: 即将退出
|
||||
body: Clash Verge 即将退出。
|
||||
appHidden:
|
||||
title: 应用已隐藏
|
||||
body: Clash Verge 正在后台运行。
|
||||
updateReady:
|
||||
title: Clash Verge 更新
|
||||
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: 系统代理
|
||||
tun: TUN
|
||||
profile: 订阅
|
||||
widget:
|
||||
unavailable: 请打开 Clash Verge
|
||||
failed: 更新失败,请检查 Clash Verge
|
||||
'on': 开启
|
||||
'off': 关闭
|
||||
unknown: 未知
|
||||
name: Clash Verge 路由
|
||||
description: 路由模式、TUN 和系统代理控制。
|
||||
update: 更新 Clash Verge
|
||||
setting: 设置
|
||||
value: 值
|
||||
|
||||
@@ -29,6 +29,9 @@ notifications:
|
||||
appQuit:
|
||||
title: 即將退出
|
||||
body: Clash Verge 即將退出。
|
||||
appHidden:
|
||||
title: 應用已隱藏
|
||||
body: Clash Verge 正在背景執行。
|
||||
updateReady:
|
||||
title: Clash Verge 更新
|
||||
body: 新版本(v{version})已下載完成,是否立即安裝?
|
||||
@@ -88,3 +91,14 @@ tray:
|
||||
systemProxy: 系統代理
|
||||
tun: 虛擬網路介面卡
|
||||
profile: 訂閱
|
||||
widget:
|
||||
unavailable: 請開啟 Clash Verge
|
||||
failed: 更新失敗,請檢查 Clash Verge
|
||||
'on': 開啟
|
||||
'off': 關閉
|
||||
unknown: 未知
|
||||
name: Clash Verge 路由
|
||||
description: 路由模式、TUN 和系統代理控制。
|
||||
update: 更新 Clash Verge
|
||||
setting: 設定
|
||||
value: 值
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Xcode per-user state and local build output
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "clash-verge-widget"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
anyhow = { workspace = true }
|
||||
libc = "0.2.189"
|
||||
log = { workspace = true }
|
||||
scopeguard = "1.2.0"
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "io-util"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,232 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { X509Certificate, randomBytes } from 'node:crypto'
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const crate = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const root = resolve(crate, '../..')
|
||||
const app = join(root, 'src-tauri')
|
||||
const stage = join(app, 'generated/widget')
|
||||
const run = (command, args) => {
|
||||
try {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${command} failed (${error.status}); ${command === 'security' ? 'check signing credentials' : error.stderr?.toString() || 'no diagnostic output'}`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
const config = JSON.parse(readFileSync(join(app, 'tauri.conf.json'), 'utf8'))
|
||||
const mac = JSON.parse(readFileSync(join(app, 'tauri.macos.conf.json'), 'utf8'))
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'verge-widget-build-'))
|
||||
let keychain
|
||||
try {
|
||||
if (process.platform !== 'darwin')
|
||||
throw new Error('Widget packaging requires macOS.')
|
||||
run('xcodebuild', ['-version'])
|
||||
run('xcrun', ['--find', 'appintentsmetadataprocessor'])
|
||||
const target = process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
const arch = {
|
||||
'aarch64-apple-darwin': 'arm64',
|
||||
'x86_64-apple-darwin': 'x86_64',
|
||||
'universal-apple-darwin': 'arm64 x86_64',
|
||||
}[target]
|
||||
if (!arch) throw new Error(`Unsupported widget target: ${target}`)
|
||||
if (process.env.APPLE_CERTIFICATE) {
|
||||
keychain = join(temporary, 'widget.keychain-db')
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const certificate = join(temporary, 'signing.p12')
|
||||
writeFileSync(
|
||||
certificate,
|
||||
Buffer.from(process.env.APPLE_CERTIFICATE, 'base64'),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
run('security', ['create-keychain', '-p', password, keychain])
|
||||
run('security', ['unlock-keychain', '-p', password, keychain])
|
||||
run('security', [
|
||||
'import',
|
||||
certificate,
|
||||
'-k',
|
||||
keychain,
|
||||
'-P',
|
||||
process.env.APPLE_CERTIFICATE_PASSWORD || '',
|
||||
'-T',
|
||||
'/usr/bin/codesign',
|
||||
])
|
||||
run('security', [
|
||||
'set-key-partition-list',
|
||||
'-S',
|
||||
'apple-tool:,apple:,codesign:',
|
||||
'-s',
|
||||
'-k',
|
||||
password,
|
||||
keychain,
|
||||
])
|
||||
}
|
||||
const keychainArgs = keychain ? [keychain] : []
|
||||
const identities = [
|
||||
...run('security', [
|
||||
'find-identity',
|
||||
'-v',
|
||||
'-p',
|
||||
'codesigning',
|
||||
...keychainArgs,
|
||||
]).matchAll(/\b([A-F0-9]{40}) "([^"]+)"/g),
|
||||
].map((match) => ({ hash: match[1], name: match[2] }))
|
||||
const requested =
|
||||
process.env.APPLE_SIGNING_IDENTITY || mac.bundle.macOS.signingIdentity
|
||||
if (requested === '-') {
|
||||
console.warn(
|
||||
'CLASH_VERGE_WIDGET=0: skipping widget embedding; this build has no desktop widget extension.',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
if (!requested)
|
||||
throw new Error(
|
||||
'pnpm build on macOS needs APPLE_SIGNING_IDENTITY (or a single valid signing certificate) for widget and host signing.',
|
||||
)
|
||||
const matches = identities.filter(
|
||||
({ hash, name }) => hash === requested.toUpperCase() || name === requested,
|
||||
)
|
||||
if (matches.length !== 1)
|
||||
throw new Error(
|
||||
'Widget packaging requires one valid Apple signing identity; run pnpm build with APPLE_SIGNING_IDENTITY set.',
|
||||
)
|
||||
const identity = matches[0]
|
||||
const certificates =
|
||||
run('security', ['find-certificate', '-a', '-p', ...keychainArgs]).match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
|
||||
) || []
|
||||
const certificate = certificates
|
||||
.map((pem) => new X509Certificate(pem))
|
||||
.find((cert) => cert.fingerprint.replaceAll(':', '') === identity.hash)
|
||||
const team = certificate?.subject.match(
|
||||
/(?:^|\n)OU=([A-Z0-9]{10})(?:\n|$)/,
|
||||
)?.[1]
|
||||
if (!team) throw new Error('Selected certificate has no Apple Team ID.')
|
||||
const group = `${team}.io.vergewidget`
|
||||
const version = config.version
|
||||
mkdirSync(stage, { recursive: true })
|
||||
run('python3', [
|
||||
'-c',
|
||||
`
|
||||
import json, plistlib, sys
|
||||
from pathlib import Path
|
||||
app, stage, team, group, version = sys.argv[1:]
|
||||
stage = Path(stage)
|
||||
common = {'VergeWidgetTeam': team, 'VergeWidgetGroup': group}
|
||||
info = dict(common, CFBundleIdentifier='io.github.clash-verge-rev.clash-verge-rev.widget', CFBundleName='VergeWidget', CFBundleDevelopmentRegion='zh-Hans', CFBundleDisplayName='Clash Verge Routing', CFBundleExecutable='VergeWidget', CFBundlePackageType='XPC!', CFBundleShortVersionString=version, CFBundleVersion=version, LSMinimumSystemVersion='14.0', NSExtension={'NSExtensionPointIdentifier':'com.apple.widgetkit-extension'})
|
||||
with open(Path(app)/'packages/macos/info_merge.plist','rb') as f: host_info = plistlib.load(f)
|
||||
host_info.update(common)
|
||||
with open(Path(app)/'packages/macos/entitlements.plist','rb') as f: host_entitlements = plistlib.load(f)
|
||||
host_entitlements['com.apple.security.application-groups'] = [group]
|
||||
host_entitlements.pop('com.apple.security.inherit', None)
|
||||
ext_entitlements = {'com.apple.security.app-sandbox': True, 'com.apple.security.application-groups': [group], 'com.apple.security.network.client': True}
|
||||
for name, value in [('Widget.plist',info), ('Host.plist',host_info), ('Host.entitlements',host_entitlements), ('Widget.entitlements',ext_entitlements)]:
|
||||
with (stage/name).open('wb') as f: plistlib.dump(value,f)
|
||||
`,
|
||||
app,
|
||||
stage,
|
||||
team,
|
||||
group,
|
||||
version,
|
||||
])
|
||||
const products = join(temporary, 'products')
|
||||
run('xcodebuild', [
|
||||
'-project',
|
||||
join(crate, 'native/Widget.xcodeproj'),
|
||||
'-target',
|
||||
'VergeWidget',
|
||||
'-configuration',
|
||||
'Release',
|
||||
`CONFIGURATION_BUILD_DIR=${products}`,
|
||||
`OBJROOT=${temporary}/objects`,
|
||||
`SYMROOT=${temporary}/symbols`,
|
||||
`ARCHS=${arch}`,
|
||||
'ONLY_ACTIVE_ARCH=NO',
|
||||
'MACOSX_DEPLOYMENT_TARGET=14.0',
|
||||
'CODE_SIGNING_ALLOWED=NO',
|
||||
`WIDGET_INFO_PLIST=${stage}/Widget.plist`,
|
||||
`WIDGET_ENTITLEMENTS=${stage}/Widget.entitlements`,
|
||||
'build',
|
||||
])
|
||||
readFileSync(
|
||||
join(
|
||||
products,
|
||||
'VergeWidget.appex/Contents/Resources/Metadata.appintents/extract.actionsdata',
|
||||
),
|
||||
)
|
||||
const extension = join(products, 'VergeWidget.appex')
|
||||
if (process.env.WIDGET_PROFILE) {
|
||||
cpSync(
|
||||
process.env.WIDGET_PROFILE,
|
||||
join(extension, 'Contents/embedded.provisionprofile'),
|
||||
)
|
||||
}
|
||||
run('codesign', [
|
||||
'--force',
|
||||
'--sign',
|
||||
identity.hash,
|
||||
...(keychain ? ['--keychain', keychain] : []),
|
||||
'--options',
|
||||
'runtime',
|
||||
'--entitlements',
|
||||
join(stage, 'Widget.entitlements'),
|
||||
extension,
|
||||
])
|
||||
rmSync(join(stage, 'VergeWidget.appex'), { recursive: true, force: true })
|
||||
cpSync(extension, join(stage, 'VergeWidget.appex'), { recursive: true })
|
||||
const slices = arch.split(' ').map((architecture) => {
|
||||
const output = join(temporary, `${architecture}.dylib`)
|
||||
run('xcrun', [
|
||||
'swiftc',
|
||||
'-emit-library',
|
||||
'-parse-as-library',
|
||||
'-target',
|
||||
`${architecture}-apple-macos14.0`,
|
||||
join(crate, 'native/Bridge.swift'),
|
||||
'-o',
|
||||
output,
|
||||
])
|
||||
return output
|
||||
})
|
||||
run('xcrun', [
|
||||
'lipo',
|
||||
'-create',
|
||||
...slices,
|
||||
'-output',
|
||||
join(stage, 'VergeWidgetBridge.dylib'),
|
||||
])
|
||||
console.log(
|
||||
`Prepared signed widget ${version} (${arch}); Tauri will embed it before host signing and packaging.`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Widget packaging failed:',
|
||||
error.stderr?.toString() || error.message,
|
||||
)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (keychain) {
|
||||
try {
|
||||
run('security', ['delete-keychain', keychain])
|
||||
} catch {
|
||||
console.error('Could not remove temporary widget keychain.')
|
||||
}
|
||||
}
|
||||
rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { load } from 'js-yaml'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const resources = process.argv[2]
|
||||
if (!resources) throw new Error('Pass the extension resources directory.')
|
||||
const catalogs = join(root, 'crates/clash-verge-i18n/locales')
|
||||
for (const file of readdirSync(catalogs)
|
||||
.filter((file) => file.endsWith('.yml'))
|
||||
.sort()) {
|
||||
const locale = file.slice(0, -4)
|
||||
const catalog = load(readFileSync(join(catalogs, file), 'utf8'))
|
||||
const language =
|
||||
{ jp: 'ja', zh: 'zh-Hans', zhtw: 'zh-Hant' }[locale] || locale
|
||||
const strings = {
|
||||
'tray.rule': catalog.tray.rule,
|
||||
'tray.global': catalog.tray.global,
|
||||
'tray.direct': catalog.tray.direct,
|
||||
'tray.systemProxy': catalog.tray.systemProxy,
|
||||
'tray.tooltip.tun': catalog.tray.tooltip.tun,
|
||||
}
|
||||
for (const [key, value] of Object.entries(catalog.widget))
|
||||
strings[`widget.${key}`] = value
|
||||
const directory = join(resources, `${language}.lproj`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
join(directory, 'Localizable.strings'),
|
||||
Object.entries(strings)
|
||||
.map(
|
||||
([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)};`,
|
||||
)
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Security
|
||||
import WidgetKit
|
||||
|
||||
let widgetKind = "VergeRoutingWidget"
|
||||
let hostID = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
func socketPath() -> String? {
|
||||
guard let group = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetGroup") as? String else { return nil }
|
||||
guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
|
||||
return nil
|
||||
}
|
||||
return container.appendingPathComponent("verge-widget.sock").path
|
||||
}
|
||||
|
||||
func authenticPeer(_ descriptor: Int32, identifier: String) -> Bool {
|
||||
var token = audit_token_t()
|
||||
var size = socklen_t(MemoryLayout.size(ofValue: token))
|
||||
guard getsockopt(descriptor, SOL_LOCAL, LOCAL_PEERTOKEN, &token, &size) == 0,
|
||||
size == MemoryLayout.size(ofValue: token) else { return false }
|
||||
let data = withUnsafeBytes(of: token) { Data($0) }
|
||||
var peer: SecCode?
|
||||
let attributes = [kSecGuestAttributeAudit: data] as CFDictionary
|
||||
guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &peer) == errSecSuccess,
|
||||
let peer,
|
||||
let team = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetTeam") as? String,
|
||||
team.range(of: "^[A-Z0-9]{10}$", options: .regularExpression) != nil else { return false }
|
||||
var requirement: SecRequirement?
|
||||
let expression = """
|
||||
anchor apple generic and certificate leaf[subject.OU] = "\(team)" and identifier "\(identifier)"
|
||||
"""
|
||||
guard SecRequirementCreateWithString(expression as CFString, [], &requirement) == errSecSuccess,
|
||||
let requirement else { return false }
|
||||
return SecCodeCheckValidity(peer, [], requirement) == errSecSuccess
|
||||
}
|
||||
|
||||
#if !WIDGET_EXTENSION
|
||||
@_cdecl("verge_widget_path")
|
||||
public func vergeWidgetPath() -> UnsafeMutablePointer<CChar>? {
|
||||
socketPath().flatMap { strdup($0) }
|
||||
}
|
||||
|
||||
@_cdecl("verge_widget_authenticate")
|
||||
public func vergeWidgetAuthenticate(_ descriptor: Int32) -> Bool {
|
||||
authenticPeer(descriptor, identifier: hostID + ".widget")
|
||||
}
|
||||
|
||||
@_cdecl("verge_widget_reload")
|
||||
public func vergeWidgetReload() {
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WidgetPreviewHost: App {
|
||||
var body: some Scene {
|
||||
WindowGroup("Widget Preview Host") {
|
||||
Text("Use the StylePreviews.swift Canvas to preview routing controls.")
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key><string>VergeWidget</string>
|
||||
<key>CFBundlePackageType</key><string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleDevelopmentRegion</key><string>zh-Hans</string>
|
||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||
<key>NSExtension</key><dict>
|
||||
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,201 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import Foundation
|
||||
|
||||
struct RoutingView: View {
|
||||
let entry: Entry
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
#if DEBUG
|
||||
var previewFamily: WidgetFamily?
|
||||
private var family: WidgetFamily { previewFamily ?? widgetFamily }
|
||||
#else
|
||||
private var family: WidgetFamily { widgetFamily }
|
||||
#endif
|
||||
@Environment(\.widgetRenderingMode) private var systemRenderingMode
|
||||
#if DEBUG
|
||||
var previewRenderingMode: WidgetRenderingMode?
|
||||
var previewMaterial = "automatic"
|
||||
var previewIncreaseContrast = false
|
||||
private var renderingMode: WidgetRenderingMode { previewRenderingMode ?? systemRenderingMode }
|
||||
#else
|
||||
private var renderingMode: WidgetRenderingMode { systemRenderingMode }
|
||||
#endif
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.colorSchemeContrast) private var systemContrast
|
||||
#if DEBUG
|
||||
private var contrast: ColorSchemeContrast { previewIncreaseContrast ? .increased : systemContrast }
|
||||
#else
|
||||
private var contrast: ColorSchemeContrast { systemContrast }
|
||||
#endif
|
||||
@Environment(\.accessibilityReduceMotion) private var systemReduceMotion
|
||||
@Environment(\.accessibilityReduceTransparency) private var systemReduceTransparency
|
||||
#if DEBUG
|
||||
var previewReduceMotion: Bool?
|
||||
var previewReduceTransparency: Bool?
|
||||
private var reduceMotion: Bool { previewReduceMotion ?? systemReduceMotion }
|
||||
private var reduceTransparency: Bool { previewReduceTransparency ?? systemReduceTransparency }
|
||||
#else
|
||||
private var reduceMotion: Bool { systemReduceMotion }
|
||||
private var reduceTransparency: Bool { systemReduceTransparency }
|
||||
#endif
|
||||
private func text(_ key: String) -> String { localized(key, language: entry.status.language) }
|
||||
private let modes = ["rule", "global", "direct"]
|
||||
private var ink: Color { renderingMode == .fullColor ? (colorScheme == .dark ? .white : .black) : .primary }
|
||||
private var secondaryInk: Color { ink.opacity(contrast == .increased ? 0.8 : 0.6) }
|
||||
@ViewBuilder private var selectionBackground: some View {
|
||||
#if DEBUG
|
||||
if previewMaterial == "thin" && renderingMode == .fullColor && !reduceTransparency {
|
||||
Capsule().fill(.thinMaterial)
|
||||
} else {
|
||||
automaticSelectionBackground
|
||||
}
|
||||
#else
|
||||
automaticSelectionBackground
|
||||
#endif
|
||||
}
|
||||
@ViewBuilder private var automaticSelectionBackground: some View {
|
||||
if renderingMode != .fullColor {
|
||||
Capsule().strokeBorder(ink.opacity(0.65), lineWidth: 1)
|
||||
} else if reduceTransparency {
|
||||
Capsule().fill(ink)
|
||||
} else if #available(macOS 26.0, *) {
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
}
|
||||
}
|
||||
private func modeInk(selected: Bool) -> Color {
|
||||
guard selected && reduceTransparency && renderingMode == .fullColor else {
|
||||
return selected ? ink : secondaryInk
|
||||
}
|
||||
return colorScheme == .dark ? .black : .white
|
||||
}
|
||||
private var locale: Locale {
|
||||
let aliases = ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"]
|
||||
let identifier = entry.status.language.map { aliases[$0] ?? $0 } ?? Locale.current.identifier
|
||||
return Locale(identifier: identifier)
|
||||
}
|
||||
private var rtl: Bool { locale.language.characterDirection == .rightToLeft }
|
||||
|
||||
func control(_ title: String, action: String, value: Bool?, icon: String) -> some View {
|
||||
let compact = family == .systemSmall
|
||||
return Button(intent: SetRouting(action, value == true ? "false" : "true")) {
|
||||
let label = HStack(spacing: 4) {
|
||||
if !compact { Image(systemName: icon).font(.caption2) }
|
||||
Text(title).font(.caption)
|
||||
.lineLimit(compact ? 2 : 1)
|
||||
.frame(height: compact ? 30 : nil, alignment: .topLeading)
|
||||
}
|
||||
let toggle = ZStack(alignment: .leading) {
|
||||
Capsule().fill(ink.opacity(value == true ? 0.28 : 0.08))
|
||||
Circle().fill(ink)
|
||||
.frame(width: 13, height: 13)
|
||||
.offset(x: value.map { $0 ? 19.0 : 4.0 } ?? 11.5)
|
||||
}
|
||||
.frame(width: 36, height: 22)
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
if compact {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
label
|
||||
if value == nil { Text(text("widget.unknown")).font(.caption2) } else { toggle }
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
label
|
||||
Spacer(minLength: 0)
|
||||
if value == nil { Text(text("widget.unknown")).font(.caption2) } else { toggle }
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(secondaryInk)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.disabled(value == nil)
|
||||
.transaction { $0.animation = nil; $0.disablesAnimations = true }
|
||||
.invalidatableContent()
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(text(value.map { $0 ? "widget.on" : "widget.off" } ?? "widget.unknown"))
|
||||
}
|
||||
|
||||
@ViewBuilder private var containerMaterial: some View {
|
||||
if reduceTransparency {
|
||||
Rectangle().fill(.background)
|
||||
} else {
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var body: some View {
|
||||
#if DEBUG
|
||||
if previewFamily != nil {
|
||||
content.padding(16)
|
||||
.frame(width: family == .systemSmall ? 170 : 360, height: 170)
|
||||
.background { containerMaterial }
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
} else {
|
||||
content.containerBackground(for: .widget) { containerMaterial }
|
||||
}
|
||||
#else
|
||||
content.containerBackground(for: .widget) { containerMaterial }
|
||||
#endif
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
GeometryReader { geometry in
|
||||
let width = (geometry.size.width - 4) / 3
|
||||
let orderedModes = rtl ? Array(modes.reversed()) : modes
|
||||
let index = orderedModes.firstIndex(of: entry.status.mode ?? "")
|
||||
ZStack(alignment: .leading) {
|
||||
selectionBackground
|
||||
.frame(width: width - 2, height: 26)
|
||||
.offset(x: 3 + CGFloat(index ?? 0) * width)
|
||||
.opacity(index == nil ? 0 : 1)
|
||||
.allowsHitTesting(false)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(orderedModes, id: \.self) { mode in
|
||||
let selected = entry.status.mode == mode
|
||||
let label = Text(text("tray." + mode))
|
||||
.font(selected ? .footnote.weight(.semibold) : .footnote)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 30)
|
||||
.foregroundStyle(modeInk(selected: selected))
|
||||
.contentTransition(reduceMotion ? .identity : .opacity)
|
||||
.contentShape(Rectangle())
|
||||
Button(intent: SetRouting("mode", mode)) { label }
|
||||
.disabled(index == nil)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.zIndex(1)
|
||||
}
|
||||
.invalidatableContent()
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
}
|
||||
.frame(height: 30)
|
||||
HStack(spacing: 8) {
|
||||
control(text("tray.tooltip.tun"), action: "tun", value: entry.status.tun, icon: "network")
|
||||
control(
|
||||
text("tray.systemProxy"), action: "proxy",
|
||||
value: entry.status.proxy, icon: "shield.lefthalf.filled"
|
||||
)
|
||||
}
|
||||
if let error = entry.status.error {
|
||||
Label {
|
||||
Text(text(error)).font(.caption2).lineLimit(2)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
|
||||
}
|
||||
.foregroundStyle(secondaryInk)
|
||||
}
|
||||
}
|
||||
.environment(\.locale, locale)
|
||||
.environment(\.layoutDirection, rtl ? .rightToLeft : .leftToRight)
|
||||
.transaction { $0.animation = nil }
|
||||
.buttonStyle(.plain)
|
||||
.widgetURL(URL(string: "clash-verge://show"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
#if DEBUG && PREVIEW_HOST
|
||||
import UniformTypeIdentifiers
|
||||
import AppKit
|
||||
private struct RoutingStylePreview: View {
|
||||
@State var medium = false
|
||||
@State var dark = false
|
||||
@State var language = "en"
|
||||
@State var mode = "rule"
|
||||
@State var tun = "on"
|
||||
@State var proxy = "off"
|
||||
@State var error = "none"
|
||||
@State var reduceTransparency = false
|
||||
@State var reduceMotion = false
|
||||
@State private var backdrop = "gradient"
|
||||
@State private var material = "automatic"
|
||||
@State private var rendering = "automatic"
|
||||
@State private var increaseContrast = false
|
||||
@State private var importingWallpaper = false
|
||||
@State private var wallpaper: NSImage?
|
||||
@State private var wallpaperName = ""
|
||||
@State private var wallpaperError: String?
|
||||
|
||||
private var renderingOverride: WidgetRenderingMode? {
|
||||
switch rendering {
|
||||
case "fullColor": .fullColor
|
||||
case "accented": .accented
|
||||
case "vibrant": .vibrant
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
private func importWallpaper(_ result: Result<URL, Error>) {
|
||||
do {
|
||||
let url = try result.get()
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let image = NSImage(data: data) else {
|
||||
wallpaperError = "The selected file is not a readable image."
|
||||
return
|
||||
}
|
||||
wallpaper = image
|
||||
wallpaperName = url.lastPathComponent
|
||||
wallpaperError = nil
|
||||
backdrop = "wallpaper"
|
||||
} catch {
|
||||
let failure = error as NSError
|
||||
if failure.domain != NSCocoaErrorDomain || failure.code != NSUserCancelledError {
|
||||
wallpaperError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func state(_ value: String) -> Bool? {
|
||||
value == "unknown" ? nil : value == "on"
|
||||
}
|
||||
private var sample: Status {
|
||||
Status(mode: mode == "unknown" ? nil : mode, tun: state(tun), proxy: state(proxy),
|
||||
error: error == "none" ? nil : "widget." + error, language: language)
|
||||
}
|
||||
|
||||
@ViewBuilder private var background: some View {
|
||||
switch backdrop {
|
||||
case "wallpaper":
|
||||
if let wallpaper {
|
||||
GeometryReader { geometry in
|
||||
Image(nsImage: wallpaper).resizable().scaledToFill()
|
||||
.frame(width: geometry.size.width, height: geometry.size.height).clipped()
|
||||
}
|
||||
}
|
||||
case "light": Color(white: 0.92)
|
||||
case "dark": Color(white: 0.1)
|
||||
case "pattern":
|
||||
Canvas { context, _ in
|
||||
for row in 0..<8 {
|
||||
for column in 0..<14 {
|
||||
let rect = CGRect(x: CGFloat(column) * 32, y: CGFloat(row) * 32, width: 32, height: 32)
|
||||
context.fill(Path(rect), with: .color((row + column).isMultiple(of: 2) ? .white : .black))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
LinearGradient(colors: [.blue, .purple, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
background
|
||||
RoutingView(
|
||||
entry: Entry(date: .now, status: sample),
|
||||
previewFamily: medium ? .systemMedium : .systemSmall,
|
||||
previewRenderingMode: renderingOverride,
|
||||
previewMaterial: material,
|
||||
previewIncreaseContrast: increaseContrast,
|
||||
previewReduceMotion: reduceMotion,
|
||||
previewReduceTransparency: reduceTransparency
|
||||
)
|
||||
.environment(\.colorScheme, dark ? .dark : .light)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(height: 218)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Toggle("Medium", isOn: $medium)
|
||||
Toggle("Dark", isOn: $dark)
|
||||
}
|
||||
Picker("Background", selection: $backdrop) {
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
Text("Gradient").tag("gradient")
|
||||
Text("Contrast pattern").tag("pattern")
|
||||
if wallpaper != nil { Text("Selected wallpaper").tag("wallpaper") }
|
||||
}
|
||||
HStack {
|
||||
Button("Choose wallpaper…") { importingWallpaper = true }
|
||||
if wallpaper != nil {
|
||||
Text(wallpaperName).lineLimit(1).truncationMode(.middle)
|
||||
Button("Clear") {
|
||||
wallpaper = nil; wallpaperName = ""; wallpaperError = nil
|
||||
if backdrop == "wallpaper" { backdrop = "gradient" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let wallpaperError { Text(wallpaperError).font(.caption).foregroundStyle(.red) }
|
||||
Picker("Selected material", selection: $material) {
|
||||
Text("Automatic").tag("automatic")
|
||||
Text("Thin material").tag("thin")
|
||||
if #available(macOS 26.0, *) { Text("Liquid Glass").tag("glass") }
|
||||
}
|
||||
Text("Selected material applies only in full-color rendering with Reduce transparency off.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Picker("Rendering input (approximate)", selection: $rendering) {
|
||||
Text("Automatic").tag("automatic")
|
||||
Text("Full color").tag("fullColor")
|
||||
Text("Accented").tag("accented")
|
||||
Text("Vibrant").tag("vibrant")
|
||||
}
|
||||
Toggle("Increase contrast (approximate)", isOn: $increaseContrast)
|
||||
Picker("Language", selection: $language) {
|
||||
Text("English").tag("en")
|
||||
Text("简体中文").tag("zh")
|
||||
}
|
||||
Picker("Mode", selection: $mode) {
|
||||
Text("Rule").tag("rule")
|
||||
Text("Global").tag("global")
|
||||
Text("Direct").tag("direct")
|
||||
Text("Unknown").tag("unknown")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
HStack {
|
||||
Picker("TUN", selection: $tun) {
|
||||
Text("On").tag("on"); Text("Off").tag("off"); Text("Unknown").tag("unknown")
|
||||
}
|
||||
Picker("Proxy", selection: $proxy) {
|
||||
Text("On").tag("on"); Text("Off").tag("off"); Text("Unknown").tag("unknown")
|
||||
}
|
||||
}
|
||||
Picker("Error", selection: $error) {
|
||||
Text("None").tag("none")
|
||||
Text("Offline").tag("unavailable")
|
||||
Text("Failed").tag("failed")
|
||||
}
|
||||
Toggle("Reduce transparency", isOn: $reduceTransparency)
|
||||
Toggle("Reduce motion", isOn: $reduceMotion)
|
||||
Text(
|
||||
"Style only. Controls change sample data; no proxy requests. "
|
||||
+ "Desktop WidgetKit compositing is not simulated."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 440, height: 570)
|
||||
.fileImporter(isPresented: $importingWallpaper, allowedContentTypes: [.image], onCompletion: importWallpaper)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Style control panel") {
|
||||
RoutingStylePreview()
|
||||
}
|
||||
|
||||
#Preview("Medium · 中文 · Dark") {
|
||||
RoutingStylePreview(medium: true, dark: true, language: "zh", mode: "global")
|
||||
}
|
||||
|
||||
#Preview("Small · English · Reduced transparency") {
|
||||
RoutingStylePreview(language: "en", mode: "direct", reduceTransparency: true)
|
||||
}
|
||||
|
||||
#Preview("中文 · Unknown · Offline") {
|
||||
RoutingStylePreview(
|
||||
medium: true, dark: true, language: "zh", mode: "unknown",
|
||||
tun: "unknown", proxy: "unknown", error: "unavailable"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,65 @@
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import Foundation
|
||||
|
||||
struct SetRouting: AppIntent {
|
||||
static let title: LocalizedStringResource = "widget.update"
|
||||
static let openAppWhenRun = false
|
||||
@Parameter(title: "widget.setting") var action: String
|
||||
@Parameter(title: "widget.value") var value: String
|
||||
init() {}
|
||||
init(_ action: String, _ value: String) { self.action = action; self.value = value }
|
||||
func perform() async throws -> some IntentResult {
|
||||
let status = await Task.detached { exchange(action: action, value: value.isEmpty ? nil : value) }.value
|
||||
if let error = status.error {
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
|
||||
throw WidgetFailure(message: localized(error, language: status.language))
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetFailure: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
struct Entry: TimelineEntry {
|
||||
var date: Date
|
||||
var status: Status
|
||||
}
|
||||
|
||||
struct Provider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> Entry {
|
||||
Entry(date: .now, status: Status(error: "widget.unavailable"))
|
||||
}
|
||||
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(Entry(date: .now, status: Status(mode: "rule", tun: true, proxy: false)))
|
||||
} else {
|
||||
getTimeline(in: context) { completion($0.entries[0]) }
|
||||
}
|
||||
}
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
|
||||
DispatchQueue.global().async {
|
||||
let status = exchange()
|
||||
let now = Date()
|
||||
let unavailable = status.mode == nil || status.tun == nil || status.proxy == nil
|
||||
let refresh = now.addingTimeInterval(unavailable ? 60 : 900)
|
||||
completion(Timeline(entries: [Entry(date: now, status: status)], policy: .after(refresh)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !PREVIEW_HOST
|
||||
@main
|
||||
#endif
|
||||
struct RoutingWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: widgetKind, provider: Provider()) { RoutingView(entry: $0) }
|
||||
.configurationDisplayName("widget.name")
|
||||
.description("widget.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
A00000000000000000000001 = {isa = PBXProject; buildConfigurationList = A00000000000000000000002; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; knownRegions = (en, Base); mainGroup = A00000000000000000000003; productRefGroup = A00000000000000000000004; projectDirPath = ""; projectRoot = ""; targets = (A00000000000000000000005, A00000000000000000000020); };
|
||||
A00000000000000000000002 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000006, A00000000000000000000010); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
|
||||
A00000000000000000000003 = {isa = PBXGroup; children = (A00000000000000000000007, A00000000000000000000008, A00000000000000000000021, A00000000000000000000030, A00000000000000000000040, A00000000000000000000041, A00000000000000000000004); sourceTree = "<group>"; };
|
||||
A00000000000000000000004 = {isa = PBXGroup; children = (A00000000000000000000009, A00000000000000000000022); name = Products; sourceTree = "<group>"; };
|
||||
A00000000000000000000005 = {isa = PBXNativeTarget; buildConfigurationList = A0000000000000000000000A; buildPhases = (A0000000000000000000000B, A0000000000000000000000C, A00000000000000000000012); buildRules = (); dependencies = (); name = VergeWidget; productName = VergeWidget; productReference = A00000000000000000000009; productType = "com.apple.product-type.app-extension"; };
|
||||
A00000000000000000000006 = {isa = XCBuildConfiguration; name = Release; buildSettings = {MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; };
|
||||
A00000000000000000000007 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000009 = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = VergeWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A0000000000000000000000A = {isa = XCConfigurationList; buildConfigurations = (A0000000000000000000000D, A00000000000000000000011); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
|
||||
A0000000000000000000000B = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A0000000000000000000000E, A0000000000000000000000F, A00000000000000000000042, A00000000000000000000043); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A0000000000000000000000C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A0000000000000000000000D = {isa = XCBuildConfiguration; name = Release; buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "$(WIDGET_ENTITLEMENTS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "$(WIDGET_INFO_PLIST)";
|
||||
WIDGET_INFO_PLIST = "$(SRCROOT)/Preview.plist";
|
||||
WIDGET_ENTITLEMENTS = "";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.widget";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = WIDGET_EXTENSION;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
}; };
|
||||
A00000000000000000000011 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "$(WIDGET_ENTITLEMENTS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "$(WIDGET_INFO_PLIST)";
|
||||
WIDGET_INFO_PLIST = "$(SRCROOT)/Preview.plist";
|
||||
WIDGET_ENTITLEMENTS = "";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.preview.widget";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WIDGET_EXTENSION DEBUG";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
}; };
|
||||
A0000000000000000000000E = {isa = PBXBuildFile; fileRef = A00000000000000000000007; };
|
||||
A0000000000000000000000F = {isa = PBXBuildFile; fileRef = A00000000000000000000008; };
|
||||
A00000000000000000000010 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; };
|
||||
A00000000000000000000012 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = (); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; alwaysOutOfDate = 1; shellScript = "export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH\nnode \"$SRCROOT/../build/localize.mjs\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n"; };
|
||||
A00000000000000000000020 = {isa = PBXNativeTarget; buildConfigurationList = A00000000000000000000023; buildPhases = (A00000000000000000000024, A00000000000000000000025, A00000000000000000000026, A00000000000000000000034); buildRules = (); dependencies = (A00000000000000000000027); name = PreviewHost; productName = PreviewHost; productReference = A00000000000000000000022; productType = "com.apple.product-type.application"; };
|
||||
A00000000000000000000021 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Host.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000022 = {isa = PBXFileReference; explicitFileType = wrapper.application; path = PreviewHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A00000000000000000000023 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000028); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
|
||||
A00000000000000000000024 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000029, A00000000000000000000031, A00000000000000000000032, A00000000000000000000033, A00000000000000000000044, A00000000000000000000045); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A00000000000000000000025 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A00000000000000000000026 = {isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = (A0000000000000000000002A); name = "Embed Widget"; runOnlyForDeploymentPostprocessing = 0; };
|
||||
A00000000000000000000027 = {isa = PBXTargetDependency; target = A00000000000000000000005; targetProxy = A0000000000000000000002B; };
|
||||
A00000000000000000000028 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Widget Preview Host"; PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.preview"; PRODUCT_NAME = "$(TARGET_NAME)"; MARKETING_VERSION = 1.0; CURRENT_PROJECT_VERSION = 1; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG PREVIEW_HOST WIDGET_EXTENSION"; ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_PREVIEWS = YES; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); }; };
|
||||
A00000000000000000000029 = {isa = PBXBuildFile; fileRef = A00000000000000000000021; };
|
||||
A0000000000000000000002A = {isa = PBXBuildFile; fileRef = A00000000000000000000009; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy); }; };
|
||||
A0000000000000000000002B = {isa = PBXContainerItemProxy; containerPortal = A00000000000000000000001; proxyType = 1; remoteGlobalIDString = A00000000000000000000005; remoteInfo = VergeWidget; };
|
||||
A00000000000000000000030 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StylePreviews.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000031 = {isa = PBXBuildFile; fileRef = A00000000000000000000007; };
|
||||
A00000000000000000000032 = {isa = PBXBuildFile; fileRef = A00000000000000000000008; };
|
||||
A00000000000000000000033 = {isa = PBXBuildFile; fileRef = A00000000000000000000030; };
|
||||
A00000000000000000000034 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = (); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; alwaysOutOfDate = 1; shellScript = "export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH\nnode \"$SRCROOT/../build/localize.mjs\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n"; };
|
||||
A00000000000000000000040 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetClient.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000041 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingView.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000042 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; };
|
||||
A00000000000000000000043 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; };
|
||||
A00000000000000000000044 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; };
|
||||
A00000000000000000000045 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; };
|
||||
};
|
||||
rootObject = A00000000000000000000001;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000020"
|
||||
BuildableName = "PreviewHost.app"
|
||||
BlueprintName = "PreviewHost"
|
||||
ReferencedContainer = "container:Widget.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "NO">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000020"
|
||||
BuildableName = "PreviewHost.app"
|
||||
BlueprintName = "PreviewHost"
|
||||
ReferencedContainer = "container:Widget.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Debug"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000020"
|
||||
BuildableName = "PreviewHost.app"
|
||||
BlueprintName = "PreviewHost"
|
||||
ReferencedContainer = "container:Widget.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,104 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct Status: Codable {
|
||||
var mode: String?
|
||||
var tun: Bool?
|
||||
var proxy: Bool?
|
||||
var error: String?
|
||||
var language: String?
|
||||
}
|
||||
|
||||
func localized(_ key: String, language: String? = nil) -> String {
|
||||
let code = language.map { ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"][$0] ?? $0 }
|
||||
let path = code.flatMap { Bundle.main.path(forResource: $0, ofType: "lproj") }
|
||||
let bundle = path.flatMap(Bundle.init(path:)) ?? .main
|
||||
return bundle.localizedString(forKey: key, value: nil, table: nil)
|
||||
}
|
||||
|
||||
private func connectSocket(_ path: String) -> Int32? {
|
||||
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard descriptor >= 0 else { return nil }
|
||||
var timeout = timeval(tv_sec: 8, tv_usec: 0)
|
||||
var noSignal: Int32 = 1
|
||||
setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout)))
|
||||
setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout)))
|
||||
setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal)))
|
||||
var address = sockaddr_un()
|
||||
address.sun_family = sa_family_t(AF_UNIX)
|
||||
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
|
||||
path.withCString { source in
|
||||
withUnsafeMutableBytes(of: &address.sun_path) { bytes in
|
||||
bytes.copyMemory(from: UnsafeRawBufferPointer(start: source, count: path.utf8.count + 1))
|
||||
}
|
||||
}
|
||||
let connected = withUnsafePointer(to: &address) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
connect(descriptor, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
guard connected == 0 else {
|
||||
close(descriptor)
|
||||
return nil
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
private func sendRequest(_ data: Data, descriptor: Int32) -> Bool {
|
||||
data.withUnsafeBytes { bytes in
|
||||
var offset = 0
|
||||
while offset < bytes.count {
|
||||
let count = write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
|
||||
if count < 0 && errno == EINTR { continue }
|
||||
guard count > 0 else { return false }
|
||||
offset += count
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func readResponse(descriptor: Int32) -> Status? {
|
||||
var response = Data(count: 4096)
|
||||
var received = 0
|
||||
while received < 4096 {
|
||||
let count = response.withUnsafeMutableBytes { bytes in
|
||||
read(descriptor, bytes.baseAddress!.advanced(by: received), bytes.count - received)
|
||||
}
|
||||
if count < 0 && errno == EINTR { continue }
|
||||
guard count > 0 else { return nil }
|
||||
let end = received + count
|
||||
if let newline = response[received..<end].firstIndex(of: 10) {
|
||||
response.count = newline
|
||||
return try? JSONDecoder().decode(Status.self, from: response)
|
||||
}
|
||||
received = end
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exchange(action: String = "status", value: String? = nil) -> Status {
|
||||
#if PREVIEW_HOST
|
||||
return Status(mode: "rule", tun: true, proxy: false, language: "en")
|
||||
#else
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
|
||||
return Status(mode: "rule", tun: true, proxy: false, language: "en")
|
||||
}
|
||||
#endif
|
||||
func unavailable(_ key: String) -> Status { Status(error: key) }
|
||||
guard let path = socketPath(), path.utf8.count < 104 else {
|
||||
return unavailable("widget.unavailable")
|
||||
}
|
||||
guard let descriptor = connectSocket(path) else { return unavailable("widget.unavailable") }
|
||||
defer { close(descriptor) }
|
||||
guard authenticPeer(descriptor, identifier: hostID) else { return unavailable("widget.unavailable") }
|
||||
var request = ["action": action]
|
||||
if let value { request["value"] = value }
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: request) else {
|
||||
return unavailable("widget.failed")
|
||||
}
|
||||
data.append(10)
|
||||
guard sendRequest(data, descriptor: descriptor) else { return unavailable("widget.failed") }
|
||||
return readResponse(descriptor: descriptor) ?? unavailable("widget.failed")
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::future::Future;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod server;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use server::{reload, start};
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Mode {
|
||||
Rule,
|
||||
Global,
|
||||
Direct,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rule => "rule",
|
||||
Self::Global => "global",
|
||||
Self::Direct => "direct",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Action {
|
||||
Mode(Mode),
|
||||
Tun(bool),
|
||||
Proxy(bool),
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct Status {
|
||||
pub mode: Option<Mode>,
|
||||
pub tun: Option<bool>,
|
||||
pub proxy: Option<bool>,
|
||||
pub error: Option<String>,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn confirms(&self, action: Action) -> bool {
|
||||
match action {
|
||||
Action::Mode(value) => self.mode == Some(value),
|
||||
Action::Tun(value) => self.tun == Some(value),
|
||||
Action::Proxy(value) => self.proxy == Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Backend: Send + Sync + 'static {
|
||||
fn snapshot(&self) -> impl Future<Output = Status> + Send;
|
||||
fn language(&self) -> impl Future<Output = String> + Send;
|
||||
fn apply(&self, action: Action) -> impl Future<Output = Result<(), String>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use crate::{Action, Backend, Mode, Status};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
ffi::{CStr, CString},
|
||||
fs, mem,
|
||||
os::fd::AsRawFd as _,
|
||||
os::unix::fs::PermissionsExt as _,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader},
|
||||
net::UnixListener,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
struct Native {
|
||||
path: unsafe extern "C" fn() -> *mut libc::c_char,
|
||||
authenticate: unsafe extern "C" fn(i32) -> bool,
|
||||
reload: unsafe extern "C" fn(),
|
||||
}
|
||||
static NATIVE: OnceLock<Native> = OnceLock::new();
|
||||
|
||||
fn load_native() -> anyhow::Result<Native> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let contents = executable
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| anyhow::anyhow!("Not an app bundle"))?;
|
||||
let path = CString::new(
|
||||
contents
|
||||
.join("Frameworks/VergeWidgetBridge.dylib")
|
||||
.as_os_str()
|
||||
.as_encoded_bytes(),
|
||||
)?;
|
||||
unsafe {
|
||||
let library = libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL);
|
||||
anyhow::ensure!(!library.is_null(), "Widget bridge unavailable");
|
||||
let path = libc::dlsym(library, c"verge_widget_path".as_ptr());
|
||||
let authenticate = libc::dlsym(library, c"verge_widget_authenticate".as_ptr());
|
||||
let reload = libc::dlsym(library, c"verge_widget_reload".as_ptr());
|
||||
anyhow::ensure!(
|
||||
!path.is_null() && !authenticate.is_null() && !reload.is_null(),
|
||||
"Invalid widget bridge"
|
||||
);
|
||||
Ok(Native {
|
||||
path: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn() -> *mut libc::c_char>(path),
|
||||
authenticate: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn(i32) -> bool>(authenticate),
|
||||
reload: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn()>(reload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static MUTATING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn reload() {
|
||||
if MUTATING.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Some(native) = NATIVE.get() {
|
||||
unsafe { (native.reload)() };
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request<'a> {
|
||||
#[serde(borrow)]
|
||||
action: Cow<'a, str>,
|
||||
#[serde(borrow)]
|
||||
value: Option<RequestValue<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(transparent)]
|
||||
struct RequestValue<'a>(#[serde(borrow)] Cow<'a, str>);
|
||||
|
||||
impl Request<'_> {
|
||||
fn action(&self) -> Result<Option<Action>, &'static str> {
|
||||
match (self.action.as_ref(), self.value.as_ref().map(|value| value.0.as_ref())) {
|
||||
("status", None) => Ok(None),
|
||||
("mode", Some("rule")) => Ok(Some(Action::Mode(Mode::Rule))),
|
||||
("mode", Some("global")) => Ok(Some(Action::Mode(Mode::Global))),
|
||||
("mode", Some("direct")) => Ok(Some(Action::Mode(Mode::Direct))),
|
||||
("tun", Some(value @ ("true" | "false"))) => Ok(Some(Action::Tun(value == "true"))),
|
||||
("proxy", Some(value @ ("true" | "false"))) => Ok(Some(Action::Proxy(value == "true"))),
|
||||
_ => Err("Invalid widget request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(backend: impl Backend) -> anyhow::Result<()> {
|
||||
let native = load_native()?;
|
||||
let raw = unsafe { (native.path)() };
|
||||
anyhow::ensure!(!raw.is_null(), "App Group container unavailable");
|
||||
let path = unsafe { CStr::from_ptr(raw).to_string_lossy().into_owned() };
|
||||
unsafe { libc::free(raw.cast()) };
|
||||
// A second process must not unlink the live app's listener.
|
||||
if tokio::net::UnixStream::connect(&path).await.is_ok() {
|
||||
anyhow::bail!("Widget listener already running");
|
||||
}
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => (),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
let _ = NATIVE.set(native);
|
||||
reload();
|
||||
let mut last_error = None;
|
||||
let mut pending = None;
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let Some(native) = NATIVE.get() else { break };
|
||||
if !unsafe { (native.authenticate)(stream.as_raw_fd()) } {
|
||||
continue;
|
||||
}
|
||||
// Serialize widget mutations; never cancel a backend transaction on client timeout.
|
||||
let mut stream = BufReader::new(stream);
|
||||
let mut line = Vec::new();
|
||||
let read = timeout(
|
||||
Duration::from_secs(2),
|
||||
(&mut stream).take(1024).read_until(b'\n', &mut line),
|
||||
)
|
||||
.await;
|
||||
if !matches!(read, Ok(Ok(_))) || line.last() != Some(&b'\n') {
|
||||
continue;
|
||||
}
|
||||
let request = match serde_json::from_slice::<Request>(&line) {
|
||||
Ok(request) => request,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let action = match request.action() {
|
||||
Ok(action) => action,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let changed = action.is_some();
|
||||
// Coalesce hooks across the whole transaction, including errors and disconnected clients.
|
||||
MUTATING.store(changed, Ordering::Release);
|
||||
let refresh = scopeguard::guard(changed, |changed| {
|
||||
MUTATING.store(false, Ordering::Release);
|
||||
if changed {
|
||||
reload();
|
||||
}
|
||||
});
|
||||
// Mutations reply without re-reading state so the widget repaints one round trip
|
||||
// sooner; the status read after the reload confirms the pending action.
|
||||
let mut reply = match action {
|
||||
Some(action) => {
|
||||
match backend.apply(action).await {
|
||||
Ok(()) => {
|
||||
last_error = None;
|
||||
pending = Some(action);
|
||||
}
|
||||
Err(error) => {
|
||||
pending = None;
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
Status::default()
|
||||
}
|
||||
None => {
|
||||
let state = backend.snapshot().await;
|
||||
if let Some(action) = pending.take()
|
||||
&& !state.confirms(action)
|
||||
{
|
||||
last_error = Some("widget.failed".into());
|
||||
}
|
||||
state
|
||||
}
|
||||
};
|
||||
if action.is_some() {
|
||||
reply.language = backend.language().await;
|
||||
}
|
||||
reply.error = last_error.take();
|
||||
let serialized = serde_json::to_vec(&reply);
|
||||
last_error = reply.error.take();
|
||||
let mut bytes = serialized?;
|
||||
bytes.push(b'\n');
|
||||
let _ = timeout(Duration::from_secs(2), stream.get_mut().write_all(&bytes)).await;
|
||||
drop(refresh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start(backend: impl Backend) {
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
if STARTED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Darwin 23 is macOS 14; older hosts must never load the WidgetKit bridge.
|
||||
let mut version = [0u8; 64];
|
||||
let mut length = version.len();
|
||||
let result = unsafe {
|
||||
libc::sysctlbyname(
|
||||
c"kern.osrelease".as_ptr(),
|
||||
version.as_mut_ptr().cast(),
|
||||
&mut length,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
let major = std::str::from_utf8(&version)
|
||||
.ok()
|
||||
.and_then(|v| v.split('.').next()?.parse::<u32>().ok());
|
||||
if result != 0 || major.is_none_or(|major| major < 23) {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = serve(backend).await {
|
||||
log::warn!("Widget unavailable: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,147 +1,3 @@
|
||||
## 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
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ export default defineConfig([
|
||||
'eslint.config.ts',
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'crates/clash-verge-widget/build/*.mjs',
|
||||
'tests/*.ts',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
@@ -143,7 +144,7 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/*.mjs'],
|
||||
files: ['scripts/*.mjs', 'crates/clash-verge-widget/build/*.mjs'],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
|
||||
+57
-57
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.6",
|
||||
"version": "2.5.4",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
@@ -10,8 +10,8 @@
|
||||
"dev:diff": "node scripts/dev.mjs dev",
|
||||
"dev:trace": "node scripts/dev.mjs trace",
|
||||
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
|
||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
||||
"build": "node scripts/build.mjs",
|
||||
"build:fast": "node scripts/build.mjs -- --profile fast-release",
|
||||
"tauri": "tauri",
|
||||
"web:dev": "vite",
|
||||
"web:build": "tsc --noEmit && vite build",
|
||||
@@ -49,90 +49,90 @@
|
||||
"@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",
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@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",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.5.9",
|
||||
"@tauri-apps/plugin-opener": "2.5.4",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"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",
|
||||
"dayjs": "1.11.21",
|
||||
"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-i18next": "17.0.14",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.11",
|
||||
"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",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"adm-zip": "^0.6.1",
|
||||
"axios": "^1.20.0",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@wdio/tauri-service": "1.3.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",
|
||||
"knip": "6.32.1",
|
||||
"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",
|
||||
"webdriverio": "9.31.9",
|
||||
"ws": "8.21.3"
|
||||
"vitest": "^5.0.0",
|
||||
"webdriverio": "9.30.0",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
@@ -144,5 +144,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.5.1"
|
||||
"packageManager": "pnpm@12.4.1"
|
||||
}
|
||||
|
||||
Generated
+1335
-1301
File diff suppressed because it is too large
Load Diff
+1
-17
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = join(import.meta.dirname, '..')
|
||||
|
||||
const forward = () => {
|
||||
const tauri = spawnSync(
|
||||
'pnpm',
|
||||
['exec', 'tauri', 'build', ...process.argv.slice(2)],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=4096',
|
||||
},
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
)
|
||||
process.exit(tauri.status ?? 1)
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') forward()
|
||||
|
||||
if (process.env.CLASH_VERGE_WIDGET === '0') {
|
||||
console.warn(
|
||||
'CLASH_VERGE_WIDGET=0: building without the desktop widget extension.',
|
||||
)
|
||||
process.env.APPLE_SIGNING_IDENTITY = '-'
|
||||
forward()
|
||||
}
|
||||
|
||||
const run = (command, args) => {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' })
|
||||
if (result.error) return ''
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
let identity = process.env.APPLE_SIGNING_IDENTITY
|
||||
if (!identity) {
|
||||
const mac = JSON.parse(
|
||||
readFileSync(join(root, 'src-tauri/tauri.macos.conf.json'), 'utf8'),
|
||||
)
|
||||
identity = mac.bundle?.macOS?.signingIdentity || undefined
|
||||
}
|
||||
if (!identity) {
|
||||
const identities = [
|
||||
...run('security', ['find-identity', '-v', '-p', 'codesigning']).matchAll(
|
||||
/\b([A-F0-9]{40}) "([^"]+)"/g,
|
||||
),
|
||||
]
|
||||
if (identities.length === 1) identity = identities[0][2]
|
||||
}
|
||||
if (!identity) {
|
||||
console.error(
|
||||
'macOS packaging needs the widget extension signed with the host: set APPLE_SIGNING_IDENTITY to a valid certificate, or export CLASH_VERGE_WIDGET=0 to build without the widget.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.env.APPLE_SIGNING_IDENTITY = identity
|
||||
forward()
|
||||
@@ -49,7 +49,7 @@ const FRONTEND_EXTENSIONS = new Set([
|
||||
'.vue',
|
||||
'.json',
|
||||
])
|
||||
const BACKEND_EXTENSIONS = new Set(['.rs'])
|
||||
const BACKEND_EXTENSIONS = new Set(['.rs', '.swift'])
|
||||
|
||||
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
|
||||
+141
-85
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
-18
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.6"
|
||||
version = "2.5.4"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -59,13 +59,13 @@ smartstring = { workspace = true, features = ["serde"] }
|
||||
bitflags = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
warp = { version = "0.4.3", features = ["server"] }
|
||||
open = "5.4.4"
|
||||
open = "5.4.1"
|
||||
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
||||
dirs = "7.0.0"
|
||||
dunce = "1.0.5"
|
||||
nanoid = "0.5"
|
||||
chrono = { workspace = true }
|
||||
boa_engine = "0.22.0"
|
||||
boa_engine = "0.21.1"
|
||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { workspace = true }
|
||||
@@ -74,37 +74,37 @@ 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-shell = "2.3.5"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
zip = "8.6.0"
|
||||
flate2 = "1.1.10"
|
||||
flate2 = "1.1.9"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
aes-gcm = { version = "0.11.0" }
|
||||
base64 = "0.23.1"
|
||||
sha2 = "0.11"
|
||||
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 = "2.3.3"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.6.1"
|
||||
tauri-plugin-http = "2.5.9"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.1.0", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.4", version = "2.7.4", features = [
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.9", version = "2.6.9", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.5"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
tokio-rustls = "0.26.4"
|
||||
rustls = { version = "0.23.43", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
bytes = "1.12.1"
|
||||
@@ -114,7 +114,10 @@ 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 }
|
||||
clash-verge-widget = { workspace = true }
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
|
||||
core-foundation = "0.9"
|
||||
system-configuration = "0.7"
|
||||
objc2 = "0.6.4"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
@@ -148,7 +151,7 @@ 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-updater = "2.10.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() -> bool {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+33
-125
@@ -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,56 +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,
|
||||
// Force-enable confirmation is valid only for the current app session.
|
||||
#[serde(skip)]
|
||||
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;
|
||||
@@ -151,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;
|
||||
@@ -187,10 +152,10 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -198,28 +163,24 @@ mod tests {
|
||||
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 settings = confirmed.dns_settings_for("one");
|
||||
assert!(DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation).enabled);
|
||||
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.clone(), 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(())
|
||||
}
|
||||
@@ -230,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
|
||||
@@ -36,10 +36,14 @@ impl Handle {
|
||||
}
|
||||
|
||||
pub fn refresh_clash() {
|
||||
#[cfg(target_os = "macos")]
|
||||
clash_verge_widget::reload();
|
||||
Self::send_event(FrontendEvent::RefreshClash);
|
||||
}
|
||||
|
||||
pub fn refresh_verge() {
|
||||
#[cfg(target_os = "macos")]
|
||||
clash_verge_widget::reload();
|
||||
Self::send_event(FrontendEvent::RefreshVerge);
|
||||
}
|
||||
|
||||
@@ -56,6 +60,8 @@ impl Handle {
|
||||
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
|
||||
/// stopped or that the Service came back.
|
||||
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
|
||||
#[cfg(target_os = "macos")]
|
||||
clash_verge_widget::reload();
|
||||
let Ok(state) = serde_json::to_value(state) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -200,6 +200,7 @@ impl Hotkey {
|
||||
HotkeyFunction::Hide => {
|
||||
AsyncHandler::spawn(async move || {
|
||||
feat::hide().await;
|
||||
notify_event(NotificationEvent::AppHidden).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -528,7 +476,10 @@ impl CoreManager {
|
||||
// rather than fail — only if the watcher is actually live; a user toggling while
|
||||
// offline still fails fast.
|
||||
#[cfg(target_os = "macos")]
|
||||
if crate::core::network_watch::is_armed()
|
||||
let watcher_armed = crate::core::network_watch::is_armed();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let watcher_armed = false;
|
||||
if watcher_armed
|
||||
&& !crate::utils::resolve::is_resolve_done()
|
||||
&& Config::verge()
|
||||
.await
|
||||
@@ -594,8 +545,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 +553,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 +572,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();
|
||||
}
|
||||
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 +607,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 +1111,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 +1371,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 +1386,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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -12,13 +12,15 @@ 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;
|
||||
pub mod tray;
|
||||
pub mod updater;
|
||||
pub mod validate;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod widget;
|
||||
pub mod win_uwp;
|
||||
|
||||
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
|
||||
|
||||
@@ -5,35 +5,53 @@
|
||||
|
||||
use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::Mutex;
|
||||
use sysproxy::NetworkServiceMonitor;
|
||||
use core_foundation::{
|
||||
array::CFArray,
|
||||
runloop::{CFRunLoop, kCFRunLoopCommonModes},
|
||||
string::CFString,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext};
|
||||
|
||||
static MONITOR: Mutex<Option<NetworkServiceMonitor>> = Mutex::new(None);
|
||||
/// Set once the subscription is live. Startup defers the proxy write to the watcher only then.
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn is_armed() -> bool {
|
||||
MONITOR.lock().is_some()
|
||||
ARMED.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Subscribe to the primary IPv4 service on the main run loop. Armed on return, so a service
|
||||
/// appearing from here on is never missed.
|
||||
pub fn start() {
|
||||
match NetworkServiceMonitor::start(|| {
|
||||
AsyncHandler::spawn(reapply);
|
||||
}) {
|
||||
Ok(monitor) => {
|
||||
*MONITOR.lock() = Some(monitor);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
"network watch armed; the system proxy is re-applied on network changes"
|
||||
);
|
||||
}
|
||||
Err(error) => logging!(
|
||||
let store = SCDynamicStoreBuilder::new("clash-verge-rev")
|
||||
.callback_context(SCDynamicStoreCallBackContext {
|
||||
callout: on_change,
|
||||
info: (),
|
||||
})
|
||||
.build();
|
||||
let keys = CFArray::from_CFTypes(&[CFString::from_static_string("State:/Network/Global/IPv4")]);
|
||||
let Some(source) = store
|
||||
.filter(|store| store.set_notification_keys(&keys, &CFArray::<CFString>::from_CFTypes(&[])))
|
||||
.and_then(|store| store.create_run_loop_source())
|
||||
else {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"could not watch the network; the proxy is not re-applied on changes: {error}"
|
||||
),
|
||||
}
|
||||
"could not watch the network; the proxy is not re-applied on changes"
|
||||
);
|
||||
return;
|
||||
};
|
||||
CFRunLoop::get_main().add_source(&source, unsafe { kCFRunLoopCommonModes });
|
||||
ARMED.store(true, Ordering::Release);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
"network watch armed; the system proxy is re-applied on network changes"
|
||||
);
|
||||
}
|
||||
|
||||
fn on_change(_: SCDynamicStore, _: CFArray<CFString>, (): &mut ()) {
|
||||
AsyncHandler::spawn(reapply);
|
||||
}
|
||||
|
||||
async fn reapply() {
|
||||
@@ -48,17 +66,10 @@ async fn reapply() {
|
||||
.latest_arc()
|
||||
.enable_system_proxy
|
||||
.unwrap_or_default()
|
||||
|| !proxy_control::has_network_service().await
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !proxy_control::has_network_service().await {
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
"network changed without a service to write the proxy on; waiting for the next change"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = manager.apply_proxy_after_start().await {
|
||||
logging!(
|
||||
warn,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -385,11 +385,12 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
|
||||
}
|
||||
|
||||
/// Whether macOS has a network service to write the proxy on right now.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn has_network_service() -> bool {
|
||||
tokio::task::spawn_blocking(|| sysproxy::Sysproxy::has_network_service().unwrap_or(true))
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
tokio::task::spawn_blocking(
|
||||
|| !matches!(sysproxy::Sysproxy::get_system_proxy(), Err(error) if is_missing_network_service(&error)),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn is_reportable(error: &anyhow::Error) -> bool {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,7 +21,6 @@ pub struct ServiceVersionReply {
|
||||
pub enum ServiceVersionCheck {
|
||||
Ready,
|
||||
NeedsReinstall(String),
|
||||
CoreUnavailable(String),
|
||||
}
|
||||
|
||||
/// What a live probe of the currently-installed Service told us.
|
||||
@@ -36,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!("approved core was rejected: {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,
|
||||
@@ -75,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,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()),
|
||||
@@ -130,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,
|
||||
@@ -167,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()),
|
||||
@@ -183,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),
|
||||
@@ -204,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),
|
||||
@@ -223,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,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<()> {
|
||||
|
||||
+384
-277
@@ -5,7 +5,7 @@ use crate::{
|
||||
core::{
|
||||
CoreManager,
|
||||
handle::Handle,
|
||||
manager::{CoreFailure, RunningMode},
|
||||
manager::RunningMode,
|
||||
owner_identity::current_owner_credentials,
|
||||
proxy_control,
|
||||
runstate::{
|
||||
@@ -21,13 +21,13 @@ use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::Draft;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{
|
||||
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
|
||||
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
|
||||
StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||
MacosProxyConfig, OwnerCredentials, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
|
||||
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
env::current_exe,
|
||||
future::Future,
|
||||
@@ -39,36 +39,6 @@ use std::{
|
||||
|
||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback() {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::sidecar_fallback", "");
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_fallback_notice() -> bool {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_repair_notice() -> bool {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns the command that gives the app data root back to this account.
|
||||
pub(crate) fn take_service_owner_notice() -> Option<String> {
|
||||
PENDING_SERVICE_OWNER_NOTICE.lock().take()
|
||||
}
|
||||
|
||||
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
|
||||
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
|
||||
return None;
|
||||
};
|
||||
let path = credentials.app_data_dir.replace('\'', r"'\''");
|
||||
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
|
||||
}
|
||||
|
||||
/// Capabilities of the service session that owns the running Core.
|
||||
/// They are discarded with that session rather than cached across service upgrades.
|
||||
@@ -242,7 +212,7 @@ fn macos_service_install_marker_exists() -> std::io::Result<bool> {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_registered_service() -> Result<Option<windows_service::service::Service>> {
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
use windows_service::{
|
||||
Error as WindowsServiceError,
|
||||
service::ServiceAccess,
|
||||
@@ -255,36 +225,17 @@ fn open_registered_service() -> Result<Option<windows_service::service::Service>
|
||||
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS,
|
||||
) {
|
||||
Ok(service) => Ok(Some(service)),
|
||||
Ok(service) => {
|
||||
drop(service);
|
||||
Ok(true)
|
||||
}
|
||||
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
||||
Ok(None)
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(error).context("failed to inspect Windows service registration"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
Ok(open_registered_service()?.is_some())
|
||||
}
|
||||
|
||||
/// Whether IPC cannot succeed until the service is started again. A service that is starting, or
|
||||
/// that the SCM has not started yet this boot, is left to the IPC retries, which wait for it.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn service_stopped() -> Result<bool> {
|
||||
use windows_service::service::{ServiceExitCode, ServiceState};
|
||||
|
||||
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
||||
let Some(service) = open_registered_service()? else {
|
||||
return Ok(true);
|
||||
};
|
||||
let status = service
|
||||
.query_status()
|
||||
.context("failed to query Windows service status")?;
|
||||
Ok(status.current_state == ServiceState::Stopped
|
||||
&& status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
|
||||
@@ -520,16 +471,44 @@ fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
|
||||
Ok(sibling)
|
||||
}
|
||||
|
||||
/// 卸载服务前以 root 清理残留 core 和 IPC 套接字。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_force_stop_core_shell() -> String {
|
||||
use crate::config::IVerge;
|
||||
|
||||
// 只清理 root 拥有的服务内核。
|
||||
let mut parts: Vec<String> = IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| format!("/usr/bin/pkill -U root -x {core} 2>/dev/null || true"))
|
||||
.collect();
|
||||
|
||||
if let Ok(ipc) = dirs::ipc_path()
|
||||
&& let Ok(ipc_str) = dirs::path_to_str(&ipc)
|
||||
{
|
||||
// 转义单引号,避免破坏 shell 参数。
|
||||
let escaped = ipc_str.replace('\'', r"'\''");
|
||||
parts.push(format!("/bin/rm -f '{escaped}' 2>/dev/null || true"));
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn escape_osascript_double_quoted_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn macos_install_shell(install_path: &Path, gid: u32) -> String {
|
||||
let install_quoted = shell_single_quote(&install_path.to_string_lossy());
|
||||
format!("cd /; CLASH_VERGE_SERVICE_GID={gid} {install_quoted}")
|
||||
}
|
||||
|
||||
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
|
||||
#[cfg(feature = "verge-dev")]
|
||||
{
|
||||
@@ -583,6 +562,54 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_service() -> Result<()> {
|
||||
use std::process::Output;
|
||||
logging!(info, Type::Service, "install service");
|
||||
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
||||
})?;
|
||||
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let token = Token::with_current_process()?;
|
||||
let level = token.privilege_level()?;
|
||||
let output = match level {
|
||||
PrivilegeLevel::NotPrivileged => {
|
||||
let status = RunasCommand::new(&install_path).show(false).status()?;
|
||||
Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// StdCommand returns Output directly
|
||||
StdCommand::new(&install_path).creation_flags(0x08000000).output()?
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
"failed to install service code: {}, details: {}",
|
||||
code,
|
||||
err
|
||||
);
|
||||
bail!("failed to install service code: {}, details: {}", code, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn uninstall_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "uninstall service");
|
||||
@@ -634,6 +661,55 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn install_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "install service");
|
||||
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let elevator = crate::utils::help::linux_elevator();
|
||||
let output = if linux_running_as_root() {
|
||||
StdCommand::new(&install_path).output()?
|
||||
} else {
|
||||
let result = StdCommand::new(&elevator)
|
||||
.arg("--disable-internal-agent")
|
||||
.arg(&install_path)
|
||||
.output()?;
|
||||
|
||||
// 如果 pkexec 执行失败,回退到 sudo
|
||||
if !result.status.success() && elevator.contains("pkexec") {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"pkexec failed with code {}, falling back to sudo",
|
||||
result.status.code().unwrap_or(-1)
|
||||
);
|
||||
StdCommand::new("sudo").arg(&install_path).output()?
|
||||
} else {
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
"failed to install service code: {}, details: {}",
|
||||
code,
|
||||
err
|
||||
);
|
||||
bail!("failed to install service code: {}, details: {}", code, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_running_as_root() -> bool {
|
||||
use crate::core::handle;
|
||||
@@ -660,8 +736,9 @@ fn uninstall_service() -> Result<()> {
|
||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
||||
|
||||
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
|
||||
// 先清理服务残留,再执行卸载器。
|
||||
let uninstall_quoted = shell_single_quote(&uninstall_shell);
|
||||
let shell = format!("cd /; {uninstall_quoted}");
|
||||
let shell = format!("cd /; {}; {uninstall_quoted}", macos_force_stop_core_shell());
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
@@ -679,46 +756,63 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn install_service() -> Result<()> {
|
||||
let executable = current_exe()?;
|
||||
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| {
|
||||
let name = format!("{core}{}", std::env::consts::EXE_SUFFIX);
|
||||
clash_verge_service_ipc::management::CoreSource {
|
||||
path: executable.with_file_name(&name),
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
invoke_service_install(&cores, false)
|
||||
logging!(info, Type::Service, "install service");
|
||||
|
||||
let binary_path = packaged_service_tool_path("clash-verge-service", dirs::service_path)?;
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
macos_service_tool_path(&binary_path)?;
|
||||
let install_path = macos_service_tool_path(&install_path)?;
|
||||
|
||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
||||
|
||||
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
|
||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
||||
let shell = macos_install_shell(&install_path, gid);
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
"failed to install service code: {}, details: {}",
|
||||
code,
|
||||
err
|
||||
);
|
||||
bail!("failed to install service code: {}, details: {}", code, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invoke_service_install(cores: &[clash_verge_service_ipc::management::CoreSource], core_only: bool) -> Result<()> {
|
||||
let name = format!("clash-verge-service-install{}", std::env::consts::EXE_SUFFIX);
|
||||
let installer = packaged_service_tool_path(&name, || {
|
||||
#[cfg(target_os = "linux")]
|
||||
let executable = tauri::utils::platform::current_exe()?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let executable = dirs::service_path()?;
|
||||
Ok(executable.with_file_name(&name))
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
let gid = Some(tauri_plugin_clash_verge_sysinfo::current_gid());
|
||||
#[cfg(windows)]
|
||||
let gid = None;
|
||||
clash_verge_service_ipc::management::install(
|
||||
&installer,
|
||||
cores,
|
||||
core_only,
|
||||
gid,
|
||||
&clash_verge_i18n::t!("service.adminInstallPrompt"),
|
||||
)
|
||||
fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> {
|
||||
if output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.is_empty() {
|
||||
return Some((code, stderr));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.is_empty() {
|
||||
return Some((code, stdout));
|
||||
}
|
||||
Some((code, Cow::Borrowed("Unknown error")))
|
||||
}
|
||||
|
||||
fn reinstall_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "reinstall service");
|
||||
// The installer replaces an existing registration and its cores in one elevation.
|
||||
install_service()
|
||||
}
|
||||
|
||||
@@ -740,20 +834,158 @@ fn force_reinstall_service() -> Result<()> {
|
||||
/// hash was computed is refused by the installer instead of published.
|
||||
pub fn stage_approved_core(core_path: &Path) -> Result<()> {
|
||||
tokio::task::block_in_place(|| {
|
||||
invoke_service_install(
|
||||
&[clash_verge_service_ipc::management::CoreSource {
|
||||
name: core_path
|
||||
.file_name()
|
||||
.context("core has no filename")?
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
path: core_path.to_path_buf(),
|
||||
}],
|
||||
true,
|
||||
)
|
||||
let digest = sha256_hex(core_path)?;
|
||||
run_core_install(core_path, &digest)
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_hex(path: &Path) -> Result<String> {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut file = std::fs::File::open(path).with_context(|| format!("failed to open {path:?} for hashing"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("failed to read {path:?}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
use std::process::Output;
|
||||
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
||||
})?;
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let token = Token::with_current_process()?;
|
||||
let output = match token.privilege_level()? {
|
||||
PrivilegeLevel::NotPrivileged => {
|
||||
let status = RunasCommand::new(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_path)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.show(false)
|
||||
.status()?;
|
||||
Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
_ => StdCommand::new(&install_path)
|
||||
.creation_flags(0x08000000)
|
||||
.arg("--install-core")
|
||||
.arg(core_path)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?,
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let core_argument = core_path.as_os_str();
|
||||
let output = if linux_running_as_root() {
|
||||
StdCommand::new(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_argument)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?
|
||||
} else {
|
||||
let elevator = crate::utils::help::linux_elevator();
|
||||
let mut elevated = StdCommand::new(&elevator);
|
||||
// pkexec-only option; other elevators such as sudo reject unknown flags outright.
|
||||
if elevator.contains("pkexec") {
|
||||
elevated.arg("--disable-internal-agent");
|
||||
}
|
||||
let result = elevated
|
||||
.arg(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_argument)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?;
|
||||
if !result.status.success() && elevator.contains("pkexec") {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"pkexec failed with code {}, falling back to sudo",
|
||||
result.status.code().unwrap_or(-1)
|
||||
);
|
||||
StdCommand::new("sudo")
|
||||
.arg(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_argument)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?
|
||||
} else {
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
let install_path = macos_service_tool_path(&install_path)?;
|
||||
|
||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
||||
let shell = format!(
|
||||
"cd /; {} --install-core {} --sha256 {}",
|
||||
shell_single_quote(&install_path.to_string_lossy()),
|
||||
shell_single_quote(&core_path.to_string_lossy()),
|
||||
shell_single_quote(sha256_hex),
|
||||
);
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
let output = StdCommand::new("osascript").args(["-e", &command]).output()?;
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatches a privileged platform operation on a blocking thread.
|
||||
pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> {
|
||||
let (operation, label): (fn() -> Result<()>, &'static str) = match action {
|
||||
@@ -810,42 +1042,6 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
|
||||
.context("Clash Verge Service 未返回运行时暂存结果")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ServiceStartRefusal {
|
||||
pub code: u16,
|
||||
pub core_path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServiceStartRefusal {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"failed to start Service core at {} (code {}): {}",
|
||||
self.core_path, self.code, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceStartRefusal {}
|
||||
|
||||
fn record_service_start_refusal<E: RunStateEnv>(
|
||||
store: &RunStateStore<E>,
|
||||
refusal: ServiceStartRefusal,
|
||||
) -> anyhow::Error {
|
||||
if store.state().mode == crate::core::manager::RunningMode::NotRunning {
|
||||
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
|
||||
}
|
||||
refusal.into()
|
||||
}
|
||||
|
||||
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
|
||||
pub(super) fn record_residual_service(error: &anyhow::Error) {
|
||||
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
|
||||
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试使用服务启动core
|
||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||
@@ -880,24 +1076,11 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
response.code,
|
||||
err_msg
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::repair_required", "");
|
||||
}
|
||||
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
|
||||
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
|
||||
Handle::notice_message("service_core::app_data_not_owned", "");
|
||||
}
|
||||
start_owner_monitor();
|
||||
return Err(record_service_start_refusal(
|
||||
&RUN_STATE,
|
||||
ServiceStartRefusal {
|
||||
code: response.code,
|
||||
core_path: request.runtime.core_path,
|
||||
message: err_msg,
|
||||
},
|
||||
));
|
||||
bail!(
|
||||
"failed to start Service core at {}: {err_msg}",
|
||||
request.runtime.core_path
|
||||
);
|
||||
}
|
||||
|
||||
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
||||
@@ -916,9 +1099,6 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||
start_owner_monitor();
|
||||
tracing::Span::current().record("outcome", "started");
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_OWNER_NOTICE.lock().take();
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
@@ -961,7 +1141,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
|
||||
|
||||
if response.code > 0 {
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
|
||||
}
|
||||
let err_msg = response.message;
|
||||
bail!(err_msg);
|
||||
@@ -979,7 +1159,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
|
||||
let response = response.context("无法连接到Clash Verge Service")?;
|
||||
if response.code > 0 {
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
|
||||
}
|
||||
bail!(response.message);
|
||||
}
|
||||
@@ -1103,9 +1283,8 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"failed to read provider cache {} into {}: {error:#}",
|
||||
declared.provider.destination,
|
||||
temp.display()
|
||||
"provider cache {} was not read: {error:#}",
|
||||
declared.provider.destination
|
||||
);
|
||||
outcome.pending += 1;
|
||||
remove_temp(&temp).await;
|
||||
@@ -1195,9 +1374,8 @@ async fn publish_fetched(
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"failed to rename provider cache {} to {}: {error}",
|
||||
cache.temp.display(),
|
||||
target.display()
|
||||
"provider cache {} was not published: {error}",
|
||||
cache.declared.provider.destination
|
||||
);
|
||||
remove_temp(&cache.temp).await;
|
||||
}
|
||||
@@ -1341,9 +1519,7 @@ fn has_settled(mtime_ns: Option<u64>) -> bool {
|
||||
// Exclusive creation protects existing files from truncation and cleanup.
|
||||
async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
||||
if let Some(parent) = target.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create provider cache directory {}", parent.display()))?;
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let name = target
|
||||
.file_name()
|
||||
@@ -1512,11 +1688,9 @@ struct OwnerRecoveryPolicy {
|
||||
reset_system_proxy: bool,
|
||||
}
|
||||
|
||||
/// The macOS proxy is machine-wide and only the helper may write it, for the session it still
|
||||
/// honours; a displaced or unreachable owner must leave it alone.
|
||||
const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
||||
const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
||||
OwnerRecoveryPolicy {
|
||||
reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure),
|
||||
reset_system_proxy: !is_macos,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1538,7 +1712,6 @@ fn start_owner_monitor() {
|
||||
AsyncHandler::spawn(move || async move {
|
||||
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
|
||||
let mut watch = OwnerWatch::new();
|
||||
let mut core_restarts = None;
|
||||
loop {
|
||||
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
|
||||
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
|
||||
@@ -1558,10 +1731,7 @@ fn start_owner_monitor() {
|
||||
break;
|
||||
}
|
||||
|
||||
let (sample, status) = read_owner_sample().await;
|
||||
if let Some(status) = &status {
|
||||
log_core_restarts(&mut core_restarts, status);
|
||||
}
|
||||
let sample = read_owner_sample().await;
|
||||
let mut step = watch.observe(sample);
|
||||
if matches!(step, OwnerStep::VerifyTransport) {
|
||||
if watch.just_became_sustained() {
|
||||
@@ -1577,41 +1747,15 @@ fn start_owner_monitor() {
|
||||
}
|
||||
|
||||
if let OwnerStep::Recover(reason) = step {
|
||||
recover_after_owner_loss(generation, reason, status.as_ref()).await;
|
||||
recover_after_owner_loss(generation, reason).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Service logs its watchdog restarts where the app cannot read them.
|
||||
fn log_core_restarts(seen: &mut Option<u32>, status: &ServiceStatusSnapshot) {
|
||||
if seen.is_some_and(|seen| status.restart_count > seen) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"service restarted the core ({} restarts so far); last exit: {}",
|
||||
status.restart_count,
|
||||
status.last_core_exit_reason.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
}
|
||||
*seen = Some(status.restart_count);
|
||||
}
|
||||
|
||||
fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
|
||||
let detail = format!(
|
||||
"service state {:?}, {} restarts, last exit: {}",
|
||||
status.service_state,
|
||||
status.restart_count,
|
||||
status.last_core_exit_reason.as_deref().unwrap_or("none reported")
|
||||
);
|
||||
logging!(error, Type::Service, "service core stopped: {detail}");
|
||||
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
|
||||
Handle::notice_message("core_start::error", "");
|
||||
}
|
||||
|
||||
/// Samples ownership, treating every unusable reply as unreadable.
|
||||
async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
|
||||
async fn read_owner_sample() -> OwnerSample {
|
||||
let response = match current_owner_credentials() {
|
||||
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
|
||||
Err(error) => Err(error),
|
||||
@@ -1621,12 +1765,12 @@ async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
logging!(debug, Type::Service, "service owner status was unreadable: {error:#}");
|
||||
return (OwnerSample::Unreadable, None);
|
||||
return OwnerSample::Unreadable;
|
||||
}
|
||||
};
|
||||
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
return (OwnerSample::NotActive, None);
|
||||
return OwnerSample::NotActive;
|
||||
}
|
||||
if response.code != 0 {
|
||||
logging!(
|
||||
@@ -1636,25 +1780,24 @@ async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
|
||||
response.code,
|
||||
response.message
|
||||
);
|
||||
return (OwnerSample::Unreadable, None);
|
||||
return OwnerSample::Unreadable;
|
||||
}
|
||||
let Some(status) = response.data else {
|
||||
logging!(debug, Type::Service, "service owner status omitted data");
|
||||
return (OwnerSample::Unreadable, None);
|
||||
return OwnerSample::Unreadable;
|
||||
};
|
||||
|
||||
// A session that no longer matches is another owner's, whatever the flags say.
|
||||
if !session_matches_active_status(status.is_active, status.active_generation) {
|
||||
return (OwnerSample::NotActive, None);
|
||||
return OwnerSample::NotActive;
|
||||
}
|
||||
|
||||
let sample = OwnerSample::Status {
|
||||
OwnerSample::Status {
|
||||
is_active: status.is_active,
|
||||
desired_core_should_be_running: status.desired_core_should_be_running,
|
||||
service_state: status.service_state,
|
||||
core_pid: status.core_pid,
|
||||
};
|
||||
(sample, Some(status))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
|
||||
@@ -1672,12 +1815,7 @@ pub(crate) fn owner_monitor_generation() -> u64 {
|
||||
OWNER_MONITOR_GENERATION.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// `status` is the sample that led to the recovery, if any; a failed core is reported from it.
|
||||
async fn recover_after_owner_loss(
|
||||
generation: u64,
|
||||
reason: OwnerRecoveryReason,
|
||||
status: Option<&ServiceStatusSnapshot>,
|
||||
) {
|
||||
async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) {
|
||||
let manager = CoreManager::global();
|
||||
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
|
||||
return;
|
||||
@@ -1693,10 +1831,6 @@ async fn recover_after_owner_loss(
|
||||
return;
|
||||
}
|
||||
recover_after_owner_loss_while_locked(reason).await;
|
||||
// Still under the lifecycle lock, so a later start is the one that clears it.
|
||||
if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) {
|
||||
report_service_core_stopped(status);
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option<u64> {
|
||||
@@ -1721,15 +1855,13 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
||||
);
|
||||
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
||||
proxy_control::stop_guard().await;
|
||||
// Clear while still in Service mode with the session: on macOS it routes through the helper.
|
||||
if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
||||
clear_proxy_after_owner_loss().await;
|
||||
}
|
||||
clear_active_service_session();
|
||||
CoreManager::global().core_stopped();
|
||||
}
|
||||
|
||||
async fn clear_proxy_after_owner_loss() {
|
||||
if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=3 {
|
||||
match proxy_control::clear().await {
|
||||
@@ -1759,13 +1891,12 @@ async fn clear_proxy_after_owner_loss() {
|
||||
#[tracing::instrument(skip_all, level = "info", fields(attempts = tracing::field::Empty, interval_ms = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
async fn wait_for_service_ipc() -> Result<()> {
|
||||
const CONTEXT: &str = "service IPC did not become available";
|
||||
const READY_ATTEMPTS: usize = 61;
|
||||
const READY_INTERVAL: Duration = Duration::from_millis(500);
|
||||
let config = ServiceManager::config();
|
||||
let span = tracing::Span::current();
|
||||
span.record("attempts", READY_ATTEMPTS);
|
||||
span.record("interval_ms", READY_INTERVAL.as_millis() as u64);
|
||||
span.record("attempts", config.max_retries);
|
||||
span.record("interval_ms", config.retry_delay.as_millis() as u64);
|
||||
|
||||
match RUN_STATE.await_ready(READY_ATTEMPTS, READY_INTERVAL).await {
|
||||
match RUN_STATE.await_ready(config.max_retries, config.retry_delay).await {
|
||||
Ok(_) => {
|
||||
tracing::Span::current().record("outcome", "ready");
|
||||
Ok(())
|
||||
@@ -1981,8 +2112,8 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager;
|
||||
mod tests {
|
||||
use super::{
|
||||
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
|
||||
generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy,
|
||||
service_core_path_for, session_matches_status,
|
||||
generate_service_session_token, macos_install_shell, mark_service_unavailable_after_owner_loss,
|
||||
owner_recovery_policy, service_core_path_for, session_matches_status,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use super::{service_core_path_for_with_publisher, service_tool_path_for};
|
||||
@@ -2119,6 +2250,17 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_install_shell_starts_from_root_without_nested_sudo() {
|
||||
let shell = macos_install_shell(Path::new("/safe/service-tools/clash-verge-service-install"), 20);
|
||||
|
||||
assert_eq!(
|
||||
shell,
|
||||
"cd /; CLASH_VERGE_SERVICE_GID=20 '/safe/service-tools/clash-verge-service-install'"
|
||||
);
|
||||
assert!(!shell.contains("sudo"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> {
|
||||
@@ -2245,16 +2387,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() {
|
||||
fn macos_recovery_never_resets_machine_wide_proxy() {
|
||||
for reason in [
|
||||
OwnerRecoveryReason::Displaced,
|
||||
OwnerRecoveryReason::SameOwnerFailure,
|
||||
OwnerRecoveryReason::TransportFailure,
|
||||
] {
|
||||
assert_eq!(
|
||||
owner_recovery_policy(reason, true).reset_system_proxy,
|
||||
reason == OwnerRecoveryReason::SameOwnerFailure
|
||||
);
|
||||
assert!(!owner_recovery_policy(reason, true).reset_system_proxy);
|
||||
assert!(owner_recovery_policy(reason, false).reset_system_proxy);
|
||||
}
|
||||
|
||||
@@ -2280,38 +2419,6 @@ mod tests {
|
||||
assert_eq!(store.generation_count(), generation + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_core_start_can_continue_with_sidecar() -> anyhow::Result<()> {
|
||||
use clash_verge_service_ipc::ServiceErrorCode;
|
||||
for code in [
|
||||
ServiceErrorCode::InvalidInstallLocation,
|
||||
ServiceErrorCode::InvalidRuntimeAsset,
|
||||
ServiceErrorCode::OwnerSwitchFailed,
|
||||
] {
|
||||
let store = fake_store();
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let error = super::record_service_start_refusal(
|
||||
&store,
|
||||
super::ServiceStartRefusal {
|
||||
code: code as u16,
|
||||
core_path: "/development/service-core/verge-mihomo".into(),
|
||||
message: "no administrator-approved copy is installed".into(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(error.downcast_ref::<super::ServiceStartRefusal>().is_some());
|
||||
let state = store.settled().await;
|
||||
assert_eq!(state.mode, crate::core::manager::RunningMode::NotRunning);
|
||||
assert!(state.service_needs_attention());
|
||||
assert!(!state.service_usable());
|
||||
assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_)));
|
||||
store.allow_sidecar_for_session()?;
|
||||
assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed);
|
||||
assert!(store.state().tun_should_be_disabled(true));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_readiness_is_false_while_a_service_operation_is_running() {
|
||||
let store = fake_store();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use crate::{cmd, config::IVerge, core::handle::Handle, feat, process::AsyncHandler};
|
||||
use clash_verge_widget::{Action, Backend, Mode, Status};
|
||||
use tauri_plugin_mihomo::models::ClashMode;
|
||||
|
||||
struct AppBackend;
|
||||
|
||||
impl Backend for AppBackend {
|
||||
async fn snapshot(&self) -> Status {
|
||||
let core = Handle::mihomo().get_base_config().await;
|
||||
let proxy = {
|
||||
let manual = cmd::network::get_sys_proxy()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("enable").and_then(|v| v.as_bool()));
|
||||
let pac = cmd::network::get_auto_proxy()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("enable").and_then(|v| v.as_bool()));
|
||||
match (manual, pac) {
|
||||
(Some(true), _) | (_, Some(true)) => Some(true),
|
||||
(Some(false), Some(false)) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let (mode, tun) = match core {
|
||||
Ok(config) => (
|
||||
Some(match config.mode {
|
||||
ClashMode::Rule => Mode::Rule,
|
||||
ClashMode::Global => Mode::Global,
|
||||
ClashMode::Direct => Mode::Direct,
|
||||
}),
|
||||
Some(config.tun.enable),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
Status {
|
||||
mode,
|
||||
tun,
|
||||
proxy,
|
||||
error: None,
|
||||
language: clash_verge_i18n::current_language(
|
||||
crate::config::Config::verge().await.data_arc().language.as_deref(),
|
||||
)
|
||||
.into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn language(&self) -> String {
|
||||
clash_verge_i18n::current_language(crate::config::Config::verge().await.data_arc().language.as_deref())
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
async fn apply(&self, action: Action) -> Result<(), String> {
|
||||
if Handle::global().is_exiting() {
|
||||
return Err("widget.unavailable".into());
|
||||
}
|
||||
match action {
|
||||
Action::Mode(mode) => feat::change_clash_mode(mode.as_str().into())
|
||||
.await
|
||||
.map_err(|_| "widget.failed".into()),
|
||||
Action::Tun(_) | Action::Proxy(_) => {
|
||||
let payload = IVerge {
|
||||
enable_tun_mode: match action {
|
||||
Action::Tun(value) => Some(value),
|
||||
_ => None,
|
||||
},
|
||||
enable_system_proxy: match action {
|
||||
Action::Proxy(value) => Some(value),
|
||||
_ => None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
cmd::verge::patch_verge_config(payload)
|
||||
.await
|
||||
.map_err(|_| "widget.failed".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
AsyncHandler::spawn(|| async { clash_verge_widget::start(AppBackend) });
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+3
-13
@@ -44,14 +44,7 @@ mod app_init {
|
||||
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(¤t.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())
|
||||
@@ -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,
|
||||
@@ -353,6 +341,8 @@ pub fn run() -> std::process::ExitCode {
|
||||
|
||||
logging!(info, Type::System, "应用就绪");
|
||||
crate::utils::server::set_commands_ready();
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::core::widget::start();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = _app_handle.get_webview_window("main") {
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::{
|
||||
core::{CoreManager, handle, timer::Timer},
|
||||
utils::help,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::{module::lightweight, utils::window_manager::WindowManager};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
|
||||
pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
|
||||
@@ -25,6 +27,19 @@ pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
|
||||
let link_parsed = Url::parse(param_str)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse deep link: {e:?}, param: {masked_deep_link}"))?;
|
||||
|
||||
// Dedicated "raise the window" route (clash-verge://show) for surfaces that
|
||||
// activate a tray-only host without firing the macOS reopen event.
|
||||
#[cfg(target_os = "macos")]
|
||||
if link_parsed.host_str() == Some("show") {
|
||||
if lightweight::is_in_lightweight_mode() {
|
||||
lightweight::exit_lightweight_mode().await;
|
||||
} else {
|
||||
handle::Handle::global().set_activation_policy_regular();
|
||||
WindowManager::show_main_window().await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some((url, name)) = extract_subscription_info(&link_parsed) else {
|
||||
logging!(
|
||||
warn,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.5.6",
|
||||
"version": "2.5.4",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
|
||||
"productName": "Clash Verge",
|
||||
"build": {
|
||||
"beforeBundleCommand": "node crates/clash-verge-widget/build/bundle.mjs"
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["app", "dmg"],
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"frameworks": ["generated/widget/VergeWidgetBridge.dylib"],
|
||||
"files": {
|
||||
"PlugIns/VergeWidget.appex": "generated/widget/VergeWidget.appex"
|
||||
},
|
||||
"minimumSystemVersion": "11.0",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"entitlements": "packages/macos/entitlements.plist",
|
||||
"entitlements": "generated/widget/Host.entitlements",
|
||||
"dmg": {
|
||||
"background": "images/background.png",
|
||||
"appPosition": {
|
||||
@@ -29,7 +35,7 @@
|
||||
"y": 180
|
||||
}
|
||||
},
|
||||
"infoPlist": "packages/macos/info_merge.plist"
|
||||
"infoPlist": "generated/widget/Host.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ export const formatConnectionTraffic = (value?: number) => {
|
||||
? 0
|
||||
: Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1)
|
||||
const data = value / 1024 ** exp
|
||||
const text = Math.round(data) >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||
const text = data >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||
return `${text} ${TRAFFIC_UNITS[exp]}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -110,8 +108,7 @@ export const SystemInfoCard = () => {
|
||||
goToSettings()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('update check failed:', err)
|
||||
showNotice.warning(err)
|
||||
showNotice.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -137,14 +134,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 +173,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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user