Compare commits

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

* refactor(core): simplify core readiness generation handling

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

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

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

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

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

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

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

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

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

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

* refactor(ui): rename measureElementRef to measureElement in sortable components
2026-09-16 12:23:12 +00:00
Tunglies e385d37e5b ci(gate): align linked-issue-check with the slop review trust tier
linked-issue-check only exempted drafts and the no-issue-needed
label, so member and collaborator PRs failed the linkage gate while
the slop review itself skipped them via skip-roles (#7944).

Skip OWNER, MEMBER, COLLABORATOR, and bot authors as well, reading
the association from the event payload so the permission-free gate
design holds. External contributors still need an issue reference.
2026-09-16 11:07:45 +08:00
Tunglies 2f097790ed fix(updater): downgrade manual check failure notice to warning
Manual update-check failures surfaced as red error toasts that read as
an app fault. Add a warning severity to the notice service and use it
in both check entry points alongside a console.warn; automatic checks
and install failures keep their existing behavior.
2026-09-16 09:14:12 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 619d6ae36a chore(deps): update npm dependencies (#7896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 17:37:05 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
de24496fa7 chore(deps): update cargo dependencies (#7765)
* chore(deps): update cargo dependencies

* chore(deps): Revert `rfd` to version 0.16.0

Assisted-by: vscode-copilot-chat:deepseek-flash
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-15 17:36:45 +00:00
wonfen f58e0a0af5 fix(service): stage approved cores during Windows service installation 2026-09-15 16:09:54 +08:00
Tunglies a8e8fe66dc fix(tray): patch tray-icon for macOS 27 click reentrancy
Close: #7256
2026-09-15 14:03:09 +08:00
Alechron 75c921b466 fix(traffic): avoid scientific notation in traffic display near 1000 threshold (#7928)
* fix(traffic): avoid scientific notation in traffic display near 1000 threshold

When a traffic value in any unit (KB, MB, etc.) falls in the 999.5–1000
range, toPrecision(3) rounds it to 1000 but outputs scientific notation
"1.00e+3" instead of plain "1000". Check Math.round(dat) >= 1000 instead
of dat >= 1000 to catch this rounding boundary.

Signed-off-by: Alec <alechron@outlook.com>

* fix(traffic): avoid scientific notation in formatConnectionTraffic

Signed-off-by: Alec <alechron@outlook.com>

---------

Signed-off-by: Alec <alechron@outlook.com>
2026-09-15 05:17:35 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e3bdea4386 chore(deps): update github/gh-aw-actions action to v0.89.15 (#7941)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 23:42:26 +00:00
Tunglies ce963d175a feat(tun): support mips stack from mihomo v1.19.31
Add `mips` to the TUN stack switch so the stack shipped in mihomo
v1.19.31 is selectable from the GUI. Older cores reject the value,
which the changelog entry discloses.
2026-09-15 00:50:20 +08:00
wonfen d289a608d9 refactor(sysproxy): delegate macOS network monitoring to sysproxy-rs 2026-09-14 22:10:33 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8a5548af10 chore(deps): update github/gh-aw-actions action to v0.89.13 (#7939)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 10:41:24 +00:00
wonfen 1d9bca13c8 chore(deps): bump clash-verge-service-ipc to v2.7.0
The service installer now admits the staged core copies through Windows
Firewall, so TUN system/mixed stacks pass traffic in service mode again
without users re-allowing the new path by hand.

Refs #7915
2026-09-14 18:33:53 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 6f19c3dfaa chore(deps): update github/gh-aw-actions action to v0.89.12 (#7934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 04:22:51 +00:00
126 changed files with 2697 additions and 4144 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.3.3"
"pnpmVersion": "12.5.1"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1",
+22 -7
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.1": {
"github/gh-aw-actions/setup@v0.89.15": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
"version": "v0.89.15",
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
}
},
"containers": {
@@ -22,6 +22,11 @@
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -32,6 +37,11 @@
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -42,10 +52,15 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
+8
View File
@@ -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
+2 -2
View File
@@ -3,8 +3,8 @@ name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: '0 4,10 * * *'
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 10,22 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
+2 -2
View File
@@ -31,6 +31,6 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
with:
version: v0.89.1
version: v0.89.15
File diff suppressed because one or more lines are too long
-1
View File
@@ -17,4 +17,3 @@ target
.vfox
.claude
.zed
/src-tauri/generated/widget/
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.4.1
pnpm 12.5.1
rust 1.98.1
Generated
+456 -612
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -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
View File
@@ -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 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
+1 -1
View File
@@ -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]
-11
View File
@@ -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: القيمة
-11
View File
@@ -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
-11
View File
@@ -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
-11
View File
@@ -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
-11
View File
@@ -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: مقدار
-11
View File
@@ -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
-11
View File
@@ -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: 値
-11
View File
@@ -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: 값
-11
View File
@@ -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 -19
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
'on': System proxy has been enabled.
'off': System proxy has been disabled.
'on': Sistem vekili etkinleştirildi.
'off': Sistem vekili devre dışı bırakıldı.
systemProxyFailed:
title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
@@ -18,8 +18,8 @@ notifications:
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled:
title: TUN Modu
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
'on': TUN modu etkinleştirildi.
'off': TUN modu devre dışı bırakıldı.
lightweightModeEntered:
title: Hafif Mod
body: Hafif moda geçildi.
@@ -33,10 +33,10 @@ notifications:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
title: Clash Verge Güncellemesi
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Şimdi Kur
later: Daha Sonra
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
@@ -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
-11
View File
@@ -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: Кыйммәт
-11
View File
@@ -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: 值
-11
View File
@@ -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: 值
-3
View File
@@ -1,3 +0,0 @@
# Xcode per-user state and local build output
xcuserdata/
DerivedData/
-19
View File
@@ -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
-232
View File
@@ -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;
}
@@ -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
}
-57
View File
@@ -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;
}
-219
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+430 -525
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -56,6 +56,12 @@
"matchFileNames": [".github/workflows/*.lock.yml"],
"enabled": false
},
{
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
"matchFileNames": [".github/workflows/**"],
"matchPackageNames": ["/gh-aw/"],
"enabled": false
},
{
"description": "Skip TypeScript 7.0 series",
"matchManagers": ["npm"],
@@ -78,7 +84,7 @@
},
{
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
"matchPackageNames": ["pnpm"],
"groupName": "pnpm"
},
{
@@ -131,6 +137,16 @@
],
"depNameTemplate": "rust-lang/rust",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"description": "Update pnpm in devcontainer.json",
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
"matchStrings": [
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "pnpm",
"datasourceTemplate": "npm"
}
],
"postUpdateOptions": ["pnpmDedupe"],
-62
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -1,5 +1,5 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ',
packageJson.version,
)
packageJson.version = newVersion.startsWith('v')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('version field was not found in package.json')
}
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating package.json version:', error)
throw error
@@ -153,6 +156,34 @@ async function updateCargoVersion(newVersion) {
}
}
async function updateCargoLockVersion(newVersion) {
const _dirname = process.cwd()
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const packageVersionPattern =
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
try {
const data = await fs.readFile(cargoLockPath, 'utf8')
const updatedData = data.replace(
packageVersionPattern,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('clash-verge package entry was not found in Cargo.lock')
}
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating Cargo.lock version:', error)
throw error
}
}
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('version field was not found in tauri.conf.json')
}
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
)
@@ -239,6 +273,7 @@ async function main(versionArg) {
console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion)
await updateCargoLockVersion(newVersion)
await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) {
+17 -20
View File
@@ -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
+2 -1
View File
@@ -210,7 +210,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
}
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
// A fragment, not a runnable config; the merged result is validated on apply.
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
.await
.stringify_err()
}
+5
View File
@@ -57,6 +57,11 @@ pub async fn continue_with_sidecar() -> CmdResult {
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
}
#[tauri::command]
pub fn take_service_fallback_notice() -> bool {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
-6
View File
@@ -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;
};
+61 -5
View File
@@ -185,6 +185,42 @@ where
apply_proxy().await
}
#[cfg(target_os = "windows")]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
is_admin: bool,
start_service: Start,
start_sidecar: Fallback,
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = Result<()>>,
{
let Err(error) = start_service().await else {
return Ok(());
};
// A transport failure cannot establish whether the service already started the core.
let location_refused = error
.downcast_ref::<crate::core::service::ServiceStartRefusal>()
.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if !is_admin || !location_refused {
return Err(error);
}
logging!(
warn,
Type::Core,
"service refused the core location while app is elevated; falling back to sidecar: {error:#}"
);
let reason = format!("{error:#}");
start_sidecar(error)
.await
.map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
clear_proxy: Clear,
terminate_sidecar: Terminate,
@@ -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(()),
};
+76 -50
View File
@@ -18,6 +18,44 @@ use tauri_plugin_shell::ShellExt as _;
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
#[cfg(target_os = "windows")]
async fn retry_service_start<Start, StartFuture>(
attempts: usize,
retry_delay: std::time::Duration,
mut start: Start,
) -> Result<()>
where
Start: FnMut() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
{
let mut last_error = None;
for attempt in 0..attempts {
match start().await {
Ok(()) => return Ok(()),
Err(error) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {error:#}",
attempt + 1,
attempts
);
if error
.downcast_ref::<service::ServiceStartRefusal>()
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
{
return Err(error);
}
last_error = Some(error);
if attempt + 1 < attempts {
tokio::time::sleep(retry_delay).await;
}
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
}
impl CoreManager {
/// Restores profile selections before callers enable the system proxy.
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
@@ -170,33 +208,10 @@ impl CoreManager {
let pid = child.pid();
tracing::Span::current().record("pid", pid);
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
// The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
// single event, so an unread channel stalls its reader threads and the core blocks on a
// full stdout pipe before it ever creates the API socket.
let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
@@ -226,6 +241,32 @@ impl CoreManager {
}
});
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
Ok(())
}
@@ -269,30 +310,15 @@ impl CoreManager {
#[cfg(target_os = "windows")]
{
use crate::constants::timing;
let mut last_err = None;
for attempt in 0..timing::SERVICE_START_RETRIES {
match service::run_core_by_service(config_file).await {
Ok(()) => {
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
return Ok(());
}
Err(e) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {e:#}",
attempt + 1,
timing::SERVICE_START_RETRIES
);
last_err = Some(e);
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
service::run_core_by_service(config_file)
})
.await?;
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
Ok(())
}
#[cfg(not(target_os = "windows"))]
-2
View File
@@ -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};
+27 -38
View File
@@ -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,
+4 -5
View File
@@ -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 {
+15
View File
@@ -323,6 +323,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(())
}
#[cfg(target_os = "windows")]
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
bail!("cannot fall back while a service operation is pending");
}
// Publish the refusal and allowance together so it never asks the UI for a repair.
state.service.health = ServiceHealth::Unavailable(reason);
state.service.allow_sidecar();
state.bump();
drop(state);
self.announce();
Ok(())
}
/// Requests installation only for a confirmed absent Service.
pub fn require_install_for_session(&self) -> Result<()> {
let mut state = self.service.lock();
+150 -7
View File
@@ -39,6 +39,17 @@ use std::{
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::sidecar_fallback", "");
}
pub(crate) fn take_service_fallback_notice() -> bool {
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
}
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
@@ -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")
}
-83
View File
@@ -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) });
}
+72 -50
View File
@@ -284,8 +284,7 @@ fn process_seq_items(
config
}
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
/// App 权威的顶层控制面键;`dns.ipv6` 单独处理。
const CONTROL_PLANE_KEYS: &[&str] = &[
"external-controller",
#[cfg(unix)]
@@ -308,25 +307,20 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
"unified-delay",
];
/// The fields the app owns, held across the stages that apply the user's manual overrides.
///
/// Capturing and restoring are one value rather than two calls because the order is a
/// correctness requirement, not a style: capture must happen after the app has finished
/// deriving these fields and before any override runs, and restore must happen after every
/// override. As four separate calls that contract lived only in comments.
/// App-owned fields: captured after the app derives them, enforced after every override.
struct AuthoritativeFields {
control_plane: Mapping,
tun: Mapping,
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
/// Only when the DNS page switched it on.
dns_ipv6: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys),
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
}
}
@@ -401,7 +395,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
config
}
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
/// 只在 DNS 页开启 IPv6 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned()
}
@@ -481,11 +475,7 @@ async fn process_profile_items(
(config, exists_keys, result_map)
}
/// Merge the Application Merge Config over the profile's own configuration.
///
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
/// out of the global config from inside its loop, which made a stage that looks pure depend
/// on process state and left it untestable.
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
@@ -683,9 +673,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
config
}
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value;
@@ -708,38 +696,77 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
}
}
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
fn is_set(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(on) => *on,
Value::String(text) => !text.trim().is_empty(),
Value::Sequence(items) => !items.is_empty(),
Value::Mapping(map) => !map.is_empty(),
Value::Number(_) | Value::Tagged(_) => true,
}
}
/// Overlay set fields onto base; maps merge per key, lists are leaves.
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
for (key, value) in overlay {
let value = match (base.get_mut(&key), value) {
(Some(Value::Mapping(existing)), Value::Mapping(patch)) => {
*existing = merge_set_fields(std::mem::take(existing), patch);
continue;
}
(_, Value::Mapping(patch)) => Value::Mapping(merge_set_fields(Mapping::new(), patch)),
(_, value) => value,
};
if is_set(&value) {
base.insert(key, value);
}
}
base
}
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
config
.get_mut(key)
.and_then(Value::as_mapping_mut)
.map(std::mem::take)
.unwrap_or_default()
}
/// Returns the merged config and whether the page set `dns.ipv6`.
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
let mut owns_ipv6 = false;
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if dns_path.exists()
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
&& let Ok(mut dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
{
if let Some(hosts_value) = dns_config.get("hosts")
&& hosts_value.is_mapping()
{
config.insert("hosts".into(), hosts_value.clone());
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts") {
let hosts = merge_set_fields(take_mapping(&mut config, "hosts"), hosts);
config.insert("hosts".into(), hosts.into());
logging!(debug, Type::Core, "apply hosts configuration");
}
if let Some(dns_value) = dns_config.get("dns") {
if let Some(dns_mapping) = dns_value.as_mapping() {
let mut dns_mapping = dns_mapping.clone();
ensure_fake_ip_range6(&mut dns_mapping);
config.insert("dns".into(), dns_mapping.into());
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)");
}
} else {
let mut dns_config = dns_config;
ensure_fake_ip_range6(&mut dns_config);
config.insert("dns".into(), dns_config.into());
// Legacy layout: no `dns` root.
let dns_override = match dns_config.remove("dns") {
Some(Value::Mapping(dns)) => Some(dns),
Some(_) => None,
None => Some(dns_config),
};
if let Some(dns_override) = dns_override {
owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
}
}
config
(config, owns_ipv6)
}
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
@@ -800,9 +827,9 @@ pub async fn enhance(
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let config = apply_dns_settings(config, enable_dns_settings).await;
let (config, owns_dns_ipv6) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, enable_dns_settings);
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, owns_dns_ipv6);
let (config, exists_keys, result_map) = process_global_items(
config,
@@ -864,8 +891,7 @@ mod fake_ip_tests {
#[test]
fn a_hand_edited_empty_range_counts_as_missing() {
// The reason this is not just a `contains_key` check: YAML edited by hand often
// leaves the key present with nothing after the colon.
// Hand-edited YAML may leave the key present but empty.
for blank in ["", " "] {
let mut config = dns(&[
("ipv6", Value::from(true)),
@@ -1004,8 +1030,7 @@ mod use_tun_tests {
#[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() {
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
// resolver is restored, which is not part of the configuration.
// TUN off must not rewrite the DNS the profile chose.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false);
@@ -1038,8 +1063,7 @@ mod use_sort_tests {
#[test]
fn the_bulky_list_fields_are_written_last() {
// These are the fields that make a config file unreadable from the top, so they are
// pushed below the settings a human actually scans for.
// Bulky list fields go last so the top of the file stays readable.
let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule"));
@@ -1129,8 +1153,7 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not just skipped: the profile may have asked for the port itself, and a
// listener the user switched off must not be opened by the profile's own value.
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
@@ -1152,8 +1175,7 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
// listen anyway, which is the opposite of what switching it off means.
// Blanked, not removed: an absent key makes mihomo listen on its default.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false);
+1 -2
View File
@@ -163,6 +163,7 @@ mod app_init {
cmd::apply_dns_config,
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
@@ -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") {
+4 -17
View File
@@ -228,27 +228,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
Ok(())
}
/// Raise existing sub-floor update intervals, once per install.
///
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
/// them, so the marker is written even when nothing needed raising.
/// Once per install; the marker is written even when no interval needed raising.
pub async fn migrate_short_update_intervals() -> Result<()> {
let marker = dirs::update_interval_migrated_path()?;
if fs::try_exists(&marker).await? {
return Ok(());
}
// Same order as every other writer holding it (lock, then draft permit): save_file runs
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
let min = constants::profile::MIN_UPDATE_INTERVAL;
let raised = Config::profiles()
.await
.with_data_modify(|mut profiles: IProfiles| async move {
// `IProfiles::new` normalises items to Some when it read the file, and returns a
// bare default() when it failed. A failed load also raises zero — without this the
// marker would burn the one shot on profiles nobody managed to load.
// A failed profiles load (bare default) must not burn the one-shot marker.
if profiles.items.is_none() {
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
}
@@ -370,14 +364,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
"nameserver-policy".into(),
Value::Mapping(serde_yaml_ng::Mapping::new()),
),
(
"proxy-server-nameserver".into(),
Value::Sequence(vec![
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("tls://223.5.5.5".into()),
]),
),
("proxy-server-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
(
-15
View File
@@ -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,
+2 -1
View File
@@ -15,11 +15,12 @@
},
"rpm": {
"depends": ["openssl", "libayatana-appindicator-gtk3"],
"recommends": ["policycoreutils", "policycoreutils-python-utils"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"postInstallScript": "./packages/linux/post-install-rpm.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
}
},
+3 -9
View File
@@ -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"
}
}
}
+16 -3
View File
@@ -13,12 +13,24 @@ interface SortableItemProps {
index: number
group?: string
disabled?: boolean
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
measureElement?: (element: HTMLDivElement | null) => void
dataIndex?: number
style?: CSSProperties
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
}
export const SortableItem = (props: SortableItemProps) => {
const { id, index, group, disabled, children, style } = props
const {
id,
index,
group,
disabled,
measureElement,
dataIndex,
children,
style,
} = props
const { ref, handleRef, sortable, isDragging } = useSortable({
id,
index,
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
(element: HTMLDivElement | null) => {
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
ref(element)
measureElement?.(element)
},
[handleRef, ref],
[handleRef, ref, measureElement],
)
const mergedStyle: CSSProperties = {
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
}
return (
<div ref={setRef} style={mergedStyle}>
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
{children}
</div>
)
@@ -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]}`
}
+320 -214
View File
@@ -1,6 +1,7 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import {
AccessTimeRounded,
ArrowDropDown,
ChevronRight,
NetworkCheckRounded,
WifiOff as SignalError,
@@ -15,25 +16,22 @@ import {
import {
Box,
Button,
ButtonBase,
Chip,
ClickAwayListener,
FormControl,
IconButton,
InputLabel,
type MenuProps,
MenuItem,
MenuList,
Paper,
Popper,
Select,
type SelectChangeEvent,
type PopperProps,
Tooltip,
Typography,
alpha,
useTheme,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
@@ -68,6 +66,40 @@ const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
const AUTO_CHECK_INITIAL_DELAY_MS = 100
const PROXY_MENU_MAX_HEIGHT = 500
const PROXY_MENU_VIEWPORT_MARGIN = 8
const proxyMenuModifiers: PopperProps['modifiers'] = [
{
name: 'menuSize',
enabled: true,
phase: 'beforeRead',
fn: ({ state }) => {
const popper = state.elements.popper
const anchor = state.elements.reference.getBoundingClientRect()
const viewportHeight = popper.ownerDocument.documentElement.clientHeight
// Use the larger side before flip runs so sizing cannot alternate with placement.
const availableHeight =
Math.max(anchor.top, viewportHeight - anchor.bottom) -
PROXY_MENU_VIEWPORT_MARGIN
popper.style.width = `${anchor.width}px`
popper.style.setProperty(
'--proxy-menu-max-height',
`${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`,
)
state.rects.popper.width = popper.offsetWidth
state.rects.popper.height = popper.offsetHeight
},
},
{
name: 'showMenu',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
fn: ({ state }) => {
state.styles.popper.visibility = 'visible'
},
},
]
interface ProxyOption {
memberIndex: number
@@ -163,34 +195,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
const hasPointerCoordinates = (
value: unknown,
): value is { clientX: number; clientY: number } =>
typeof value === 'object' &&
value !== null &&
'clientX' in value &&
typeof value.clientX === 'number' &&
'clientY' in value &&
typeof value.clientY === 'number'
const eventHitsElement = (event: unknown, element: HTMLElement | null) => {
if (!element) return false
const nativeEvent =
typeof event === 'object' && event !== null && 'nativeEvent' in event
? event.nativeEvent
: event
if (!hasPointerCoordinates(nativeEvent)) return false
const rect = element.getBoundingClientRect()
return (
nativeEvent.clientX >= rect.left &&
nativeEvent.clientX <= rect.right &&
nativeEvent.clientY >= rect.top &&
nativeEvent.clientY <= rect.bottom
)
}
const sortProxyOptions = (
options: ProxyOption[],
sortType: ProxySortType,
@@ -216,58 +220,71 @@ const sortProxyOptions = (
.map(({ option }) => option)
}
interface PersistentProxySelectProps {
interface PersistentSelectProps {
label: string
groupName: string
fixed?: string
value: string
selectedName: string
selectedDelay: number
options: ProxyOption[]
displayValue: React.ReactNode
open: boolean
disabled: boolean
keepOpenRef: React.RefObject<HTMLElement | null>
onOpen: () => void
onClose: () => void
onChange: (value: string) => void
onOpenChange: (open: boolean) => void
renderOptions: () => React.ReactNode
}
const PersistentProxySelect = ({
const PersistentSelect = ({
label,
groupName,
fixed,
value,
selectedName,
selectedDelay,
options,
displayValue,
open,
disabled,
keepOpenRef,
onOpen,
onClose,
onChange,
}: PersistentProxySelectProps) => {
const anchorRef = useRef<HTMLDivElement>(null)
const listboxId = 'current-proxy-node-listbox'
const labelId = 'proxy-select-label'
const fixedProxyInUsed = selectedName === fixed
onOpenChange,
renderOptions,
}: PersistentSelectProps) => {
const id = useId()
const labelId = `${id}-label`
const valueId = `${id}-value`
const listboxId = `${id}-listbox`
const anchorRef = useRef<HTMLButtonElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const popperOptions = useMemo<PopperProps['popperOptions']>(
() => ({
onFirstUpdate: () => {
const list = listRef.current
const paper = list?.parentElement
const item =
list?.querySelector<HTMLElement>(
'[aria-selected="true"]:not([aria-disabled="true"])',
) ??
list?.querySelector<HTMLElement>(
'[role="option"]:not([aria-disabled="true"])',
)
if (!item || !paper) return
// Focusing before positioning can scroll the page instead of just the menu.
item.focus({ preventScroll: true })
paper.scrollTop +=
item.getBoundingClientRect().top -
paper.getBoundingClientRect().top -
(paper.clientHeight - item.offsetHeight) / 2
},
}),
[],
)
useEffect(() => {
if (!open) return
const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
onClose()
onOpenChange(false)
} else if (event.key === 'Escape') {
onClose()
anchorRef.current
?.querySelector<HTMLElement>('[role="combobox"]')
?.focus()
onOpenChange(false)
anchorRef.current?.focus({ preventScroll: true })
}
}
document.addEventListener('keydown', closeFromKeyboard)
return () => document.removeEventListener('keydown', closeFromKeyboard)
}, [onClose, open])
}, [onOpenChange, open])
return (
<ClickAwayListener
@@ -277,134 +294,119 @@ const PersistentProxySelect = ({
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
return
}
onClose()
onOpenChange(false)
}}
>
<Box>
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
value={value}
open={false}
onOpen={onOpen}
label={label}
<Box
sx={{
position: 'relative',
borderRadius: 1,
'& fieldset': {
borderColor: (theme) =>
alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
},
...(!disabled && {
'&:hover fieldset': { borderColor: 'text.primary' },
'&:focus-within fieldset': {
borderColor: 'primary.main',
borderWidth: 2,
},
'&:focus-within legend': { color: 'primary.main' },
}),
}}
>
<ButtonBase
ref={anchorRef}
type="button"
role="combobox"
aria-labelledby={`${labelId} ${valueId}`}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-expanded={open}
disabled={disabled}
renderValue={() => (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography noWrap>{selectedName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(selectedDelay)}
color={convertDelayColor(selectedDelay)}
/>
{fixedProxyInUsed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
)}
SelectDisplayProps={{
'aria-controls': open ? listboxId : undefined,
'aria-expanded': open ? 'true' : 'false',
onClick={() => onOpenChange(!open)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
onOpenChange(true)
}
}}
sx={{
width: '100%',
height: 40,
px: 1.75,
borderRadius: 'inherit',
justifyContent: 'space-between',
textAlign: 'left',
color: disabled ? 'text.disabled' : 'text.primary',
}}
>
<MenuItem value={value}>{selectedName}</MenuItem>
</Select>
</FormControl>
<Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
{displayValue}
</Box>
<ArrowDropDown
sx={{
ml: 1,
color: disabled ? 'action.disabled' : 'action.active',
transform: open ? 'rotate(180deg)' : undefined,
}}
/>
</ButtonBase>
<Box
component="fieldset"
sx={{
position: 'absolute',
inset: '-5px 0 0',
m: 0,
px: 1,
minWidth: 0,
border: '1px solid',
borderRadius: 'inherit',
pointerEvents: 'none',
}}
>
<Box
component="legend"
id={labelId}
sx={{
px: 0.5,
typography: 'caption',
lineHeight: '11px',
color: disabled ? 'text.disabled' : 'text.secondary',
}}
>
{label}
</Box>
</Box>
</Box>
<Popper
open={open}
anchorEl={anchorRef.current}
placement="bottom-start"
modifiers={proxyMenuModifiers}
popperOptions={popperOptions}
sx={{
width: anchorRef.current?.clientWidth,
visibility: 'hidden',
zIndex: (theme) => theme.zIndex.modal,
}}
>
<Paper
elevation={8}
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
sx={{
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
overflow: 'auto',
}}
>
<MenuList
ref={listRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
autoFocusItem
variant="selectedMenu"
>
{options.map((option) => {
const selected = option.value === value
const isFixed = option.name === fixed
const delay = option.disabled
? -1
: delayManager.getDelayFix(option.member, groupName)
return (
<MenuItem
key={option.value}
role="option"
aria-selected={selected}
selected={selected}
disabled={option.disabled}
onClick={() => {
if (!option.disabled) onChange(option.value)
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
pr: 1,
}}
>
{isFixed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '5px',
...(!fixedProxyInUsed && {
filter: 'grayscale(1)',
}),
}}
>
📌
</span>
)}
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{option.name}
</Typography>
{!option.disabled && (
<Chip
size="small"
label={delayManager.formatDelay(delay)}
color={convertDelayColor(delay)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})}
{open && renderOptions()}
</MenuList>
</Paper>
</Popper>
@@ -413,6 +415,124 @@ const PersistentProxySelect = ({
)
}
interface PersistentProxySelectProps
extends Omit<PersistentSelectProps, 'displayValue' | 'renderOptions'> {
value: string
selectedName: string
groupName: string
fixed?: string
selectedDelay: number
options: ProxyOption[]
onChange: (value: string) => void
}
const PersistentProxySelect = ({
groupName,
fixed,
selectedName,
selectedDelay,
options,
value,
onChange,
...props
}: PersistentProxySelectProps) => {
const fixedProxyInUsed = selectedName === fixed
return (
<PersistentSelect
{...props}
displayValue={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography noWrap>{selectedName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(selectedDelay)}
color={convertDelayColor(selectedDelay)}
/>
{fixedProxyInUsed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
}
renderOptions={() =>
options.map((option) => {
const selected = option.value === value
const isFixed = option.name === fixed
const delay = option.disabled
? -1
: delayManager.getDelayFix(option.member, groupName)
return (
<MenuItem
key={option.value}
role="option"
aria-selected={selected}
selected={selected}
disabled={option.disabled}
onClick={() => {
if (!option.disabled) onChange(option.value)
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
pr: 1,
}}
>
{isFixed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '5px',
...(!fixedProxyInUsed && {
filter: 'grayscale(1)',
}),
}}
>
📌
</span>
)}
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{option.name}
</Typography>
{!option.disabled && (
<Chip
size="small"
label={delayManager.formatDelay(delay)}
color={convertDelayColor(delay)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})
}
/>
)
}
export const CurrentProxyCard = () => {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -497,6 +617,11 @@ export const CurrentProxyCard = () => {
const [selectedGroupName, setSelectedGroupName] = useState('')
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
useEffect(() => {
setOpenSelect(null)
}, [mode])
const delayButtonRef = useRef<HTMLButtonElement>(null)
const delays = useGroupDelays(selectedGroupName || null)
@@ -621,9 +746,8 @@ export const CurrentProxyCard = () => {
latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => {
(newGroupName: string) => {
if (isGlobalMode || isDirectMode) return
const newGroupName = event.target.value
setSelectedGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
},
@@ -785,24 +909,9 @@ export const CurrentProxyCard = () => {
refreshProxy()
})
const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
(event, reason) => {
if (
reason === 'backdropClick' &&
eventHitsElement(event, delayButtonRef.current)
) {
void handleCheckDelay()
return
}
setOpenSelect(null)
},
[handleCheckDelay],
)
const proxyOptions = useMemo(
() =>
isDirectMode
isDirectMode || openSelect !== 'proxy'
? []
: sortProxyOptions(
unsortedProxyOptions,
@@ -813,6 +922,7 @@ export const CurrentProxyCard = () => {
[
delays,
isDirectMode,
openSelect,
unsortedProxyOptions,
sortType,
defaultLatencyTimeout,
@@ -975,32 +1085,29 @@ export const CurrentProxyCard = () => {
/>
)}
</Box>
<FormControl
fullWidth
variant="outlined"
size="small"
sx={{ mb: 1.5 }}
>
<InputLabel id="proxy-group-select-label">
{t('home.components.currentProxy.labels.group')}
</InputLabel>
<Select
labelId="proxy-group-select-label"
value={selectedGroupName}
onChange={handleGroupChange}
open={openSelect === 'group'}
onOpen={() => setOpenSelect('group')}
<Box sx={{ mb: 1.5 }}>
<PersistentSelect
label={t('home.components.currentProxy.labels.group')}
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
open={openSelect === 'group'}
disabled={isGlobalMode || isDirectMode}
MenuProps={{ onClose: handleGroupMenuClose }}
>
{selectableGroups.map((group) => (
<MenuItem key={group.name} value={group.name}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
keepOpenRef={delayButtonRef}
onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
renderOptions={() =>
selectableGroups.map((group) => (
<MenuItem
key={group.name}
role="option"
aria-selected={group.name === selectedGroupName}
selected={group.name === selectedGroupName}
onClick={() => handleGroupChange(group.name)}
>
<Typography noWrap>{group.name}</Typography>
</MenuItem>
))
}
/>
</Box>
<PersistentProxySelect
label={t('home.components.currentProxy.labels.proxy')}
@@ -1013,12 +1120,11 @@ export const CurrentProxyCard = () => {
}
selectedName={selectedProxyName}
selectedDelay={currentDelay}
options={isDirectMode ? [] : proxyOptions}
options={proxyOptions}
open={openSelect === 'proxy'}
disabled={isDirectMode}
keepOpenRef={delayButtonRef}
onOpen={() => setOpenSelect('proxy')}
onClose={() => setOpenSelect(null)}
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
onChange={handleProxyChange}
/>
</Box>
+2 -1
View File
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context'
import { updateProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
@@ -41,7 +42,7 @@ const round = keyframes`
// 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => {
if (!url) return '-'
if (url.startsWith('http')) return new URL(url).host
if (isValidUrl(url)) return new URL(url).host
return 'local'
}
+2 -1
View File
@@ -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>
)
}
+71 -170
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -43,13 +35,7 @@ import {
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
proxiesUid: string
mergeUid: string
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredGroupList,
filteredAppendSeq,
(group) => group.name,
),
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
<GroupItem
type={isDeleted ? 'delete' : 'original'}
group={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
</SortableItem>
)
})}
</>
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
// prevent drag over if source and target are in different groups
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
+2 -3
View File
@@ -43,6 +43,7 @@ import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
@@ -965,9 +966,7 @@ export const ProfileItem = memo(ProfileItemBase)
function parseUrl(url?: string) {
if (!url) return ''
const regex = /https?:\/\/(.+?)\//
const result = url.match(regex)
return result ? result[1] : 'local file'
return isValidUrl(url) ? new URL(url).host : 'local file'
}
function parseExpire(expire?: number) {
+71 -166
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -35,12 +27,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -50,6 +37,12 @@ import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import parseUri from '@/utils/uri-parser'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
profileUid: string
property: string
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredProxyList,
filteredAppendSeq,
(proxy) => proxy.name,
),
[filteredPrependSeq, filteredProxyList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
<ProxyItem
type={isDeleted ? 'delete' : 'original'}
proxy={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
</SortableItem>
)
})}
</>
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = []
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
+83 -177
View File
@@ -1,11 +1,4 @@
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import { arrayMove } from '@dnd-kit/helpers'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -36,13 +29,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
import { isValidIpCidr } from '@/utils/network'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
groupsUid: string
mergeUid: string
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredRuleList,
filteredAppendSeq,
(rule) => rule,
),
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<string>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`prepend:${item}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
<RuleItem
type={isDeleted ? 'delete' : 'original'}
ruleRaw={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item))
} else {
setDeleteSeq((prev) => [...prev, item])
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`append:${item}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<RuleItem
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
onPrepend={() => {
setPrependSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
</SortableItem>
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
})}
</>
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
}
return (
<RuleItem
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
onPrepend={() => {
setPrependSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
}
const reorder = (list: string[], startIndex: number, endIndex: number) => {
const result = Array.from(list)
const [removed] = result.splice(startIndex, 1)
result.splice(endIndex, 0, removed)
return result
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
return
}
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
@@ -854,22 +770,12 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
@@ -17,6 +17,8 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
@@ -174,6 +176,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
+6
View File
@@ -18,7 +18,9 @@ import { useTranslation } from 'react-i18next'
import { BaseSearchBox } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -95,6 +97,10 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
+2 -7
View File
@@ -171,11 +171,7 @@ const DEFAULT_DNS_CONFIG = {
],
fallback: [],
'nameserver-policy': {},
'proxy-server-nameserver': [
'https://doh.pub/dns-query',
'https://dns.alidns.com/dns-query',
'tls://223.5.5.5',
],
'proxy-server-nameserver': [],
'direct-nameserver': [],
'direct-nameserver-follow-policy': false,
'fallback-filter': {
@@ -340,8 +336,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
listen: values.listen,
'enhanced-mode': values.enhancedMode,
'fake-ip-range': values.fakeIpRange,
'fake-ip-range6':
values.fakeIpRange6 || DEFAULT_DNS_CONFIG['fake-ip-range6'],
'fake-ip-range6': values.fakeIpRange6,
'fake-ip-filter-mode': values.fakeIpFilterMode,
'prefer-h3': values.preferH3,
'respect-rules': values.respectRules,
@@ -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)
}
}
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "تم تثبيت الخدمة بنجاح",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Service erfolgreich installiert",
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
"uninstallSuccess": "Service erfolgreich deinstalliert"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Service Installed Successfully",
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
"uninstallSuccess": "Service Uninstalled Successfully"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Servicio instalado con éxito",
"sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.",
"uninstallSuccess": "Servicio desinstalado con éxito"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "سرویس با موفقیت نصب شد",
"sidecarFallback": "سرویس به‌دلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Layanan Berhasil Diinstal",
"sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.",
"uninstallSuccess": "Layanan Berhasil Dicopot"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "サービスのインストールに成功しました。",
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
"uninstallSuccess": "サービスのアンインストールに成功しました。"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} 업데이트 성공",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "서비스가 설치되었습니다",
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
"uninstallSuccess": "서비스가 제거되었습니다"
},
"updater": {
+3
View File
@@ -89,6 +89,9 @@
}
},
"feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": {
"provider": {
"updateSuccess": "{{name}} успешно обновлён",
+1
View File
@@ -683,6 +683,7 @@
},
"clashService": {
"installSuccess": "Служба успешно установлена",
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
"uninstallSuccess": "Служба успешно удалена"
},
"updater": {

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