mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 22:00:56 +08:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13676d3335 | ||
|
|
2a7972fa5b | ||
|
|
19334368cd | ||
|
|
73ea0fbd80 | ||
|
|
9f97d938f5 | ||
|
|
04a2a09368 | ||
|
|
94491b4ba4 | ||
|
|
78516dc1dc | ||
|
|
8f6fd3bbf9 | ||
|
|
c599f5547e | ||
|
|
f6fdb34745 | ||
|
|
65ed8d3cf2 | ||
|
|
2baa629ac4 | ||
|
|
c7ffb21294 | ||
|
|
0d0ed72467 | ||
|
|
a36dcd319f | ||
|
|
9a879dd8d8 | ||
|
|
20141e791e | ||
|
|
e385d37e5b | ||
|
|
2f097790ed | ||
|
|
619d6ae36a | ||
|
|
de24496fa7 | ||
|
|
f58e0a0af5 | ||
|
|
a8e8fe66dc | ||
|
|
75c921b466 | ||
|
|
e3bdea4386 | ||
|
|
ce963d175a | ||
|
|
d289a608d9 | ||
|
|
8a5548af10 | ||
|
|
1d9bca13c8 | ||
|
|
6f19c3dfaa |
@@ -5,7 +5,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.3.3"
|
||||
"pnpmVersion": "12.5.1"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.98.1",
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.89.1": {
|
||||
"github/gh-aw-actions/setup@v0.89.15": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.89.1",
|
||||
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
|
||||
"version": "v0.89.15",
|
||||
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
@@ -22,6 +22,11 @@
|
||||
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
|
||||
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
||||
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||
@@ -32,6 +37,11 @@
|
||||
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
|
||||
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||
@@ -42,10 +52,15 @@
|
||||
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
|
||||
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
|
||||
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
|
||||
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-node": {
|
||||
"image": "ghcr.io/github/gh-aw-node",
|
||||
|
||||
@@ -30,11 +30,19 @@ 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 12:00, 18:00 -> UTC 4:00, 10:00
|
||||
- cron: '0 4,10 * * *'
|
||||
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
|
||||
- cron: '0 10,22 * * *'
|
||||
permissions: write-all
|
||||
env:
|
||||
TAG_NAME: autobuild
|
||||
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
|
||||
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
|
||||
with:
|
||||
version: v0.89.1
|
||||
version: v0.89.15
|
||||
|
||||
+111
-108
File diff suppressed because one or more lines are too long
@@ -17,4 +17,3 @@ target
|
||||
.vfox
|
||||
.claude
|
||||
.zed
|
||||
/src-tauri/generated/widget/
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.4.1
|
||||
pnpm 12.5.1
|
||||
rust 1.98.1
|
||||
|
||||
Generated
+456
-612
File diff suppressed because it is too large
Load Diff
+18
-8
@@ -8,7 +8,6 @@ members = [
|
||||
"crates/tauri-plugin-clash-verge-sysinfo",
|
||||
"crates/clash-verge-i18n",
|
||||
"crates/clash-verge-limiter",
|
||||
"crates/clash-verge-widget",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -47,6 +46,12 @@ 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" }
|
||||
@@ -54,13 +59,12 @@ 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"
|
||||
tracing = "0.1.44"
|
||||
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.104"
|
||||
chrono = "0.4.45"
|
||||
@@ -72,9 +76,15 @@ tokio = { version = "1.53.1", features = [
|
||||
"sync",
|
||||
] }
|
||||
flexi_logger = "0.31.10"
|
||||
log = "0.4.33"
|
||||
log = "0.4.34"
|
||||
regex = "1.13.1"
|
||||
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
|
||||
reqwest = { version = "0.13.5", features = [
|
||||
"json",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"rustls",
|
||||
"form",
|
||||
] }
|
||||
rust_iso3166 = "0.2.0"
|
||||
|
||||
smartstring = { version = "1.0.1" }
|
||||
@@ -82,7 +92,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.1" }
|
||||
bitflags = { version = "2.13.2" }
|
||||
socket2 = "0.6.5"
|
||||
|
||||
# *** For Windows platform only ***
|
||||
@@ -95,7 +105,7 @@ windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_JobObjects"
|
||||
"Win32_System_JobObjects",
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
|
||||
+9
-3
@@ -1,11 +1,9 @@
|
||||
## v2.5.4
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
@@ -27,8 +25,12 @@
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
@@ -39,6 +41,7 @@
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
@@ -60,6 +63,7 @@
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
@@ -75,7 +79,9 @@
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.2.1"
|
||||
rust-i18n = "4.2.2"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -91,14 +91,3 @@ 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: القيمة
|
||||
|
||||
@@ -91,14 +91,3 @@ 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
|
||||
|
||||
@@ -91,14 +91,3 @@ 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
|
||||
|
||||
@@ -91,14 +91,3 @@ 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
|
||||
|
||||
@@ -91,14 +91,3 @@ 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: مقدار
|
||||
|
||||
@@ -91,14 +91,3 @@ 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
|
||||
|
||||
@@ -91,14 +91,3 @@ 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: 値
|
||||
|
||||
@@ -91,14 +91,3 @@ 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: 값
|
||||
|
||||
@@ -91,14 +91,3 @@ 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': System proxy has been enabled.
|
||||
'off': System proxy has been disabled.
|
||||
'on': Sistem vekili etkinleştirildi.
|
||||
'off': Sistem vekili devre dışı bırakıldı.
|
||||
systemProxyFailed:
|
||||
title: Sistem Vekili
|
||||
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
|
||||
@@ -18,8 +18,8 @@ notifications:
|
||||
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
|
||||
tunModeToggled:
|
||||
title: TUN Modu
|
||||
'on': TUN mode has been enabled.
|
||||
'off': TUN mode has been disabled.
|
||||
'on': TUN modu etkinleştirildi.
|
||||
'off': TUN modu devre dışı bırakıldı.
|
||||
lightweightModeEntered:
|
||||
title: Hafif Mod
|
||||
body: Hafif moda geçildi.
|
||||
@@ -33,10 +33,10 @@ notifications:
|
||||
title: Uygulama Gizlendi
|
||||
body: Clash Verge arka planda çalışıyor.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
installNow: Install Now
|
||||
later: Later
|
||||
title: Clash Verge Güncellemesi
|
||||
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
|
||||
installNow: Şimdi Kur
|
||||
later: Daha Sonra
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
||||
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||
@@ -91,14 +91,3 @@ 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
|
||||
|
||||
@@ -91,14 +91,3 @@ 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: Кыйммәт
|
||||
|
||||
@@ -91,14 +91,3 @@ 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: 值
|
||||
|
||||
@@ -91,14 +91,3 @@ tray:
|
||||
systemProxy: 系統代理
|
||||
tun: 虛擬網路介面卡
|
||||
profile: 訂閱
|
||||
widget:
|
||||
unavailable: 請開啟 Clash Verge
|
||||
failed: 更新失敗,請檢查 Clash Verge
|
||||
'on': 開啟
|
||||
'off': 關閉
|
||||
unknown: 未知
|
||||
name: Clash Verge 路由
|
||||
description: 路由模式、TUN 和系統代理控制。
|
||||
update: 更新 Clash Verge
|
||||
setting: 設定
|
||||
value: 值
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Xcode per-user state and local build output
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
@@ -1,19 +0,0 @@
|
||||
[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
|
||||
@@ -1,232 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,201 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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
|
||||
@@ -1,65 +0,0 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// !$*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
@@ -1,77 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,104 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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
-2
@@ -43,7 +43,6 @@ export default defineConfig([
|
||||
'eslint.config.ts',
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'crates/clash-verge-widget/build/*.mjs',
|
||||
'tests/*.ts',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
@@ -144,7 +143,7 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/*.mjs', 'crates/clash-verge-widget/build/*.mjs'],
|
||||
files: ['scripts/*.mjs'],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
|
||||
+17
-17
@@ -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": "node scripts/build.mjs",
|
||||
"build:fast": "node scripts/build.mjs -- --profile fast-release",
|
||||
"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",
|
||||
"tauri": "tauri",
|
||||
"web:dev": "vite",
|
||||
"web:build": "tsc --noEmit && vite build",
|
||||
@@ -56,13 +56,13 @@
|
||||
"@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",
|
||||
"@tauri-apps/plugin-http": "~2.6.0",
|
||||
"@tauri-apps/plugin-opener": "2.5.5",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"ahooks": "^3.9.7",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dayjs": "1.11.21",
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
@@ -73,11 +73,11 @@
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-error-boundary": "6.1.5",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -96,12 +96,12 @@
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@wdio/tauri-service": "1.3.0",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.16.1",
|
||||
"cli-color": "^2.0.4",
|
||||
@@ -121,7 +121,7 @@
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.32.1",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
@@ -131,8 +131,8 @@
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.0",
|
||||
"webdriverio": "9.30.0",
|
||||
"ws": "8.21.0"
|
||||
"webdriverio": "9.31.9",
|
||||
"ws": "8.21.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
@@ -144,5 +144,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.4.1"
|
||||
"packageManager": "pnpm@12.5.1"
|
||||
}
|
||||
|
||||
Generated
+430
-525
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -56,6 +56,12 @@
|
||||
"matchFileNames": [".github/workflows/*.lock.yml"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
|
||||
"matchFileNames": [".github/workflows/**"],
|
||||
"matchPackageNames": ["/gh-aw/"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Skip TypeScript 7.0 series",
|
||||
"matchManagers": ["npm"],
|
||||
@@ -78,7 +84,7 @@
|
||||
},
|
||||
{
|
||||
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
||||
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"groupName": "pnpm"
|
||||
},
|
||||
{
|
||||
@@ -131,6 +137,16 @@
|
||||
],
|
||||
"depNameTemplate": "rust-lang/rust",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Update pnpm in devcontainer.json",
|
||||
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
|
||||
"matchStrings": [
|
||||
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "pnpm",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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', '.swift'])
|
||||
const BACKEND_EXTENSIONS = new Set(['.rs'])
|
||||
|
||||
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
|
||||
+50
-15
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
|
||||
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm release-version <version>
|
||||
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
|
||||
'[INFO]: Current package.json version is: ',
|
||||
packageJson.version,
|
||||
)
|
||||
packageJson.version = newVersion.startsWith('v')
|
||||
const versionWithoutV = newVersion.startsWith('v')
|
||||
? newVersion.slice(1)
|
||||
: newVersion
|
||||
await fs.writeFile(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
console.log(
|
||||
`[INFO]: package.json version updated to: ${packageJson.version}`,
|
||||
const updatedData = data.replace(
|
||||
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
||||
`$1"${versionWithoutV}"`,
|
||||
)
|
||||
|
||||
if (updatedData === data) {
|
||||
throw new Error('version field was not found in package.json')
|
||||
}
|
||||
|
||||
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
|
||||
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
|
||||
} catch (error) {
|
||||
console.error('Error updating package.json version:', error)
|
||||
throw error
|
||||
@@ -153,6 +156,34 @@ async function updateCargoVersion(newVersion) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCargoLockVersion(newVersion) {
|
||||
const _dirname = process.cwd()
|
||||
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
|
||||
const versionWithoutV = newVersion.startsWith('v')
|
||||
? newVersion.slice(1)
|
||||
: newVersion
|
||||
const packageVersionPattern =
|
||||
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(cargoLockPath, 'utf8')
|
||||
const updatedData = data.replace(
|
||||
packageVersionPattern,
|
||||
`$1"${versionWithoutV}"`,
|
||||
)
|
||||
|
||||
if (updatedData === data) {
|
||||
throw new Error('clash-verge package entry was not found in Cargo.lock')
|
||||
}
|
||||
|
||||
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
|
||||
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
|
||||
} catch (error) {
|
||||
console.error('Error updating Cargo.lock version:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTauriConfigVersion(newVersion) {
|
||||
const _dirname = process.cwd()
|
||||
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
|
||||
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
|
||||
tauriConfig.version,
|
||||
)
|
||||
|
||||
tauriConfig.version = versionWithoutV
|
||||
|
||||
await fs.writeFile(
|
||||
tauriConfigPath,
|
||||
JSON.stringify(tauriConfig, null, 2),
|
||||
'utf8',
|
||||
const updatedData = data.replace(
|
||||
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
||||
`$1"${versionWithoutV}"`,
|
||||
)
|
||||
|
||||
if (updatedData === data) {
|
||||
throw new Error('version field was not found in tauri.conf.json')
|
||||
}
|
||||
|
||||
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
|
||||
console.log(
|
||||
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
||||
)
|
||||
@@ -239,6 +273,7 @@ async function main(versionArg) {
|
||||
console.log(`[INFO]: Updating versions to: ${newVersion}`)
|
||||
await updatePackageVersion(newVersion)
|
||||
await updateCargoVersion(newVersion)
|
||||
await updateCargoLockVersion(newVersion)
|
||||
await updateTauriConfigVersion(newVersion)
|
||||
console.log('[SUCCESS]: All version updates completed successfully!')
|
||||
} catch (error) {
|
||||
|
||||
+17
-20
@@ -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.1"
|
||||
open = "5.4.4"
|
||||
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.21.1"
|
||||
boa_engine = "0.22.0"
|
||||
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.5"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-shell = "2.3.6"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
tauri-plugin-dialog = "2.7.3"
|
||||
tauri-plugin-fs = "2.5.2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
zip = "8.6.0"
|
||||
flate2 = "1.1.9"
|
||||
flate2 = "1.1.10"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.0" }
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
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.3.3"
|
||||
tauri-plugin-notification = "2.4.0"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.5.9"
|
||||
tauri-plugin-http = "2.6.0"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.1.0", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.9", version = "2.6.9", features = [
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.2", version = "2.7.2", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.4"
|
||||
rustls = { version = "0.23.43", features = ["ring"] }
|
||||
tokio-rustls = "0.26.5"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
bytes = "1.12.1"
|
||||
@@ -114,10 +114,7 @@ tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
libc = "0.2.189"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
clash-verge-widget = { workspace = true }
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
|
||||
core-foundation = "0.9"
|
||||
system-configuration = "0.7"
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||
objc2 = "0.6.4"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
@@ -151,7 +148,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.10.1"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
chmod +x /usr/bin/clash-verge-service-install
|
||||
chmod +x /usr/bin/clash-verge-service-uninstall
|
||||
chmod +x /usr/bin/clash-verge-service
|
||||
|
||||
# The enforcement file exists in both Enforcing and Permissive mode.
|
||||
if [ ! -e /sys/fs/selinux/enforce ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
|
||||
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
|
||||
for service in clash-verge-service clash-verge-service-dev; do
|
||||
for directory in bin cores; do
|
||||
target="/var/lib/$service/$directory"
|
||||
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
|
||||
echo "Refusing to relabel a symlinked service directory: $target" >&2
|
||||
exit 1
|
||||
fi
|
||||
spec="$target(/.*)?"
|
||||
# Older semanage versions reject -a when an exact rule already exists.
|
||||
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
|
||||
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
|
||||
printf '%s\n' "$add_error" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -d "$target" ]; then
|
||||
/usr/sbin/restorecon -R "$target"
|
||||
fi
|
||||
done
|
||||
done
|
||||
@@ -210,7 +210,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
||||
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
|
||||
}
|
||||
|
||||
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
|
||||
// A fragment, not a runnable config; the merged result is validated on apply.
|
||||
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
|
||||
.await
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ pub async fn continue_with_sidecar() -> CmdResult {
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_fallback_notice() -> bool {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_runtime_providers() {
|
||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||
|
||||
@@ -36,14 +36,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -60,8 +56,6 @@ 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;
|
||||
};
|
||||
|
||||
@@ -185,6 +185,42 @@ where
|
||||
apply_proxy().await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
|
||||
is_admin: bool,
|
||||
start_service: Start,
|
||||
start_sidecar: Fallback,
|
||||
) -> Result<()>
|
||||
where
|
||||
Start: FnOnce() -> StartFuture,
|
||||
StartFuture: std::future::Future<Output = Result<()>>,
|
||||
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let Err(error) = start_service().await else {
|
||||
return Ok(());
|
||||
};
|
||||
// A transport failure cannot establish whether the service already started the core.
|
||||
let location_refused = error
|
||||
.downcast_ref::<crate::core::service::ServiceStartRefusal>()
|
||||
.is_some_and(|refusal| {
|
||||
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
|
||||
});
|
||||
if !is_admin || !location_refused {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service refused the core location while app is elevated; falling back to sidecar: {error:#}"
|
||||
);
|
||||
let reason = format!("{error:#}");
|
||||
start_sidecar(error)
|
||||
.await
|
||||
.map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}")))
|
||||
}
|
||||
|
||||
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
|
||||
clear_proxy: Clear,
|
||||
terminate_sidecar: Terminate,
|
||||
@@ -476,10 +512,7 @@ impl CoreManager {
|
||||
// rather than fail — only if the watcher is actually live; a user toggling while
|
||||
// offline still fails fast.
|
||||
#[cfg(target_os = "macos")]
|
||||
let watcher_armed = crate::core::network_watch::is_armed();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let watcher_armed = false;
|
||||
if watcher_armed
|
||||
if crate::core::network_watch::is_armed()
|
||||
&& !crate::utils::resolve::is_resolve_done()
|
||||
&& Config::verge()
|
||||
.await
|
||||
@@ -553,7 +586,30 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
let result = match startup {
|
||||
StartupDecision::Service => self.start_core_by_service().await,
|
||||
StartupDecision::Service => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_service_start_with_sidecar_fallback(
|
||||
crate::core::runstate::RUN_STATE.state().is_admin,
|
||||
|| self.start_core_by_service(),
|
||||
|error| async move {
|
||||
use crate::core::runstate::RUN_STATE;
|
||||
|
||||
RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?;
|
||||
let result = self.start_core_by_sidecar().await;
|
||||
if result.is_err() {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
} else {
|
||||
crate::core::service::notify_service_fallback();
|
||||
}
|
||||
result
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
self.start_core_by_service().await
|
||||
}
|
||||
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
|
||||
StartupDecision::Wait => Ok(()),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,44 @@ use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn retry_service_start<Start, StartFuture>(
|
||||
attempts: usize,
|
||||
retry_delay: std::time::Duration,
|
||||
mut start: Start,
|
||||
) -> Result<()>
|
||||
where
|
||||
Start: FnMut() -> StartFuture,
|
||||
StartFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let mut last_error = None;
|
||||
for attempt in 0..attempts {
|
||||
match start().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service start attempt {}/{} failed: {error:#}",
|
||||
attempt + 1,
|
||||
attempts
|
||||
);
|
||||
if error
|
||||
.downcast_ref::<service::ServiceStartRefusal>()
|
||||
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
last_error = Some(error);
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
/// Restores profile selections before callers enable the system proxy.
|
||||
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
|
||||
@@ -170,33 +208,10 @@ impl CoreManager {
|
||||
let pid = child.pid();
|
||||
tracing::Span::current().record("pid", pid);
|
||||
|
||||
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
|
||||
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
|
||||
handle::Handle::mihomo().get_version().await
|
||||
})
|
||||
.await
|
||||
.context("Mihomo readiness probe timed out")??;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(readiness_error) = readiness {
|
||||
proxy_control::stop_guard().await;
|
||||
self.core_stopped();
|
||||
return match child.kill() {
|
||||
Ok(()) => Err(readiness_error),
|
||||
Err(kill_error) => Err(anyhow::anyhow!(
|
||||
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(Some(job));
|
||||
self.set_running_child_sidecar(child);
|
||||
// The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
|
||||
// single event, so an unread channel stalls its reader threads and the core blocks on a
|
||||
// full stdout pipe before it ever creates the API socket.
|
||||
let core_readiness_generation = self.mark_core_ready();
|
||||
self.core_started(RunningMode::Sidecar);
|
||||
self.restore_selected_nodes().await;
|
||||
|
||||
AsyncHandler::spawn(move || async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
@@ -226,6 +241,32 @@ impl CoreManager {
|
||||
}
|
||||
});
|
||||
|
||||
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
|
||||
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
|
||||
handle::Handle::mihomo().get_version().await
|
||||
})
|
||||
.await
|
||||
.context("Mihomo readiness probe timed out")??;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(readiness_error) = readiness {
|
||||
proxy_control::stop_guard().await;
|
||||
self.core_stopped();
|
||||
return match child.kill() {
|
||||
Ok(()) => Err(readiness_error),
|
||||
Err(kill_error) => Err(anyhow::anyhow!(
|
||||
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(Some(job));
|
||||
self.set_running_child_sidecar(child);
|
||||
self.core_started(RunningMode::Sidecar);
|
||||
self.restore_selected_nodes().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -269,30 +310,15 @@ impl CoreManager {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::constants::timing;
|
||||
let mut last_err = None;
|
||||
for attempt in 0..timing::SERVICE_START_RETRIES {
|
||||
match service::run_core_by_service(config_file).await {
|
||||
Ok(()) => {
|
||||
self.mark_core_ready();
|
||||
self.core_started(RunningMode::Service);
|
||||
self.restore_selected_nodes().await;
|
||||
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service start attempt {}/{} failed: {e:#}",
|
||||
attempt + 1,
|
||||
timing::SERVICE_START_RETRIES
|
||||
);
|
||||
last_err = Some(e);
|
||||
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
||||
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
|
||||
service::run_core_by_service(config_file)
|
||||
})
|
||||
.await?;
|
||||
self.mark_core_ready();
|
||||
self.core_started(RunningMode::Service);
|
||||
self.restore_selected_nodes().await;
|
||||
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
|
||||
@@ -19,8 +19,6 @@ 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,53 +5,35 @@
|
||||
|
||||
use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use core_foundation::{
|
||||
array::CFArray,
|
||||
runloop::{CFRunLoop, kCFRunLoopCommonModes},
|
||||
string::CFString,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext};
|
||||
use parking_lot::Mutex;
|
||||
use sysproxy::NetworkServiceMonitor;
|
||||
|
||||
/// Set once the subscription is live. Startup defers the proxy write to the watcher only then.
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static MONITOR: Mutex<Option<NetworkServiceMonitor>> = Mutex::new(None);
|
||||
|
||||
pub fn is_armed() -> bool {
|
||||
ARMED.load(Ordering::Acquire)
|
||||
MONITOR.lock().is_some()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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!(
|
||||
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!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"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);
|
||||
"could not watch the network; the proxy is not re-applied on changes: {error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reapply() {
|
||||
@@ -66,10 +48,17 @@ 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,
|
||||
|
||||
@@ -385,12 +385,11 @@ 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(
|
||||
|| !matches!(sysproxy::Sysproxy::get_system_proxy(), Err(error) if is_missing_network_service(&error)),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
tokio::task::spawn_blocking(|| sysproxy::Sysproxy::has_network_service().unwrap_or(true))
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn is_reportable(error: &anyhow::Error) -> bool {
|
||||
|
||||
@@ -323,6 +323,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
|
||||
let mut state = self.service.lock();
|
||||
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
|
||||
bail!("cannot fall back while a service operation is pending");
|
||||
}
|
||||
// Publish the refusal and allowance together so it never asks the UI for a repair.
|
||||
state.service.health = ServiceHealth::Unavailable(reason);
|
||||
state.service.allow_sidecar();
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Requests installation only for a confirmed absent Service.
|
||||
pub fn require_install_for_session(&self) -> Result<()> {
|
||||
let mut state = self.service.lock();
|
||||
|
||||
@@ -39,6 +39,17 @@ use std::{
|
||||
|
||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback() {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::sidecar_fallback", "");
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_fallback_notice() -> bool {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Capabilities of the service session that owns the running Core.
|
||||
/// They are discarded with that session rather than cached across service upgrades.
|
||||
@@ -564,8 +575,39 @@ fn uninstall_service() -> Result<()> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_service() -> Result<()> {
|
||||
use std::process::Output;
|
||||
logging!(info, Type::Service, "install service");
|
||||
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| service_core_path(core, ".exe"))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
install_windows_service_with_cores(&cores, run_windows_service_installer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_windows_service_with_cores(
|
||||
cores: &[PathBuf],
|
||||
mut run_installer: impl FnMut(&[std::ffi::OsString]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let cores: Vec<_> = cores.iter().filter(|core| core.is_file()).collect();
|
||||
if cores.is_empty() {
|
||||
bail!("no core executable is available; restore a bundled core before installing the service");
|
||||
}
|
||||
let mut arguments = vec!["--install-service".into()];
|
||||
for core in cores {
|
||||
let digest = sha256_hex(core)?;
|
||||
arguments.extend([
|
||||
"--install-core".into(),
|
||||
core.as_os_str().to_owned(),
|
||||
"--sha256".into(),
|
||||
digest.into(),
|
||||
]);
|
||||
}
|
||||
run_installer(&arguments).context("failed to install the service and its cores; choose Repair to retry")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> {
|
||||
use std::process::Output;
|
||||
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
@@ -583,7 +625,7 @@ fn install_service() -> Result<()> {
|
||||
let level = token.privilege_level()?;
|
||||
let output = match level {
|
||||
PrivilegeLevel::NotPrivileged => {
|
||||
let status = RunasCommand::new(&install_path).show(false).status()?;
|
||||
let status = RunasCommand::new(&install_path).args(arguments).show(false).status()?;
|
||||
Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
@@ -592,7 +634,10 @@ fn install_service() -> Result<()> {
|
||||
}
|
||||
_ => {
|
||||
// StdCommand returns Output directly
|
||||
StdCommand::new(&install_path).creation_flags(0x08000000).output()?
|
||||
StdCommand::new(&install_path)
|
||||
.args(arguments)
|
||||
.creation_flags(0x08000000)
|
||||
.output()?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1042,6 +1087,25 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
|
||||
.context("Clash Verge Service 未返回运行时暂存结果")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ServiceStartRefusal {
|
||||
pub code: u16,
|
||||
pub core_path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServiceStartRefusal {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"failed to start Service core at {} (code {}): {}",
|
||||
self.core_path, self.code, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceStartRefusal {}
|
||||
|
||||
/// 尝试使用服务启动core
|
||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||
@@ -1077,10 +1141,12 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
err_msg
|
||||
);
|
||||
start_owner_monitor();
|
||||
bail!(
|
||||
"failed to start Service core at {}: {err_msg}",
|
||||
request.runtime.core_path
|
||||
);
|
||||
return Err(ServiceStartRefusal {
|
||||
code: response.code,
|
||||
core_path: request.runtime.core_path,
|
||||
message: err_msg,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
||||
@@ -1099,6 +1165,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||
start_owner_monitor();
|
||||
tracing::Span::current().record("outcome", "started");
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
@@ -2167,6 +2234,82 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_combines_service_and_attested_cores() -> anyhow::Result<()> {
|
||||
use std::ffi::OsString;
|
||||
let root = TestDirectory::new("scoop install")?;
|
||||
let cores: Vec<_> = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|name| root.path().join(format!("{name}.exe")))
|
||||
.collect();
|
||||
for core in &cores {
|
||||
std::fs::write(core, b"abc")?;
|
||||
}
|
||||
let mut calls = Vec::new();
|
||||
super::install_windows_service_with_cores(&cores, |arguments| {
|
||||
calls.push(arguments.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
assert_eq!(calls.len(), 1, "service and cores must share one elevation");
|
||||
let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
let mut expected: Vec<OsString> = vec!["--install-service".into()];
|
||||
expected.extend(cores.iter().flat_map(|core| {
|
||||
[
|
||||
"--install-core".into(),
|
||||
core.as_os_str().to_owned(),
|
||||
"--sha256".into(),
|
||||
digest.into(),
|
||||
]
|
||||
}));
|
||||
assert_eq!(calls[0], expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_skips_unavailable_cores() -> anyhow::Result<()> {
|
||||
let root = TestDirectory::new("missing-core")?;
|
||||
let core = root.path().join("verge-mihomo.exe");
|
||||
let missing = root.path().join("verge-mihomo-alpha.exe");
|
||||
std::fs::write(&core, b"abc")?;
|
||||
let cores = [core.clone(), missing.clone(), root.path().to_path_buf()];
|
||||
let mut calls = Vec::new();
|
||||
super::install_windows_service_with_cores(&cores, |arguments| {
|
||||
calls.push(arguments.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].len(), 5);
|
||||
assert_eq!(calls[0][0], "--install-service");
|
||||
assert_eq!(calls[0][2], core.as_os_str());
|
||||
|
||||
let error = super::install_windows_service_with_cores(&[missing, root.path().to_path_buf()], |_| {
|
||||
panic!("no available core must fail before invoking the installer")
|
||||
})
|
||||
.expect_err("installation requires at least one available core");
|
||||
assert!(error.to_string().contains("no core executable"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_propagates_installer_failure() -> anyhow::Result<()> {
|
||||
let root = TestDirectory::new("install-failure")?;
|
||||
let core = root.path().join("verge-mihomo.exe");
|
||||
std::fs::write(&core, b"abc")?;
|
||||
let mut calls = 0;
|
||||
let result = super::install_windows_service_with_cores(std::slice::from_ref(&core), |_| {
|
||||
calls += 1;
|
||||
bail!("installer failed with exit code 1")
|
||||
});
|
||||
let error = result.expect_err("installation failure must not be reported as success");
|
||||
assert!(format!("{error:#}").contains("exit code 1"));
|
||||
assert!(error.to_string().contains("Repair"));
|
||||
assert_eq!(calls, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn staging_directory(home: &Path) -> PathBuf {
|
||||
home.join("Applications/.clash-verge-rev-dev/service-core")
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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) });
|
||||
}
|
||||
@@ -284,8 +284,7 @@ fn process_seq_items(
|
||||
config
|
||||
}
|
||||
|
||||
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
|
||||
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
|
||||
/// App 权威的顶层控制面键;`dns.ipv6` 单独处理。
|
||||
const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"external-controller",
|
||||
#[cfg(unix)]
|
||||
@@ -308,25 +307,20 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"unified-delay",
|
||||
];
|
||||
|
||||
/// The fields the app owns, held across the stages that apply the user's manual overrides.
|
||||
///
|
||||
/// Capturing and restoring are one value rather than two calls because the order is a
|
||||
/// correctness requirement, not a style: capture must happen after the app has finished
|
||||
/// deriving these fields and before any override runs, and restore must happen after every
|
||||
/// override. As four separate calls that contract lived only in comments.
|
||||
/// App-owned fields: captured after the app derives them, enforced after every override.
|
||||
struct AuthoritativeFields {
|
||||
control_plane: Mapping,
|
||||
tun: Mapping,
|
||||
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
|
||||
/// Only when the DNS page switched it on.
|
||||
dns_ipv6: Option<Value>,
|
||||
}
|
||||
|
||||
impl AuthoritativeFields {
|
||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
|
||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
|
||||
Self {
|
||||
control_plane: snapshot_control_plane(config),
|
||||
tun: snapshot_tun(config, gui_tun_keys),
|
||||
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
|
||||
dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +395,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
|
||||
/// 只在 DNS 页开启 IPv6 时快照。
|
||||
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
|
||||
config.get("dns")?.get("ipv6").cloned()
|
||||
}
|
||||
@@ -481,11 +475,7 @@ async fn process_profile_items(
|
||||
(config, exists_keys, result_map)
|
||||
}
|
||||
|
||||
/// Merge the Application Merge Config over the profile's own configuration.
|
||||
///
|
||||
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
|
||||
/// out of the global config from inside its loop, which made a stage that looks pure depend
|
||||
/// on process state and left it untestable.
|
||||
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
||||
fn merge_default_config(
|
||||
mut config: Mapping,
|
||||
clash_config: Mapping,
|
||||
@@ -683,9 +673,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
|
||||
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
|
||||
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
|
||||
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
|
||||
fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
@@ -708,38 +696,77 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
|
||||
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
|
||||
fn is_set(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::Bool(on) => *on,
|
||||
Value::String(text) => !text.trim().is_empty(),
|
||||
Value::Sequence(items) => !items.is_empty(),
|
||||
Value::Mapping(map) => !map.is_empty(),
|
||||
Value::Number(_) | Value::Tagged(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay set fields onto base; maps merge per key, lists are leaves.
|
||||
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
|
||||
for (key, value) in overlay {
|
||||
let value = match (base.get_mut(&key), value) {
|
||||
(Some(Value::Mapping(existing)), Value::Mapping(patch)) => {
|
||||
*existing = merge_set_fields(std::mem::take(existing), patch);
|
||||
continue;
|
||||
}
|
||||
(_, Value::Mapping(patch)) => Value::Mapping(merge_set_fields(Mapping::new(), patch)),
|
||||
(_, value) => value,
|
||||
};
|
||||
if is_set(&value) {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
|
||||
config
|
||||
.get_mut(key)
|
||||
.and_then(Value::as_mapping_mut)
|
||||
.map(std::mem::take)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the merged config and whether the page set `dns.ipv6`.
|
||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
|
||||
let mut owns_ipv6 = false;
|
||||
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
|
||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||
|
||||
if dns_path.exists()
|
||||
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
|
||||
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
|
||||
&& let Ok(mut dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
|
||||
{
|
||||
if let Some(hosts_value) = dns_config.get("hosts")
|
||||
&& hosts_value.is_mapping()
|
||||
{
|
||||
config.insert("hosts".into(), hosts_value.clone());
|
||||
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts") {
|
||||
let hosts = merge_set_fields(take_mapping(&mut config, "hosts"), hosts);
|
||||
config.insert("hosts".into(), hosts.into());
|
||||
logging!(debug, Type::Core, "apply hosts configuration");
|
||||
}
|
||||
|
||||
if let Some(dns_value) = dns_config.get("dns") {
|
||||
if let Some(dns_mapping) = dns_value.as_mapping() {
|
||||
let mut dns_mapping = dns_mapping.clone();
|
||||
ensure_fake_ip_range6(&mut dns_mapping);
|
||||
config.insert("dns".into(), dns_mapping.into());
|
||||
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)");
|
||||
}
|
||||
} else {
|
||||
let mut dns_config = dns_config;
|
||||
ensure_fake_ip_range6(&mut dns_config);
|
||||
config.insert("dns".into(), dns_config.into());
|
||||
// Legacy layout: no `dns` root.
|
||||
let dns_override = match dns_config.remove("dns") {
|
||||
Some(Value::Mapping(dns)) => Some(dns),
|
||||
Some(_) => None,
|
||||
None => Some(dns_config),
|
||||
};
|
||||
if let Some(dns_override) = dns_override {
|
||||
owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
|
||||
let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
|
||||
ensure_fake_ip_range6(&mut dns);
|
||||
config.insert("dns".into(), dns.into());
|
||||
logging!(debug, Type::Core, "apply dns_config.yaml");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
(config, owns_ipv6)
|
||||
}
|
||||
|
||||
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
|
||||
@@ -800,9 +827,9 @@ pub async fn enhance(
|
||||
|
||||
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
|
||||
let config = use_tun(config, enable_tun);
|
||||
let config = apply_dns_settings(config, enable_dns_settings).await;
|
||||
let (config, owns_dns_ipv6) = apply_dns_settings(config, enable_dns_settings).await;
|
||||
|
||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, enable_dns_settings);
|
||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, owns_dns_ipv6);
|
||||
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
config,
|
||||
@@ -864,8 +891,7 @@ mod fake_ip_tests {
|
||||
|
||||
#[test]
|
||||
fn a_hand_edited_empty_range_counts_as_missing() {
|
||||
// The reason this is not just a `contains_key` check: YAML edited by hand often
|
||||
// leaves the key present with nothing after the colon.
|
||||
// Hand-edited YAML may leave the key present but empty.
|
||||
for blank in ["", " "] {
|
||||
let mut config = dns(&[
|
||||
("ipv6", Value::from(true)),
|
||||
@@ -1004,8 +1030,7 @@ mod use_tun_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn switching_tun_off_leaves_dns_untouched() {
|
||||
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
|
||||
// resolver is restored, which is not part of the configuration.
|
||||
// TUN off must not rewrite the DNS the profile chose.
|
||||
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
|
||||
|
||||
let result = use_tun(config, false);
|
||||
@@ -1038,8 +1063,7 @@ mod use_sort_tests {
|
||||
|
||||
#[test]
|
||||
fn the_bulky_list_fields_are_written_last() {
|
||||
// These are the fields that make a config file unreadable from the top, so they are
|
||||
// pushed below the settings a human actually scans for.
|
||||
// Bulky list fields go last so the top of the file stays readable.
|
||||
let mut config = Mapping::new();
|
||||
config.insert(Value::from("rules"), Value::from("rules"));
|
||||
config.insert(Value::from("mode"), Value::from("rule"));
|
||||
@@ -1129,8 +1153,7 @@ mod merge_default_config_tests {
|
||||
|
||||
#[test]
|
||||
fn a_disabled_listener_is_removed_rather_than_merged() {
|
||||
// Removed, not just skipped: the profile may have asked for the port itself, and a
|
||||
// listener the user switched off must not be opened by the profile's own value.
|
||||
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
|
||||
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
|
||||
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
|
||||
|
||||
@@ -1152,8 +1175,7 @@ mod merge_default_config_tests {
|
||||
|
||||
#[test]
|
||||
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
|
||||
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
|
||||
// listen anyway, which is the opposite of what switching it off means.
|
||||
// Blanked, not removed: an absent key makes mihomo listen on its default.
|
||||
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
|
||||
|
||||
let merged = merge(Mapping::new(), clash_config, false);
|
||||
|
||||
@@ -163,6 +163,7 @@ mod app_init {
|
||||
cmd::apply_dns_config,
|
||||
cmd::set_dns_override,
|
||||
cmd::take_dns_override_notice,
|
||||
cmd::take_service_fallback_notice,
|
||||
cmd::get_dns_config_content,
|
||||
cmd::validate_dns_config,
|
||||
cmd::get_clash_logs,
|
||||
@@ -341,8 +342,6 @@ 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") {
|
||||
|
||||
@@ -228,27 +228,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Raise existing sub-floor update intervals, once per install.
|
||||
///
|
||||
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
|
||||
/// them, so the marker is written even when nothing needed raising.
|
||||
/// Once per install; the marker is written even when no interval needed raising.
|
||||
pub async fn migrate_short_update_intervals() -> Result<()> {
|
||||
let marker = dirs::update_interval_migrated_path()?;
|
||||
if fs::try_exists(&marker).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Same order as every other writer holding it (lock, then draft permit): save_file runs
|
||||
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
|
||||
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
|
||||
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let min = constants::profile::MIN_UPDATE_INTERVAL;
|
||||
let raised = Config::profiles()
|
||||
.await
|
||||
.with_data_modify(|mut profiles: IProfiles| async move {
|
||||
// `IProfiles::new` normalises items to Some when it read the file, and returns a
|
||||
// bare default() when it failed. A failed load also raises zero — without this the
|
||||
// marker would burn the one shot on profiles nobody managed to load.
|
||||
// A failed profiles load (bare default) must not burn the one-shot marker.
|
||||
if profiles.items.is_none() {
|
||||
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
|
||||
}
|
||||
@@ -370,14 +364,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
|
||||
"nameserver-policy".into(),
|
||||
Value::Mapping(serde_yaml_ng::Mapping::new()),
|
||||
),
|
||||
(
|
||||
"proxy-server-nameserver".into(),
|
||||
Value::Sequence(vec![
|
||||
Value::String("https://doh.pub/dns-query".into()),
|
||||
Value::String("https://dns.alidns.com/dns-query".into()),
|
||||
Value::String("tls://223.5.5.5".into()),
|
||||
]),
|
||||
),
|
||||
("proxy-server-nameserver".into(), Value::Sequence(vec![])),
|
||||
("direct-nameserver".into(), Value::Sequence(vec![])),
|
||||
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
|
||||
(
|
||||
|
||||
@@ -8,8 +8,6 @@ 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<()> {
|
||||
@@ -27,19 +25,6 @@ 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,
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
},
|
||||
"rpm": {
|
||||
"depends": ["openssl", "libayatana-appindicator-gtk3"],
|
||||
"recommends": ["policycoreutils", "policycoreutils-python-utils"],
|
||||
"desktopTemplate": "./packages/linux/clash-verge.desktop",
|
||||
"provides": ["clash-verge"],
|
||||
"conflicts": ["clash-verge"],
|
||||
"obsoletes": ["clash-verge"],
|
||||
"postInstallScript": "./packages/linux/post-install.sh",
|
||||
"postInstallScript": "./packages/linux/post-install-rpm.sh",
|
||||
"preRemoveScript": "./packages/linux/pre-remove.sh"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,20 +2,14 @@
|
||||
"$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": ["generated/widget/VergeWidgetBridge.dylib"],
|
||||
"files": {
|
||||
"PlugIns/VergeWidget.appex": "generated/widget/VergeWidget.appex"
|
||||
},
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "11.0",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"entitlements": "generated/widget/Host.entitlements",
|
||||
"entitlements": "packages/macos/entitlements.plist",
|
||||
"dmg": {
|
||||
"background": "images/background.png",
|
||||
"appPosition": {
|
||||
@@ -35,7 +29,7 @@
|
||||
"y": 180
|
||||
}
|
||||
},
|
||||
"infoPlist": "generated/widget/Host.plist"
|
||||
"infoPlist": "packages/macos/info_merge.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,24 @@ interface SortableItemProps {
|
||||
index: number
|
||||
group?: string
|
||||
disabled?: boolean
|
||||
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
|
||||
measureElement?: (element: HTMLDivElement | null) => void
|
||||
dataIndex?: number
|
||||
style?: CSSProperties
|
||||
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
|
||||
}
|
||||
|
||||
export const SortableItem = (props: SortableItemProps) => {
|
||||
const { id, index, group, disabled, children, style } = props
|
||||
const {
|
||||
id,
|
||||
index,
|
||||
group,
|
||||
disabled,
|
||||
measureElement,
|
||||
dataIndex,
|
||||
children,
|
||||
style,
|
||||
} = props
|
||||
const { ref, handleRef, sortable, isDragging } = useSortable({
|
||||
id,
|
||||
index,
|
||||
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
|
||||
(element: HTMLDivElement | null) => {
|
||||
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
|
||||
ref(element)
|
||||
measureElement?.(element)
|
||||
},
|
||||
[handleRef, ref],
|
||||
[handleRef, ref, measureElement],
|
||||
)
|
||||
|
||||
const mergedStyle: CSSProperties = {
|
||||
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setRef} style={mergedStyle}>
|
||||
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 = data >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||
const text = Math.round(data) >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||
return `${text} ${TRAFFIC_UNITS[exp]}`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @eslint-react/set-state-in-effect */
|
||||
import {
|
||||
AccessTimeRounded,
|
||||
ArrowDropDown,
|
||||
ChevronRight,
|
||||
NetworkCheckRounded,
|
||||
WifiOff as SignalError,
|
||||
@@ -15,25 +16,22 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonBase,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
type MenuProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Paper,
|
||||
Popper,
|
||||
Select,
|
||||
type SelectChangeEvent,
|
||||
type PopperProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
@@ -68,6 +66,40 @@ const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
|
||||
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
|
||||
const AUTO_CHECK_INITIAL_DELAY_MS = 100
|
||||
const PROXY_MENU_MAX_HEIGHT = 500
|
||||
const PROXY_MENU_VIEWPORT_MARGIN = 8
|
||||
|
||||
const proxyMenuModifiers: PopperProps['modifiers'] = [
|
||||
{
|
||||
name: 'menuSize',
|
||||
enabled: true,
|
||||
phase: 'beforeRead',
|
||||
fn: ({ state }) => {
|
||||
const popper = state.elements.popper
|
||||
const anchor = state.elements.reference.getBoundingClientRect()
|
||||
const viewportHeight = popper.ownerDocument.documentElement.clientHeight
|
||||
// Use the larger side before flip runs so sizing cannot alternate with placement.
|
||||
const availableHeight =
|
||||
Math.max(anchor.top, viewportHeight - anchor.bottom) -
|
||||
PROXY_MENU_VIEWPORT_MARGIN
|
||||
popper.style.width = `${anchor.width}px`
|
||||
popper.style.setProperty(
|
||||
'--proxy-menu-max-height',
|
||||
`${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`,
|
||||
)
|
||||
state.rects.popper.width = popper.offsetWidth
|
||||
state.rects.popper.height = popper.offsetHeight
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'showMenu',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
requires: ['computeStyles'],
|
||||
fn: ({ state }) => {
|
||||
state.styles.popper.visibility = 'visible'
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface ProxyOption {
|
||||
memberIndex: number
|
||||
@@ -163,34 +195,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
|
||||
member.kind === 'node' ? member.node.recordId : member.ref.name
|
||||
}`
|
||||
|
||||
const hasPointerCoordinates = (
|
||||
value: unknown,
|
||||
): value is { clientX: number; clientY: number } =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'clientX' in value &&
|
||||
typeof value.clientX === 'number' &&
|
||||
'clientY' in value &&
|
||||
typeof value.clientY === 'number'
|
||||
|
||||
const eventHitsElement = (event: unknown, element: HTMLElement | null) => {
|
||||
if (!element) return false
|
||||
|
||||
const nativeEvent =
|
||||
typeof event === 'object' && event !== null && 'nativeEvent' in event
|
||||
? event.nativeEvent
|
||||
: event
|
||||
if (!hasPointerCoordinates(nativeEvent)) return false
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
nativeEvent.clientX >= rect.left &&
|
||||
nativeEvent.clientX <= rect.right &&
|
||||
nativeEvent.clientY >= rect.top &&
|
||||
nativeEvent.clientY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
const sortProxyOptions = (
|
||||
options: ProxyOption[],
|
||||
sortType: ProxySortType,
|
||||
@@ -216,58 +220,71 @@ const sortProxyOptions = (
|
||||
.map(({ option }) => option)
|
||||
}
|
||||
|
||||
interface PersistentProxySelectProps {
|
||||
interface PersistentSelectProps {
|
||||
label: string
|
||||
groupName: string
|
||||
fixed?: string
|
||||
value: string
|
||||
selectedName: string
|
||||
selectedDelay: number
|
||||
options: ProxyOption[]
|
||||
displayValue: React.ReactNode
|
||||
open: boolean
|
||||
disabled: boolean
|
||||
keepOpenRef: React.RefObject<HTMLElement | null>
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
onChange: (value: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
renderOptions: () => React.ReactNode
|
||||
}
|
||||
|
||||
const PersistentProxySelect = ({
|
||||
const PersistentSelect = ({
|
||||
label,
|
||||
groupName,
|
||||
fixed,
|
||||
value,
|
||||
selectedName,
|
||||
selectedDelay,
|
||||
options,
|
||||
displayValue,
|
||||
open,
|
||||
disabled,
|
||||
keepOpenRef,
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
}: PersistentProxySelectProps) => {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const listboxId = 'current-proxy-node-listbox'
|
||||
const labelId = 'proxy-select-label'
|
||||
const fixedProxyInUsed = selectedName === fixed
|
||||
onOpenChange,
|
||||
renderOptions,
|
||||
}: PersistentSelectProps) => {
|
||||
const id = useId()
|
||||
const labelId = `${id}-label`
|
||||
const valueId = `${id}-value`
|
||||
const listboxId = `${id}-listbox`
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
const popperOptions = useMemo<PopperProps['popperOptions']>(
|
||||
() => ({
|
||||
onFirstUpdate: () => {
|
||||
const list = listRef.current
|
||||
const paper = list?.parentElement
|
||||
const item =
|
||||
list?.querySelector<HTMLElement>(
|
||||
'[aria-selected="true"]:not([aria-disabled="true"])',
|
||||
) ??
|
||||
list?.querySelector<HTMLElement>(
|
||||
'[role="option"]:not([aria-disabled="true"])',
|
||||
)
|
||||
if (!item || !paper) return
|
||||
|
||||
// Focusing before positioning can scroll the page instead of just the menu.
|
||||
item.focus({ preventScroll: true })
|
||||
paper.scrollTop +=
|
||||
item.getBoundingClientRect().top -
|
||||
paper.getBoundingClientRect().top -
|
||||
(paper.clientHeight - item.offsetHeight) / 2
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const closeFromKeyboard = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab') {
|
||||
onClose()
|
||||
onOpenChange(false)
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose()
|
||||
anchorRef.current
|
||||
?.querySelector<HTMLElement>('[role="combobox"]')
|
||||
?.focus()
|
||||
onOpenChange(false)
|
||||
anchorRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', closeFromKeyboard)
|
||||
return () => document.removeEventListener('keydown', closeFromKeyboard)
|
||||
}, [onClose, open])
|
||||
}, [onOpenChange, open])
|
||||
|
||||
return (
|
||||
<ClickAwayListener
|
||||
@@ -277,134 +294,119 @@ const PersistentProxySelect = ({
|
||||
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
|
||||
<InputLabel id={labelId}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={labelId}
|
||||
value={value}
|
||||
open={false}
|
||||
onOpen={onOpen}
|
||||
label={label}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 1,
|
||||
'& fieldset': {
|
||||
borderColor: (theme) =>
|
||||
alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
|
||||
},
|
||||
...(!disabled && {
|
||||
'&:hover fieldset': { borderColor: 'text.primary' },
|
||||
'&:focus-within fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
borderWidth: 2,
|
||||
},
|
||||
'&:focus-within legend': { color: 'primary.main' },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<ButtonBase
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
renderValue={() => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography noWrap>{selectedName}</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(selectedDelay)}
|
||||
color={convertDelayColor(selectedDelay)}
|
||||
/>
|
||||
{fixedProxyInUsed && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
fontSize: '12px',
|
||||
top: '-3px',
|
||||
right: '25px',
|
||||
}}
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
SelectDisplayProps={{
|
||||
'aria-controls': open ? listboxId : undefined,
|
||||
'aria-expanded': open ? 'true' : 'false',
|
||||
onClick={() => onOpenChange(!open)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
onOpenChange(true)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 40,
|
||||
px: 1.75,
|
||||
borderRadius: 'inherit',
|
||||
justifyContent: 'space-between',
|
||||
textAlign: 'left',
|
||||
color: disabled ? 'text.disabled' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
<MenuItem value={value}>{selectedName}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
|
||||
{displayValue}
|
||||
</Box>
|
||||
<ArrowDropDown
|
||||
sx={{
|
||||
ml: 1,
|
||||
color: disabled ? 'action.disabled' : 'action.active',
|
||||
transform: open ? 'rotate(180deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
</ButtonBase>
|
||||
<Box
|
||||
component="fieldset"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '-5px 0 0',
|
||||
m: 0,
|
||||
px: 1,
|
||||
minWidth: 0,
|
||||
border: '1px solid',
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="legend"
|
||||
id={labelId}
|
||||
sx={{
|
||||
px: 0.5,
|
||||
typography: 'caption',
|
||||
lineHeight: '11px',
|
||||
color: disabled ? 'text.disabled' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
placement="bottom-start"
|
||||
modifiers={proxyMenuModifiers}
|
||||
popperOptions={popperOptions}
|
||||
sx={{
|
||||
width: anchorRef.current?.clientWidth,
|
||||
visibility: 'hidden',
|
||||
zIndex: (theme) => theme.zIndex.modal,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
|
||||
sx={{
|
||||
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<MenuList
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={labelId}
|
||||
autoFocusItem
|
||||
variant="selectedMenu"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value
|
||||
const isFixed = option.name === fixed
|
||||
const delay = option.disabled
|
||||
? -1
|
||||
: delayManager.getDelayFix(option.member, groupName)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
selected={selected}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (!option.disabled) onChange(option.value)
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
{isFixed && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
fontSize: '12px',
|
||||
top: '-5px',
|
||||
right: '5px',
|
||||
...(!fixedProxyInUsed && {
|
||||
filter: 'grayscale(1)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
{!option.disabled && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delay)}
|
||||
color={convertDelayColor(delay)}
|
||||
sx={{
|
||||
minWidth: '60px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
{open && renderOptions()}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</Popper>
|
||||
@@ -413,6 +415,124 @@ const PersistentProxySelect = ({
|
||||
)
|
||||
}
|
||||
|
||||
interface PersistentProxySelectProps
|
||||
extends Omit<PersistentSelectProps, 'displayValue' | 'renderOptions'> {
|
||||
value: string
|
||||
selectedName: string
|
||||
groupName: string
|
||||
fixed?: string
|
||||
selectedDelay: number
|
||||
options: ProxyOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const PersistentProxySelect = ({
|
||||
groupName,
|
||||
fixed,
|
||||
selectedName,
|
||||
selectedDelay,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: PersistentProxySelectProps) => {
|
||||
const fixedProxyInUsed = selectedName === fixed
|
||||
|
||||
return (
|
||||
<PersistentSelect
|
||||
{...props}
|
||||
displayValue={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography noWrap>{selectedName}</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(selectedDelay)}
|
||||
color={convertDelayColor(selectedDelay)}
|
||||
/>
|
||||
{fixedProxyInUsed && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
fontSize: '12px',
|
||||
top: '-3px',
|
||||
right: '25px',
|
||||
}}
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
renderOptions={() =>
|
||||
options.map((option) => {
|
||||
const selected = option.value === value
|
||||
const isFixed = option.name === fixed
|
||||
const delay = option.disabled
|
||||
? -1
|
||||
: delayManager.getDelayFix(option.member, groupName)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
selected={selected}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (!option.disabled) onChange(option.value)
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
{isFixed && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
fontSize: '12px',
|
||||
top: '-5px',
|
||||
right: '5px',
|
||||
...(!fixedProxyInUsed && {
|
||||
filter: 'grayscale(1)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
{!option.disabled && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delay)}
|
||||
color={convertDelayColor(delay)}
|
||||
sx={{
|
||||
minWidth: '60px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const CurrentProxyCard = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -497,6 +617,11 @@ export const CurrentProxyCard = () => {
|
||||
|
||||
const [selectedGroupName, setSelectedGroupName] = useState('')
|
||||
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setOpenSelect(null)
|
||||
}, [mode])
|
||||
|
||||
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const delays = useGroupDelays(selectedGroupName || null)
|
||||
|
||||
@@ -621,9 +746,8 @@ export const CurrentProxyCard = () => {
|
||||
latestProxyMemberRef.current = currentOption?.member ?? null
|
||||
|
||||
const handleGroupChange = useCallback(
|
||||
(event: SelectChangeEvent<string>) => {
|
||||
(newGroupName: string) => {
|
||||
if (isGlobalMode || isDirectMode) return
|
||||
const newGroupName = event.target.value
|
||||
setSelectedGroupName(newGroupName)
|
||||
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
|
||||
},
|
||||
@@ -785,24 +909,9 @@ export const CurrentProxyCard = () => {
|
||||
refreshProxy()
|
||||
})
|
||||
|
||||
const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
|
||||
(event, reason) => {
|
||||
if (
|
||||
reason === 'backdropClick' &&
|
||||
eventHitsElement(event, delayButtonRef.current)
|
||||
) {
|
||||
void handleCheckDelay()
|
||||
return
|
||||
}
|
||||
|
||||
setOpenSelect(null)
|
||||
},
|
||||
[handleCheckDelay],
|
||||
)
|
||||
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
isDirectMode
|
||||
isDirectMode || openSelect !== 'proxy'
|
||||
? []
|
||||
: sortProxyOptions(
|
||||
unsortedProxyOptions,
|
||||
@@ -813,6 +922,7 @@ export const CurrentProxyCard = () => {
|
||||
[
|
||||
delays,
|
||||
isDirectMode,
|
||||
openSelect,
|
||||
unsortedProxyOptions,
|
||||
sortType,
|
||||
defaultLatencyTimeout,
|
||||
@@ -975,32 +1085,29 @@ export const CurrentProxyCard = () => {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<FormControl
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<InputLabel id="proxy-group-select-label">
|
||||
{t('home.components.currentProxy.labels.group')}
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="proxy-group-select-label"
|
||||
value={selectedGroupName}
|
||||
onChange={handleGroupChange}
|
||||
open={openSelect === 'group'}
|
||||
onOpen={() => setOpenSelect('group')}
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<PersistentSelect
|
||||
label={t('home.components.currentProxy.labels.group')}
|
||||
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
|
||||
open={openSelect === 'group'}
|
||||
disabled={isGlobalMode || isDirectMode}
|
||||
MenuProps={{ onClose: handleGroupMenuClose }}
|
||||
>
|
||||
{selectableGroups.map((group) => (
|
||||
<MenuItem key={group.name} value={group.name}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
keepOpenRef={delayButtonRef}
|
||||
onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
|
||||
renderOptions={() =>
|
||||
selectableGroups.map((group) => (
|
||||
<MenuItem
|
||||
key={group.name}
|
||||
role="option"
|
||||
aria-selected={group.name === selectedGroupName}
|
||||
selected={group.name === selectedGroupName}
|
||||
onClick={() => handleGroupChange(group.name)}
|
||||
>
|
||||
<Typography noWrap>{group.name}</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<PersistentProxySelect
|
||||
label={t('home.components.currentProxy.labels.proxy')}
|
||||
@@ -1013,12 +1120,11 @@ export const CurrentProxyCard = () => {
|
||||
}
|
||||
selectedName={selectedProxyName}
|
||||
selectedDelay={currentDelay}
|
||||
options={isDirectMode ? [] : proxyOptions}
|
||||
options={proxyOptions}
|
||||
open={openSelect === 'proxy'}
|
||||
disabled={isDirectMode}
|
||||
keepOpenRef={delayButtonRef}
|
||||
onOpen={() => setOpenSelect('proxy')}
|
||||
onClose={() => setOpenSelect(null)}
|
||||
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
|
||||
onChange={handleProxyChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router'
|
||||
import { useAppRefreshers } from '@/providers/app-data-context'
|
||||
import { updateProfile } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
@@ -41,7 +42,7 @@ const round = keyframes`
|
||||
// 辅助函数解析URL和过期时间
|
||||
const parseUrl = (url?: string) => {
|
||||
if (!url) return '-'
|
||||
if (url.startsWith('http')) return new URL(url).host
|
||||
if (isValidUrl(url)) return new URL(url).host
|
||||
return 'local'
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,8 @@ export const SystemInfoCard = () => {
|
||||
goToSettings()
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
console.warn('update check failed:', err)
|
||||
showNotice.warning(err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ServiceRequest,
|
||||
type ServiceRequestReason,
|
||||
} from '@/services/service-request'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
type Remedy = 'installAndRestart' | 'restartOnly'
|
||||
|
||||
@@ -98,19 +99,25 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
setStep('restarting')
|
||||
await restartCore()
|
||||
|
||||
// Verify that restart actually moved the core into the service.
|
||||
const runState = await getRuntimeState()
|
||||
if (runState.mode === 'Service') {
|
||||
// Retry the original request now that the service can apply it.
|
||||
const usingAdminFallback =
|
||||
getSystem() === 'windows' &&
|
||||
runState.mode === 'Sidecar' &&
|
||||
runState.isAdmin &&
|
||||
runState.sidecarAllowed &&
|
||||
runState.service === 'unavailable'
|
||||
if (runState.mode === 'Service' || usingAdminFallback) {
|
||||
if (restoring !== undefined) {
|
||||
setStep('applying')
|
||||
await patchVergeConfig(restoring)
|
||||
}
|
||||
showNotice.success(
|
||||
restoring === undefined
|
||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
)
|
||||
if (!usingAdminFallback) {
|
||||
showNotice.success(
|
||||
restoring === undefined
|
||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
)
|
||||
}
|
||||
close()
|
||||
} else {
|
||||
showNotice.error(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { move } from '@dnd-kit/helpers'
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type CSSProperties, type ReactNode, useRef, useState } from 'react'
|
||||
|
||||
import { SortableItem } from '../base'
|
||||
|
||||
type GroupedVirtualCategory = 'prepend' | 'original' | 'append'
|
||||
|
||||
export interface GroupedVirtualItem<T> {
|
||||
id: string
|
||||
category: GroupedVirtualCategory
|
||||
item: T
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function buildGroupedItems<T>(
|
||||
prepend: T[],
|
||||
original: T[],
|
||||
append: T[],
|
||||
getKey: (item: T) => string,
|
||||
): GroupedVirtualItem<T>[] {
|
||||
const build = (
|
||||
category: GroupedVirtualCategory,
|
||||
list: T[],
|
||||
): GroupedVirtualItem<T>[] =>
|
||||
list.map((item) => ({
|
||||
id: `${category}:${getKey(item)}`,
|
||||
category,
|
||||
item,
|
||||
}))
|
||||
|
||||
return [
|
||||
...build('prepend', prepend),
|
||||
...build('original', original),
|
||||
...build('append', append),
|
||||
]
|
||||
}
|
||||
|
||||
interface GroupedVirtualListProps<T> {
|
||||
items: GroupedVirtualItem<T>[]
|
||||
estimateSize?: number
|
||||
renderItem: (entry: GroupedVirtualItem<T>) => ReactNode
|
||||
onReorder: (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => void
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于编辑规则/节点/代理组功能的 prepend/original/append 分组虚拟化列表
|
||||
*/
|
||||
export function GroupedVirtualList<T>(props: GroupedVirtualListProps<T>) {
|
||||
const {
|
||||
items: itemsProp,
|
||||
estimateSize = 56,
|
||||
renderItem,
|
||||
onReorder,
|
||||
style,
|
||||
} = props
|
||||
const [items, setItems] = useState(itemsProp)
|
||||
const [prevItemsProp, setPrevItemsProp] = useState(itemsProp)
|
||||
const snapshotRef = useRef(itemsProp)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
if (itemsProp !== prevItemsProp) {
|
||||
setPrevItemsProp(itemsProp)
|
||||
setItems(itemsProp)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => estimateSize,
|
||||
overscan: 15,
|
||||
gap: 8,
|
||||
getItemKey: (index) => items[index]?.id ?? index,
|
||||
})
|
||||
|
||||
const onDragStart = () => {
|
||||
snapshotRef.current = items
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (!source || !target) return
|
||||
|
||||
const sourceEntry = items.find((entry) => entry.id === source.id)
|
||||
const targetEntry = items.find((entry) => entry.id === target.id)
|
||||
if (
|
||||
!sourceEntry ||
|
||||
!targetEntry ||
|
||||
sourceEntry.category !== targetEntry.category
|
||||
) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
setItems((current) => move(current, event))
|
||||
}
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
|
||||
if (event.canceled) {
|
||||
setItems(snapshotRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const snapshot = snapshotRef.current
|
||||
const sourceEntry = snapshot.find((entry) => entry.id === source.id)
|
||||
if (!sourceEntry) return
|
||||
|
||||
const { category } = sourceEntry
|
||||
if (category !== 'prepend' && category !== 'append') return
|
||||
|
||||
const activeIndex = snapshot
|
||||
.filter((entry) => entry.category === category)
|
||||
.findIndex((entry) => entry.id === source.id)
|
||||
const overIndex = items
|
||||
.filter((entry) => entry.category === category)
|
||||
.findIndex((entry) => entry.id === source.id)
|
||||
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) return
|
||||
|
||||
onReorder(category, activeIndex, overIndex)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div ref={scrollRef} style={{ ...style, overflow: 'auto' }}>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => {
|
||||
const entry = items[vi.index]
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<SortableItem
|
||||
key={vi.key}
|
||||
id={entry.id}
|
||||
index={vi.index}
|
||||
disabled={entry.category === 'original'}
|
||||
measureElement={virtualizer.measureElement}
|
||||
dataIndex={vi.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${vi.start}px`,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{renderItem(entry)}
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import {
|
||||
VerticalAlignBottomRounded,
|
||||
VerticalAlignTopRounded,
|
||||
@@ -43,13 +35,7 @@ import {
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BaseSearchBox,
|
||||
MonacoEditor,
|
||||
SortableItem,
|
||||
Switch,
|
||||
VirtualList,
|
||||
} from '@/components/base'
|
||||
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||
import { GroupItem } from '@/components/profile/group-item'
|
||||
import {
|
||||
getNetworkInterfaces,
|
||||
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
|
||||
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
||||
import { parseYamlSafe } from '@/utils/yaml'
|
||||
|
||||
import {
|
||||
buildGroupedItems,
|
||||
type GroupedVirtualItem,
|
||||
GroupedVirtualList,
|
||||
} from './grouped-virtual-list'
|
||||
|
||||
interface Props {
|
||||
proxiesUid: string
|
||||
mergeUid: string
|
||||
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
[appendSeq, match],
|
||||
)
|
||||
|
||||
const renderItem = (index: number): React.ReactNode => {
|
||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildGroupedItems(
|
||||
filteredPrependSeq,
|
||||
filteredGroupList,
|
||||
filteredAppendSeq,
|
||||
(group) => group.name,
|
||||
),
|
||||
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
|
||||
)
|
||||
|
||||
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
|
||||
const { category, item } = entry
|
||||
|
||||
if (category === 'original') {
|
||||
const isDeleted = deleteSeq.includes(item.name)
|
||||
return (
|
||||
<>
|
||||
{filteredPrependSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`prepend:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="prepend"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<GroupItem
|
||||
type="prepend"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter((v) => v.name !== item.name),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (index < filteredGroupList.length + shift) {
|
||||
const newIndex = index - shift
|
||||
return (
|
||||
<Box sx={{ margin: '8px 0' }}>
|
||||
<GroupItem
|
||||
key={filteredGroupList[newIndex].name}
|
||||
type={
|
||||
deleteSeq.includes(filteredGroupList[newIndex].name)
|
||||
? 'delete'
|
||||
: 'original'
|
||||
<GroupItem
|
||||
type={isDeleted ? 'delete' : 'original'}
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
if (isDeleted) {
|
||||
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, item.name])
|
||||
}
|
||||
group={filteredGroupList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredGroupList[newIndex].name,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredGroupList[newIndex].name,
|
||||
])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (category === 'prepend') {
|
||||
return (
|
||||
<>
|
||||
{filteredAppendSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`append:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="append"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<GroupItem
|
||||
type="append"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<GroupItem
|
||||
type="prepend"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupItem
|
||||
type="append"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
overIndex,
|
||||
)
|
||||
const onReorder = (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => {
|
||||
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||
const filtered =
|
||||
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
appendSeq,
|
||||
filteredAppendSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
activeRealIndex === overRealIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
|
||||
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||
}
|
||||
|
||||
// prevent drag over if source and target are in different groups
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (source?.group !== target?.group) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const { initialGroup, group } = source.sortable
|
||||
if (initialGroup !== group) return
|
||||
|
||||
if (group === 'prepend') {
|
||||
await onPrependDragEnd(event)
|
||||
} else if (group === 'append') {
|
||||
await onAppendDragEnd(event)
|
||||
}
|
||||
}
|
||||
const fetchContent = useCallback(async () => {
|
||||
hasLoadedSeqConfigRef.current = false
|
||||
const data = await readProfileFile(property)
|
||||
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<VirtualList
|
||||
count={
|
||||
filteredGroupList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
estimateSize={56}
|
||||
renderItem={renderItem}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
<GroupedVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onReorder={onReorder}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -43,6 +43,7 @@ import { showNotice } from '@/services/notice-service'
|
||||
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
@@ -965,9 +966,7 @@ export const ProfileItem = memo(ProfileItemBase)
|
||||
|
||||
function parseUrl(url?: string) {
|
||||
if (!url) return ''
|
||||
const regex = /https?:\/\/(.+?)\//
|
||||
const result = url.match(regex)
|
||||
return result ? result[1] : 'local file'
|
||||
return isValidUrl(url) ? new URL(url).host : 'local file'
|
||||
}
|
||||
|
||||
function parseExpire(expire?: number) {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import {
|
||||
VerticalAlignBottomRounded,
|
||||
VerticalAlignTopRounded,
|
||||
@@ -35,12 +27,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BaseSearchBox,
|
||||
MonacoEditor,
|
||||
SortableItem,
|
||||
VirtualList,
|
||||
} from '@/components/base'
|
||||
import { BaseSearchBox, MonacoEditor } from '@/components/base'
|
||||
import { ProxyItem } from '@/components/profile/proxy-item'
|
||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@@ -50,6 +37,12 @@ import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
||||
import parseUri from '@/utils/uri-parser'
|
||||
import { parseYamlSafe } from '@/utils/yaml'
|
||||
|
||||
import {
|
||||
buildGroupedItems,
|
||||
type GroupedVirtualItem,
|
||||
GroupedVirtualList,
|
||||
} from './grouped-virtual-list'
|
||||
|
||||
interface Props {
|
||||
profileUid: string
|
||||
property: string
|
||||
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
[appendSeq, match],
|
||||
)
|
||||
|
||||
const renderItem = (index: number): React.ReactNode => {
|
||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildGroupedItems(
|
||||
filteredPrependSeq,
|
||||
filteredProxyList,
|
||||
filteredAppendSeq,
|
||||
(proxy) => proxy.name,
|
||||
),
|
||||
[filteredPrependSeq, filteredProxyList, filteredAppendSeq],
|
||||
)
|
||||
|
||||
const renderItem = (entry: GroupedVirtualItem<IProxyConfig>) => {
|
||||
const { category, item } = entry
|
||||
|
||||
if (category === 'original') {
|
||||
const isDeleted = deleteSeq.includes(item.name)
|
||||
return (
|
||||
<>
|
||||
{filteredPrependSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`prepend:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="prepend"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<ProxyItem
|
||||
type="prepend"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter((v) => v.name !== item.name),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (index < filteredProxyList.length + shift) {
|
||||
const newIndex = index - shift
|
||||
return (
|
||||
<Box sx={{ margin: '8px 0' }}>
|
||||
<ProxyItem
|
||||
key={filteredProxyList[newIndex].name}
|
||||
type={
|
||||
deleteSeq.includes(filteredProxyList[newIndex].name)
|
||||
? 'delete'
|
||||
: 'original'
|
||||
<ProxyItem
|
||||
type={isDeleted ? 'delete' : 'original'}
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
if (isDeleted) {
|
||||
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, item.name])
|
||||
}
|
||||
proxy={filteredProxyList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredProxyList[newIndex].name,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredProxyList[newIndex].name,
|
||||
])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (category === 'prepend') {
|
||||
return (
|
||||
<>
|
||||
{filteredAppendSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`append:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="append"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<ProxyItem
|
||||
type="append"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<ProxyItem
|
||||
type="prepend"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProxyItem
|
||||
type="append"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
overIndex,
|
||||
)
|
||||
const onReorder = (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => {
|
||||
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||
const filtered =
|
||||
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
|
||||
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
appendSeq,
|
||||
filteredAppendSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
activeRealIndex === overRealIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (source?.group !== target?.group) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const { initialGroup, group } = source.sortable
|
||||
if (initialGroup !== group) return
|
||||
|
||||
if (group === 'prepend') {
|
||||
await onPrependDragEnd(event)
|
||||
} else if (group === 'append') {
|
||||
await onAppendDragEnd(event)
|
||||
}
|
||||
}
|
||||
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
|
||||
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
||||
const proxies: IProxyConfig[] = []
|
||||
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<VirtualList
|
||||
count={
|
||||
filteredProxyList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
estimateSize={56}
|
||||
renderItem={renderItem}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
<GroupedVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onReorder={onReorder}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
VerticalAlignBottomRounded,
|
||||
VerticalAlignTopRounded,
|
||||
@@ -36,13 +29,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BaseSearchBox,
|
||||
MonacoEditor,
|
||||
SortableItem,
|
||||
Switch,
|
||||
VirtualList,
|
||||
} from '@/components/base'
|
||||
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||
import { RuleItem } from '@/components/profile/rule-item'
|
||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
|
||||
import { isValidIpCidr } from '@/utils/network'
|
||||
import { parseYamlSafe } from '@/utils/yaml'
|
||||
|
||||
import {
|
||||
buildGroupedItems,
|
||||
type GroupedVirtualItem,
|
||||
GroupedVirtualList,
|
||||
} from './grouped-virtual-list'
|
||||
|
||||
interface Props {
|
||||
groupsUid: string
|
||||
mergeUid: string
|
||||
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
[appendSeq, match],
|
||||
)
|
||||
|
||||
const renderItem = (index: number): React.ReactNode => {
|
||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildGroupedItems(
|
||||
filteredPrependSeq,
|
||||
filteredRuleList,
|
||||
filteredAppendSeq,
|
||||
(rule) => rule,
|
||||
),
|
||||
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
|
||||
)
|
||||
|
||||
const renderItem = (entry: GroupedVirtualItem<string>) => {
|
||||
const { category, item } = entry
|
||||
|
||||
if (category === 'original') {
|
||||
const isDeleted = deleteSeq.includes(item)
|
||||
return (
|
||||
<>
|
||||
{filteredPrependSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item}
|
||||
id={`prepend:${item}`}
|
||||
index={itemIndex}
|
||||
group="prepend"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<RuleItem
|
||||
type="prepend"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onAppend={() => {
|
||||
setAppendSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (index < filteredRuleList.length + shift) {
|
||||
const newIndex = index - shift
|
||||
return (
|
||||
<Box sx={{ margin: '8px 0' }}>
|
||||
<RuleItem
|
||||
key={filteredRuleList[newIndex]}
|
||||
type={
|
||||
deleteSeq.includes(filteredRuleList[newIndex])
|
||||
? 'delete'
|
||||
: 'original'
|
||||
<RuleItem
|
||||
type={isDeleted ? 'delete' : 'original'}
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
if (isDeleted) {
|
||||
setDeleteSeq(deleteSeq.filter((v) => v !== item))
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, item])
|
||||
}
|
||||
ruleRaw={filteredRuleList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredRuleList[newIndex])) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
|
||||
)
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (category === 'prepend') {
|
||||
return (
|
||||
<>
|
||||
{filteredAppendSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item}
|
||||
id={`append:${item}`}
|
||||
index={itemIndex}
|
||||
group="append"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<RuleItem
|
||||
type="append"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onPrepend={() => {
|
||||
setPrependSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
<RuleItem
|
||||
type="prepend"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onAppend={() => {
|
||||
setAppendSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
})}
|
||||
</>
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RuleItem
|
||||
type="append"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onPrepend={() => {
|
||||
setPrependSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const reorder = (list: string[], startIndex: number, endIndex: number) => {
|
||||
const result = Array.from(list)
|
||||
const [removed] = result.splice(startIndex, 1)
|
||||
result.splice(endIndex, 0, removed)
|
||||
return result
|
||||
}
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
overIndex,
|
||||
)
|
||||
const onReorder = (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => {
|
||||
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||
const filtered =
|
||||
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
|
||||
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
appendSeq,
|
||||
filteredAppendSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
activeRealIndex === overRealIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (source?.group !== target?.group) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const { initialGroup, group } = source.sortable
|
||||
if (initialGroup !== group) return
|
||||
|
||||
if (group === 'prepend') {
|
||||
await onPrependDragEnd(event)
|
||||
} else if (group === 'append') {
|
||||
await onAppendDragEnd(event)
|
||||
}
|
||||
}
|
||||
const fetchContent = useCallback(async () => {
|
||||
hasLoadedSeqConfigRef.current = false
|
||||
const data = await readProfileFile(property)
|
||||
@@ -854,22 +770,12 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<VirtualList
|
||||
count={
|
||||
filteredRuleList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
estimateSize={56}
|
||||
renderItem={renderItem}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
<GroupedVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onReorder={onReorder}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
|
||||
import { BaseSearchBox, type SearchState } from '../base'
|
||||
|
||||
@@ -174,6 +176,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
if (testUrl?.trim() && textState !== 'filter') {
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||
return
|
||||
}
|
||||
onCheckDelay()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseSearchBox } from '@/components/base'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
|
||||
import type { ProxySortType } from './use-filter-sort'
|
||||
import type { HeadState } from './use-head-state'
|
||||
@@ -95,6 +97,10 @@ export const ProxyHead = ({
|
||||
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||
return
|
||||
}
|
||||
onCheckDelay()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -171,11 +171,7 @@ const DEFAULT_DNS_CONFIG = {
|
||||
],
|
||||
fallback: [],
|
||||
'nameserver-policy': {},
|
||||
'proxy-server-nameserver': [
|
||||
'https://doh.pub/dns-query',
|
||||
'https://dns.alidns.com/dns-query',
|
||||
'tls://223.5.5.5',
|
||||
],
|
||||
'proxy-server-nameserver': [],
|
||||
'direct-nameserver': [],
|
||||
'direct-nameserver-follow-policy': false,
|
||||
'fallback-filter': {
|
||||
@@ -340,8 +336,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
listen: values.listen,
|
||||
'enhanced-mode': values.enhancedMode,
|
||||
'fake-ip-range': values.fakeIpRange,
|
||||
'fake-ip-range6':
|
||||
values.fakeIpRange6 || DEFAULT_DNS_CONFIG['fake-ip-range6'],
|
||||
'fake-ip-range6': values.fakeIpRange6,
|
||||
'fake-ip-filter-mode': values.fakeIpFilterMode,
|
||||
'prefer-h3': values.preferH3,
|
||||
'respect-rules': values.respectRules,
|
||||
|
||||
@@ -31,6 +31,13 @@ export const StackModeSwitch = (props: Props) => {
|
||||
>
|
||||
Mixed
|
||||
</Button>
|
||||
<Button
|
||||
variant={value?.toLowerCase() === 'mips' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange?.('mips')}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
Mips
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
|
||||
} else {
|
||||
updateRef.current?.open()
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotice.error(err)
|
||||
} catch (err) {
|
||||
console.warn('update check failed:', err)
|
||||
showNotice.warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "تم تثبيت الخدمة بنجاح",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
|
||||
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Service erfolgreich installiert",
|
||||
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
|
||||
"uninstallSuccess": "Service erfolgreich deinstalliert"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Service Installed Successfully",
|
||||
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
|
||||
"uninstallSuccess": "Service Uninstalled Successfully"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Servicio instalado con éxito",
|
||||
"sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.",
|
||||
"uninstallSuccess": "Servicio desinstalado con éxito"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "سرویس با موفقیت نصب شد",
|
||||
"sidecarFallback": "سرویس بهدلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
|
||||
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Layanan Berhasil Diinstal",
|
||||
"sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.",
|
||||
"uninstallSuccess": "Layanan Berhasil Dicopot"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} updated successfully",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "サービスのインストールに成功しました。",
|
||||
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
|
||||
"uninstallSuccess": "サービスのアンインストールに成功しました。"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} 업데이트 성공",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "서비스가 설치되었습니다",
|
||||
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
|
||||
"uninstallSuccess": "서비스가 제거되었습니다"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warnings": {
|
||||
"invalidTestUrl": "Test URL must start with http:// or https://"
|
||||
},
|
||||
"notifications": {
|
||||
"provider": {
|
||||
"updateSuccess": "{{name}} успешно обновлён",
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Служба успешно установлена",
|
||||
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
|
||||
"uninstallSuccess": "Служба успешно удалена"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user