mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-22 19:52:14 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4337bd8575 | ||
|
|
cb751052a4 | ||
|
|
d9dc18a57b | ||
|
|
6b4d4e1551 | ||
|
|
4a125681bd | ||
|
|
1c2817023e | ||
|
|
89076f01c3 | ||
|
|
a4531cf926 | ||
|
|
07b0e71cc6 | ||
|
|
ad2baf254a | ||
|
|
d01f43cc78 | ||
|
|
720dc13e96 | ||
|
|
69b76cc6a9 | ||
|
|
8328e34208 | ||
|
|
78691ea5c8 | ||
|
|
af808a677c | ||
|
|
a1bebf6de1 | ||
|
|
2caff762b5 | ||
|
|
dba0f471bf | ||
|
|
c341b9b238 | ||
|
|
6ec35c8195 | ||
|
|
30f05a7a39 | ||
|
|
7cc6c1ed95 | ||
|
|
c7cee50fc1 | ||
|
|
4cae1ff132 | ||
|
|
1a01844cb8 | ||
|
|
1575f134e0 | ||
|
|
a9a3cfa3a7 | ||
|
|
14770df157 | ||
|
|
57c5484080 | ||
|
|
4a0882fc18 | ||
|
|
d87bb7b6a3 | ||
|
|
a58cefdc31 | ||
|
|
f958dccb25 | ||
|
|
8c1fb3ac56 | ||
|
|
3607e66bb3 |
@@ -5,10 +5,10 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:2": {
|
"ghcr.io/devcontainers/features/node:2": {
|
||||||
"version": "24.21.0",
|
"version": "24.21.0",
|
||||||
"pnpmVersion": "12.8.1"
|
"pnpmVersion": "12.10.0"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/rust:1": {
|
"ghcr.io/devcontainers/features/rust:1": {
|
||||||
"version": "1.98.1",
|
"version": "1.99.0",
|
||||||
"profile": "default"
|
"profile": "default"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/git:1": {},
|
"ghcr.io/devcontainers/features/git:1": {},
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.98.1'
|
toolchain: '1.99.0'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -206,7 +206,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.98.1'
|
toolchain: '1.99.0'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -347,7 +347,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Backend Guardrails
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "src-tauri/**"
|
||||||
|
- ".github/workflows/backend-guardrails.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
banned-patterns:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: No Handle::refresh calls in feat
|
||||||
|
run: '! grep -rn "Handle::refresh" src-tauri/src/feat'
|
||||||
|
|
||||||
|
- name: No notice_message outside core/notify
|
||||||
|
run: '! grep -rn "notice_message(" src-tauri/src --include="*.rs" | grep -v "core/notify"'
|
||||||
|
|
||||||
|
- name: No manual update-flag chains
|
||||||
|
run: '! grep -rnE "determine_update_flags|UpdateFlags" src-tauri/src'
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
uses: dtolnay/rust-toolchain@1.98.1
|
uses: dtolnay/rust-toolchain@1.99.0
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
|
|||||||
@@ -85,3 +85,82 @@ jobs:
|
|||||||
- name: Run Knip
|
- name: Run Knip
|
||||||
if: steps.check_frontend.outputs.frontend == 'true'
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm knip:check
|
run: pnpm knip:check
|
||||||
|
|
||||||
|
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
|
run: node scripts/check-architecture.mjs listen emit invoke
|
||||||
|
|
||||||
|
contract-parity:
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Check contract inputs
|
||||||
|
id: check_inputs
|
||||||
|
uses: dorny/paths-filter@v4
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
contract:
|
||||||
|
- 'src/services/contract.ts'
|
||||||
|
- 'scripts/gen-contract.mjs'
|
||||||
|
- 'src-tauri/src/core/notify/**'
|
||||||
|
- 'src-tauri/src/bin/**'
|
||||||
|
- 'src-tauri/src/lib.rs'
|
||||||
|
- '.github/workflows/frontend-check.yml'
|
||||||
|
|
||||||
|
- name: Skip if contract inputs unchanged
|
||||||
|
if: steps.check_inputs.outputs.contract != 'true'
|
||||||
|
run: echo "No contract inputs changed, skipping parity check."
|
||||||
|
|
||||||
|
- name: Install Rust Stable
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
prefix-key: 'v1-rust'
|
||||||
|
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
|
||||||
|
workspaces: |
|
||||||
|
. -> target
|
||||||
|
cache-all-crates: true
|
||||||
|
cache-workspace-crates: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
with:
|
||||||
|
node-version: '24.21.0'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
|
||||||
|
- name: Provision sidecar placeholders for tauri-build
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/sidecar src-tauri/resources dist
|
||||||
|
touch dist/index.html
|
||||||
|
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
|
||||||
|
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Regenerate contract and require empty diff
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
run: |
|
||||||
|
pnpm gen:contract
|
||||||
|
git diff --exit-code -- src/services/contract.ts
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.98.1'
|
toolchain: '1.99.0'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -244,7 +244,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.98.1'
|
toolchain: '1.99.0'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -389,7 +389,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -446,7 +446,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.98.1'
|
toolchain: '1.99.0'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -487,7 +487,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
nodejs 24.21.0
|
nodejs 24.21.0
|
||||||
pnpm 12.8.1
|
pnpm 12.10.0
|
||||||
rust 1.98.1
|
rust 1.99.0
|
||||||
|
|||||||
Generated
+273
-211
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -47,6 +47,9 @@ codegen-units = 1
|
|||||||
split-debuginfo = "unpacked"
|
split-debuginfo = "unpacked"
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
|
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
|
||||||
|
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
|
||||||
|
|
||||||
# macOS Golden Gate fix for tray-icon click reentrancy issue
|
# macOS Golden Gate fix for tray-icon click reentrancy issue
|
||||||
# Remove the following patch once the fix is merged upstream(tauri)
|
# Remove the following patch once the fix is merged upstream(tauri)
|
||||||
# https://github.com/tauri-apps/tray-icon/pull/365
|
# https://github.com/tauri-apps/tray-icon/pull/365
|
||||||
@@ -63,13 +66,13 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
|
|||||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
tauri = { version = "2.11.6" }
|
tauri = { version = "2.12.1" }
|
||||||
tauri-plugin-clipboard-manager = "2.3.3"
|
tauri-plugin-clipboard-manager = "2.4.1"
|
||||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||||
tokio = { version = "1.53.1", features = [
|
tokio = { version = "1.53.2", features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"macros",
|
"macros",
|
||||||
"time",
|
"time",
|
||||||
|
|||||||
+28
-12
@@ -1,35 +1,51 @@
|
|||||||
## v2.5.7
|
## v2.5.8
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
|
||||||
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
|
||||||
|
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
|
||||||
|
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
|
||||||
|
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
||||||
|
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
|
||||||
|
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
|
||||||
|
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
|
||||||
|
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
|
||||||
|
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
|
||||||
|
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
|
||||||
|
|
||||||
**🖥️ Windows**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
|
||||||
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
|
||||||
|
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
|
||||||
|
|
||||||
**🍎 macOS**
|
**🍎 macOS**
|
||||||
|
|
||||||
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
|
||||||
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
|
||||||
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
|
||||||
|
|
||||||
**🍎/🐧 macOS/Linux**
|
**🖥️/🍎 Windows/macOS**
|
||||||
|
|
||||||
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||||
|
|
||||||
|
- 新增系统托盘代理组中的「测试延迟」菜单项
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
- 优化端口设置窗口的实现,精简重复代码
|
||||||
|
- 优化侧边栏流量图表的 CPU 占用
|
||||||
|
|
||||||
**🖥️ Windows**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Languages:
|
Languages:
|
||||||
<a href="./README.md">简体中文</a> ·
|
|
||||||
<a href="./docs/README_en.md">English</a> ·
|
<a href="./docs/README_en.md">English</a> ·
|
||||||
<a href="./docs/README_es.md">Español</a> ·
|
<a href="./docs/README_es.md">Español</a> ·
|
||||||
<a href="./docs/README_ru.md">Русский</a> ·
|
<a href="./docs/README_fa.md">فارسی</a> ·
|
||||||
<a href="./docs/README_ja.md">日本語</a> ·
|
<a href="./docs/README_ja.md">日本語</a> ·
|
||||||
<a href="./docs/README_ko.md">한국어</a> ·
|
<a href="./docs/README_ko.md">한국어</a> ·
|
||||||
<a href="./docs/README_fa.md">فارسی</a>
|
<a href="./docs/README_pt.md">Português</a> ·
|
||||||
|
<a href="./docs/README_ru.md">Русский</a> ·
|
||||||
|
<a href="./README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
@@ -31,6 +32,7 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
|||||||
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
|
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
|
||||||
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
|
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
|
||||||
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
||||||
|
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
|
||||||
|
|
||||||
#### 我应当怎样选择发行版
|
#### 我应当怎样选择发行版
|
||||||
|
|
||||||
@@ -50,14 +52,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
|||||||
|
|
||||||
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
||||||
|
|
||||||
🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
||||||
|
|
||||||
#### AI云边 -- 全新架构机场。
|
#### AI云边 -- 全新架构机场。
|
||||||
|
|
||||||
- 💻 多次**技术迭代后**全新亮相。
|
- 💻 多次**技术迭代后**全新亮相。
|
||||||
- 🗺 全**高速稳定**正价节点。
|
- 🗺 全**高速稳定**正价节点。
|
||||||
- 🌏 **海外团队**,不跑路
|
- 🌏 **海外团队**,不跑路
|
||||||
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
|
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
|
||||||
- 👨🦲 团队架构师为**大厂**网络架构师
|
- 👨🦲 团队架构师为**大厂**网络架构师
|
||||||
- 💰 极致**稳定**,亲民价**价格**
|
- 💰 极致**稳定**,亲民价**价格**
|
||||||
- 🌐 全面支持**流媒体及各AI访问**
|
- 🌐 全面支持**流媒体及各AI访问**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-i18n = "4.2.2"
|
rust-i18n = "4.2.4"
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
||||||
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: عام
|
global: عام
|
||||||
profiles: الملفات التعريفية
|
profiles: الملفات التعريفية
|
||||||
proxies: وكلاء
|
proxies: وكلاء
|
||||||
|
testLatency: اختبار التأخير
|
||||||
|
testingLatency: جارٍ اختبار التأخير…
|
||||||
systemProxy: وكيل النظام
|
systemProxy: وكيل النظام
|
||||||
tunMode: وضع TUN
|
tunMode: وضع TUN
|
||||||
closeAllConnections: إغلاق كل الاتصالات
|
closeAllConnections: إغلاق كل الاتصالات
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ notifications:
|
|||||||
body: Auf {mode} umgeschaltet.
|
body: Auf {mode} umgeschaltet.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Systemproxy
|
title: Systemproxy
|
||||||
'on': System proxy has been enabled.
|
'on': Der Systemproxy wurde aktiviert.
|
||||||
'off': System proxy has been disabled.
|
'off': Der Systemproxy wurde deaktiviert.
|
||||||
systemProxyFailed:
|
systemProxyFailed:
|
||||||
title: Systemproxy
|
title: Systemproxy
|
||||||
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
|
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
|
||||||
@@ -18,8 +18,8 @@ notifications:
|
|||||||
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
|
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN-Modus
|
title: TUN-Modus
|
||||||
'on': TUN mode has been enabled.
|
'on': Der TUN-Modus wurde aktiviert.
|
||||||
'off': TUN mode has been disabled.
|
'off': Der TUN-Modus wurde deaktiviert.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Leichtmodus
|
title: Leichtmodus
|
||||||
body: Leichtmodus aktiviert.
|
body: Leichtmodus aktiviert.
|
||||||
@@ -30,13 +30,15 @@ notifications:
|
|||||||
title: Beenden steht bevor
|
title: Beenden steht bevor
|
||||||
body: Clash Verge wird gleich beendet.
|
body: Clash Verge wird gleich beendet.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash-Verge-Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
|
||||||
installNow: Install Now
|
installNow: Jetzt installieren
|
||||||
later: Later
|
later: Später
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||||
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
|
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
|
||||||
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
|
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Profile
|
profiles: Profile
|
||||||
proxies: Proxy
|
proxies: Proxy
|
||||||
|
testLatency: Latenz testen
|
||||||
|
testingLatency: Latenz wird getestet…
|
||||||
systemProxy: Systemproxy
|
systemProxy: Systemproxy
|
||||||
tunMode: TUN-Modus
|
tunMode: TUN-Modus
|
||||||
closeAllConnections: Alle Verbindungen schließen
|
closeAllConnections: Alle Verbindungen schließen
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Unable to determine the application location: {error}'
|
currentExeError: 'Unable to determine the application location: {error}'
|
||||||
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
|
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Profiles
|
profiles: Profiles
|
||||||
proxies: Proxies
|
proxies: Proxies
|
||||||
|
testLatency: Test Latency
|
||||||
|
testingLatency: Testing latency…
|
||||||
systemProxy: System Proxy
|
systemProxy: System Proxy
|
||||||
tunMode: TUN Mode
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Close All Connections
|
closeAllConnections: Close All Connections
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||||
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
|
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
|
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
|
||||||
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
|
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Perfiles
|
profiles: Perfiles
|
||||||
proxies: Proxies
|
proxies: Proxies
|
||||||
|
testLatency: Probar latencia
|
||||||
|
testingLatency: Probando latencia…
|
||||||
systemProxy: Proxy del sistema
|
systemProxy: Proxy del sistema
|
||||||
tunMode: Modo TUN
|
tunMode: Modo TUN
|
||||||
closeAllConnections: Cerrar todas las conexiones
|
closeAllConnections: Cerrar todas las conexiones
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'هسته شروع نمیشود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقیمانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راهاندازی مجدد کنید.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
||||||
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: سراسری
|
global: سراسری
|
||||||
profiles: پروفایلها
|
profiles: پروفایلها
|
||||||
proxies: پروکسیها
|
proxies: پروکسیها
|
||||||
|
testLatency: آزمایش تأخیر
|
||||||
|
testingLatency: در حال آزمایش تأخیر…
|
||||||
systemProxy: پروکسی سیستم
|
systemProxy: پروکسی سیستم
|
||||||
tunMode: حالت TUN
|
tunMode: حالت TUN
|
||||||
closeAllConnections: بستن همه اتصالها
|
closeAllConnections: بستن همه اتصالها
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
||||||
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
|
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
|
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
|
||||||
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
|
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Profil
|
profiles: Profil
|
||||||
proxies: Proksi
|
proxies: Proksi
|
||||||
|
testLatency: Uji Latensi
|
||||||
|
testingLatency: Menguji latensi…
|
||||||
systemProxy: Proksi Sistem
|
systemProxy: Proksi Sistem
|
||||||
tunMode: Mode TUN
|
tunMode: Mode TUN
|
||||||
closeAllConnections: Tutup Semua Koneksi
|
closeAllConnections: Tutup Semua Koneksi
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
||||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
||||||
|
core:
|
||||||
|
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
||||||
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: グローバル
|
global: グローバル
|
||||||
profiles: プロファイル
|
profiles: プロファイル
|
||||||
proxies: プロキシ
|
proxies: プロキシ
|
||||||
|
testLatency: 遅延テスト
|
||||||
|
testingLatency: 遅延をテスト中…
|
||||||
systemProxy: システムプロキシ
|
systemProxy: システムプロキシ
|
||||||
tunMode: TUN モード
|
tunMode: TUN モード
|
||||||
closeAllConnections: すべての接続を閉じる
|
closeAllConnections: すべての接続を閉じる
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
||||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
||||||
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: 글로벌
|
global: 글로벌
|
||||||
profiles: 프로필
|
profiles: 프로필
|
||||||
proxies: 프록시
|
proxies: 프록시
|
||||||
|
testLatency: 지연 테스트
|
||||||
|
testingLatency: 지연 테스트 중…
|
||||||
systemProxy: 시스템 프록시
|
systemProxy: 시스템 프록시
|
||||||
tunMode: TUN 모드
|
tunMode: TUN 모드
|
||||||
closeAllConnections: 모든 연결 닫기
|
closeAllConnections: 모든 연결 닫기
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
_version: 1
|
||||||
|
notifications:
|
||||||
|
dashboardToggled:
|
||||||
|
title: Painel
|
||||||
|
body: A visibilidade do painel foi atualizada.
|
||||||
|
clashModeChanged:
|
||||||
|
title: Troca de modo
|
||||||
|
body: Alterado para {mode}.
|
||||||
|
systemProxyToggled:
|
||||||
|
title: Proxy do sistema
|
||||||
|
'on': O proxy do sistema foi ativado.
|
||||||
|
'off': O proxy do sistema foi desativado.
|
||||||
|
systemProxyFailed:
|
||||||
|
title: Proxy do sistema
|
||||||
|
body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo.
|
||||||
|
systemProxyGuardStopped:
|
||||||
|
title: Proteção do proxy do sistema
|
||||||
|
body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo.
|
||||||
|
tunModeToggled:
|
||||||
|
title: Modo TUN
|
||||||
|
'on': O modo TUN foi ativado.
|
||||||
|
'off': O modo TUN foi desativado.
|
||||||
|
lightweightModeEntered:
|
||||||
|
title: Modo leve
|
||||||
|
body: Modo leve ativado.
|
||||||
|
profilesReactivated:
|
||||||
|
title: Perfis
|
||||||
|
body: Perfil reativado.
|
||||||
|
appQuit:
|
||||||
|
title: Prestes a sair
|
||||||
|
body: O Clash Verge está prestes a sair.
|
||||||
|
updateReady:
|
||||||
|
title: Atualização do Clash Verge
|
||||||
|
body: Uma nova versão (v{version}) foi baixada e está pronta para instalar.
|
||||||
|
installNow: Instalar agora
|
||||||
|
later: Depois
|
||||||
|
service:
|
||||||
|
adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador.
|
||||||
|
adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.'
|
||||||
|
launchGuard:
|
||||||
|
currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}'
|
||||||
|
homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}'
|
||||||
|
translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente.
|
||||||
|
rejected: |-
|
||||||
|
Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos.
|
||||||
|
|
||||||
|
{reason}
|
||||||
|
notInBundle: O executável não está dentro de um pacote de aplicativo.
|
||||||
|
canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}'
|
||||||
|
bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado.
|
||||||
|
replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos?
|
||||||
|
movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora?
|
||||||
|
createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}'
|
||||||
|
moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente.
|
||||||
|
replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}'
|
||||||
|
relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos.
|
||||||
|
cancel: Cancelar
|
||||||
|
move: Mover
|
||||||
|
ok: OK
|
||||||
|
tray:
|
||||||
|
dashboard: Painel
|
||||||
|
ruleMode: Modo regra
|
||||||
|
globalMode: Modo global
|
||||||
|
directMode: Modo direto
|
||||||
|
outboundModes: Modos de saída
|
||||||
|
rule: Regra
|
||||||
|
direct: Direto
|
||||||
|
global: Global
|
||||||
|
profiles: Perfis
|
||||||
|
proxies: Proxies
|
||||||
|
systemProxy: Proxy do sistema
|
||||||
|
tunMode: Modo TUN
|
||||||
|
closeAllConnections: Fechar todas as conexões
|
||||||
|
lightweightMode: Modo leve
|
||||||
|
copyEnv: Copiar variáveis de ambiente
|
||||||
|
confDir: Diretório de configuração
|
||||||
|
coreDir: Diretório do núcleo
|
||||||
|
logsDir: Diretório de logs
|
||||||
|
openDir: Abrir diretório
|
||||||
|
appLog: Log do aplicativo
|
||||||
|
coreLog: Log do núcleo
|
||||||
|
restartClash: Reiniciar o núcleo do Clash
|
||||||
|
restartApp: Reiniciar o aplicativo
|
||||||
|
vergeVersion: Versão do Verge
|
||||||
|
more: Mais
|
||||||
|
exit: Sair
|
||||||
|
tooltip:
|
||||||
|
systemProxy: Proxy do sistema
|
||||||
|
tun: TUN
|
||||||
|
profile: Perfil
|
||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
||||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
||||||
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Глобальный
|
global: Глобальный
|
||||||
profiles: Профили
|
profiles: Профили
|
||||||
proxies: Прокси
|
proxies: Прокси
|
||||||
|
testLatency: Проверить задержку
|
||||||
|
testingLatency: Проверка задержки…
|
||||||
systemProxy: Системный прокси
|
systemProxy: Системный прокси
|
||||||
tunMode: Режим TUN
|
tunMode: Режим TUN
|
||||||
closeAllConnections: Закрыть все соединения
|
closeAllConnections: Закрыть все соединения
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
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.
|
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
|
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
|
||||||
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
|
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Küresel
|
global: Küresel
|
||||||
profiles: Profiller
|
profiles: Profiller
|
||||||
proxies: Vekil'ler
|
proxies: Vekil'ler
|
||||||
|
testLatency: Gecikmeyi Test Et
|
||||||
|
testingLatency: Gecikme test ediliyor…
|
||||||
systemProxy: Sistem Vekil'i
|
systemProxy: Sistem Vekil'i
|
||||||
tunMode: TUN Modu
|
tunMode: TUN Modu
|
||||||
closeAllConnections: Tüm Bağlantıları Kapat
|
closeAllConnections: Tüm Bağlantıları Kapat
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
||||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
||||||
|
core:
|
||||||
|
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
||||||
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: Глобаль
|
global: Глобаль
|
||||||
profiles: Профильләр
|
profiles: Профильләр
|
||||||
proxies: Проксилар
|
proxies: Проксилар
|
||||||
|
testLatency: Кичектерүне тикшерү
|
||||||
|
testingLatency: Кичектерү тикшерелә…
|
||||||
systemProxy: Системалы прокси
|
systemProxy: Системалы прокси
|
||||||
tunMode: TUN режимы
|
tunMode: TUN режимы
|
||||||
closeAllConnections: Барлык тоташуларны ябу
|
closeAllConnections: Барлык тоташуларны ябу
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
||||||
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
||||||
|
core:
|
||||||
|
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 无法确定应用位置:{error}
|
currentExeError: 无法确定应用位置:{error}
|
||||||
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: 全局
|
global: 全局
|
||||||
profiles: 订阅
|
profiles: 订阅
|
||||||
proxies: 代理
|
proxies: 代理
|
||||||
|
testLatency: 测试延迟
|
||||||
|
testingLatency: 正在测试延迟…
|
||||||
systemProxy: 系统代理
|
systemProxy: 系统代理
|
||||||
tunMode: TUN 模式
|
tunMode: TUN 模式
|
||||||
closeAllConnections: 关闭所有连接
|
closeAllConnections: 关闭所有连接
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||||
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
|
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
|
||||||
|
core:
|
||||||
|
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 無法判斷應用程式位置:{error}
|
currentExeError: 無法判斷應用程式位置:{error}
|
||||||
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
||||||
@@ -68,6 +70,8 @@ tray:
|
|||||||
global: 全域
|
global: 全域
|
||||||
profiles: 訂閱
|
profiles: 訂閱
|
||||||
proxies: 代理
|
proxies: 代理
|
||||||
|
testLatency: 測試延遲
|
||||||
|
testingLatency: 正在測試延遲…
|
||||||
systemProxy: 系統代理
|
systemProxy: 系統代理
|
||||||
tunMode: 虛擬網路介面卡模式
|
tunMode: 虛擬網路介面卡模式
|
||||||
closeAllConnections: 關閉所有連線
|
closeAllConnections: 關閉所有連線
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
||||||
|
|
||||||
[target.'cfg(not(windows))'.dependencies]
|
[target.'cfg(not(windows))'.dependencies]
|
||||||
libc = "0.2.189"
|
libc = "0.2.190"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
deelevate = { workspace = true }
|
deelevate = { workspace = true }
|
||||||
|
|||||||
@@ -1,3 +1,40 @@
|
|||||||
|
## v2.5.7
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
|
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
||||||
|
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||||
|
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
||||||
|
|
||||||
|
**🍎 macOS**
|
||||||
|
|
||||||
|
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
||||||
|
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
||||||
|
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
||||||
|
|
||||||
|
**🍎/🐧 macOS/Linux**
|
||||||
|
|
||||||
|
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
|
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
## v2.5.6
|
## v2.5.6
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
+4
-3
@@ -11,13 +11,14 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Languages:
|
Languages:
|
||||||
<a href="../README.md">简体中文</a> ·
|
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a>
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
|
|||||||
+4
-3
@@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Idiomas:
|
Idiomas:
|
||||||
<a href="../README.md">简体中文</a> ·
|
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a>
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Vista previa
|
## Vista previa
|
||||||
|
|||||||
+4
-3
@@ -11,13 +11,14 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
زبانها:
|
زبانها:
|
||||||
<a href="../README.md">简体中文</a> ·
|
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a>
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## پیشنمایش
|
## پیشنمایش
|
||||||
|
|||||||
+4
-3
@@ -11,13 +11,14 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
言語:
|
言語:
|
||||||
<a href="../README.md">简体中文</a> ·
|
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a>
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## プレビュー
|
## プレビュー
|
||||||
|
|||||||
+4
-3
@@ -11,13 +11,14 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
언어:
|
언어:
|
||||||
<a href="../README.md">简体中文</a> ·
|
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a>
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 미리보기
|
## 미리보기
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<h1 align="center">
|
||||||
|
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
|
||||||
|
<br>
|
||||||
|
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
|
||||||
|
<br>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h3 align="center">
|
||||||
|
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Idiomas:
|
||||||
|
<a href="./README_en.md">English</a> ·
|
||||||
|
<a href="./README_es.md">Español</a> ·
|
||||||
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Pré-visualização
|
||||||
|
|
||||||
|
| Escuro | Claro |
|
||||||
|
| -------------------------------------- | -------------------------------------- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## Instalação
|
||||||
|
|
||||||
|
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
|
||||||
|
Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple).
|
||||||
|
|
||||||
|
#### Como escolher o canal de lançamento
|
||||||
|
|
||||||
|
| Canal | Descrição | Link |
|
||||||
|
| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- |
|
||||||
|
| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
|
||||||
|
| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
|
||||||
|
| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
|
||||||
|
|
||||||
|
#### Guias de instalação e perguntas frequentes
|
||||||
|
|
||||||
|
Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes.
|
||||||
|
|
||||||
|
### Canal do Telegram
|
||||||
|
|
||||||
|
Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promoções
|
||||||
|
|
||||||
|
#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
|
- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria.
|
||||||
|
- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos)
|
||||||
|
- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual
|
||||||
|
- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada
|
||||||
|
- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos
|
||||||
|
- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge)
|
||||||
|
- Desbloqueia serviços de streaming e acesso ao ChatGPT
|
||||||
|
- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
|
### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas.
|
||||||
|
- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem.
|
||||||
|
- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados.
|
||||||
|
- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente.
|
||||||
|
- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com)
|
||||||
|
- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funcionalidades
|
||||||
|
|
||||||
|
- Baseado em Rust de alto desempenho e no framework Tauri 2
|
||||||
|
- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha`
|
||||||
|
- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection`
|
||||||
|
- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações
|
||||||
|
- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual)
|
||||||
|
- Editores visuais para nós e regras
|
||||||
|
- Backup e sincronização via WebDAV
|
||||||
|
|
||||||
|
### Perguntas frequentes
|
||||||
|
|
||||||
|
Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma.
|
||||||
|
|
||||||
|
### Doações
|
||||||
|
|
||||||
|
[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev)
|
||||||
|
|
||||||
|
## Desenvolvimento
|
||||||
|
|
||||||
|
Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição.
|
||||||
|
|
||||||
|
Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pnpm i
|
||||||
|
pnpm run prebuild
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contribuições
|
||||||
|
|
||||||
|
Issues e pull requests são bem-vindos.
|
||||||
|
|
||||||
|
## Agradecimentos
|
||||||
|
|
||||||
|
O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles:
|
||||||
|
|
||||||
|
- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux.
|
||||||
|
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web.
|
||||||
|
- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go.
|
||||||
|
- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go.
|
||||||
|
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS.
|
||||||
|
- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida.
|
||||||
|
|
||||||
|
## Licença
|
||||||
|
|
||||||
|
Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes.
|
||||||
+4
-3
@@ -11,13 +11,14 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Языки:
|
Языки:
|
||||||
<a href="../README.md">简体中文</a> ·
|
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
<a href="./README_fa.md">فارسی</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a>
|
<a href="./README_pt.md">Português</a> ·
|
||||||
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
|
<a href="../README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
## Предпросмотр
|
## Предпросмотр
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"ignoreBinaries": ["rustc", "cc"],
|
"ignoreBinaries": ["rustc", "cc"],
|
||||||
"ignoreDependencies": ["lint-staged"],
|
"ignoreDependencies": ["lint-staged"],
|
||||||
"ignoreIssues": {
|
"ignoreIssues": {
|
||||||
"src/types/generated/**": ["exports", "types"]
|
"src/types/generated/**": ["exports", "types"],
|
||||||
|
"src/services/contract.ts": ["exports", "types"]
|
||||||
},
|
},
|
||||||
"cycles": {
|
"cycles": {
|
||||||
"dynamicImports": true
|
"dynamicImports": true
|
||||||
|
|||||||
+18
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.5.7",
|
"version": "2.5.8",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
||||||
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
||||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||||
|
"gen:contract": "node scripts/gen-contract.mjs",
|
||||||
|
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -52,13 +54,13 @@
|
|||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.4.0",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.4.0",
|
||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@tauri-apps/api": "2.11.1",
|
"@tauri-apps/api": "2.12.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
"@tauri-apps/plugin-dialog": "2.8.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.2",
|
"@tauri-apps/plugin-fs": "2.6.0",
|
||||||
"@tauri-apps/plugin-http": "~2.6.1",
|
"@tauri-apps/plugin-http": "2.8.0",
|
||||||
"@tauri-apps/plugin-opener": "2.5.5",
|
"@tauri-apps/plugin-opener": "2.7.0",
|
||||||
"@tauri-apps/plugin-updater": "2.12.0",
|
"@tauri-apps/plugin-updater": "2.13.1",
|
||||||
"ahooks": "^3.10.0",
|
"ahooks": "^3.10.0",
|
||||||
"cidr-block": "^2.3.0",
|
"cidr-block": "^2.3.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
@@ -69,15 +71,15 @@
|
|||||||
"js-yaml": "^5.4.2",
|
"js-yaml": "^5.4.2",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"meta-json-schema": "^1.19.31",
|
"meta-json-schema": "^1.19.31",
|
||||||
"monaco-editor": "^0.56.0",
|
"monaco-editor": "^0.57.0",
|
||||||
"monaco-yaml": "^5.5.1",
|
"monaco-yaml": "^5.5.1",
|
||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.1",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"react-error-boundary": "6.1.5",
|
"react-error-boundary": "6.1.6",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.88.0",
|
||||||
"react-i18next": "17.0.14",
|
"react-i18next": "17.0.15",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^8.4.0",
|
"react-router": "^8.4.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
"@biomejs/biome": "^2.5.14",
|
"@biomejs/biome": "^2.5.14",
|
||||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "2.11.4",
|
"@tauri-apps/cli": "2.12.1",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
@@ -121,7 +123,7 @@
|
|||||||
"https-proxy-agent": "^9.1.0",
|
"https-proxy-agent": "^9.1.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"knip": "6.35.1",
|
"knip": "6.39.0",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
"sass": "^1.104.1",
|
"sass": "^1.104.1",
|
||||||
"tar": "^7.5.22",
|
"tar": "^7.5.22",
|
||||||
@@ -131,8 +133,8 @@
|
|||||||
"vite": "^8.3.0",
|
"vite": "^8.3.0",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.1",
|
||||||
"webdriverio": "9.31.9",
|
"webdriverio": "10.0.0",
|
||||||
"ws": "8.21.3"
|
"ws": "8.22.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,mjs}": [
|
"*.{ts,tsx,js,mjs}": [
|
||||||
@@ -144,5 +146,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@12.8.1"
|
"packageManager": "pnpm@12.10.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+519
-729
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.98.1"
|
channel = "1.99.0"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture grep gates for the frontend contract flow. Select gates via
|
||||||
|
* argv:
|
||||||
|
*
|
||||||
|
* node scripts/check-architecture.mjs listen emit invoke
|
||||||
|
*
|
||||||
|
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
|
||||||
|
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
|
||||||
|
* like `this.emit(` are unrelated class methods) or as importing the name
|
||||||
|
* from '@tauri-apps/api/event'.
|
||||||
|
* - invoke: `invoke(` calls may exist only under src/services/**.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||||
|
const SRC = path.join(ROOT, 'src')
|
||||||
|
|
||||||
|
const tauriEventImport = (name) =>
|
||||||
|
new RegExp(
|
||||||
|
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const GATES = {
|
||||||
|
listen: {
|
||||||
|
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
|
||||||
|
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||||
|
description: 'Tauri listen outside the event bus (src/services/bus)',
|
||||||
|
},
|
||||||
|
emit: {
|
||||||
|
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
|
||||||
|
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||||
|
description: 'Tauri emit outside the event bus (src/services/bus)',
|
||||||
|
},
|
||||||
|
invoke: {
|
||||||
|
patterns: [/\binvoke\(/],
|
||||||
|
allowed: (file) => file.startsWith('src/services/'),
|
||||||
|
description: 'invoke( outside the services layer (src/services)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const walk = (dir) => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) return walk(full)
|
||||||
|
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = process.argv.slice(2)
|
||||||
|
if (selected.length === 0) {
|
||||||
|
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let violations = 0
|
||||||
|
for (const gate of selected) {
|
||||||
|
const rule = GATES[gate]
|
||||||
|
if (!rule) {
|
||||||
|
console.error(`unknown gate: ${gate}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
for (const file of walk(SRC)) {
|
||||||
|
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
|
||||||
|
if (rule.allowed(relative)) continue
|
||||||
|
const lines = fs.readFileSync(file, 'utf8').split('\n')
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (rule.patterns.some((pattern) => pattern.test(line))) {
|
||||||
|
violations += 1
|
||||||
|
console.error(
|
||||||
|
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations > 0) {
|
||||||
|
console.error(`[arch] ${violations} violation(s) found`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log('[arch] no violations')
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates src/services/contract.ts from the backend wire contract.
|
||||||
|
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
|
||||||
|
* src-tauri, which serializes the same enum variants and event payloads the
|
||||||
|
* app emits at runtime. CI reruns this and fails on a non-empty diff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||||
|
const OUT = path.join(ROOT, 'src/services/contract.ts')
|
||||||
|
|
||||||
|
const contract = JSON.parse(
|
||||||
|
execFileSync(
|
||||||
|
'cargo',
|
||||||
|
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
|
||||||
|
{
|
||||||
|
cwd: path.join(ROOT, 'src-tauri'),
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (contract.version !== 1) {
|
||||||
|
throw new Error(`unsupported wire contract version: ${contract.version}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS type per event, derived from the serialized sample payload's JSON shape. */
|
||||||
|
const payloadType = (name, sample) => {
|
||||||
|
if (sample === null) return 'null'
|
||||||
|
if (typeof sample === 'string') return 'string'
|
||||||
|
if (Array.isArray(sample)) {
|
||||||
|
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
|
||||||
|
throw new Error(`unexpected array payload for ${name}`)
|
||||||
|
}
|
||||||
|
if (typeof sample === 'object') {
|
||||||
|
const keys = Object.keys(sample)
|
||||||
|
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
|
||||||
|
if (name === 'verge://run-state-changed') return 'RunState'
|
||||||
|
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected payload for ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsRunState = contract.events.some(
|
||||||
|
(e) => e.name === 'verge://run-state-changed',
|
||||||
|
)
|
||||||
|
if (!needsRunState)
|
||||||
|
throw new Error('run-state-changed event missing from contract')
|
||||||
|
|
||||||
|
const lines = []
|
||||||
|
lines.push(
|
||||||
|
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
|
||||||
|
)
|
||||||
|
lines.push('// Regen: pnpm gen:contract')
|
||||||
|
lines.push('')
|
||||||
|
lines.push("import type { RunState } from './cmds'")
|
||||||
|
lines.push('')
|
||||||
|
lines.push(
|
||||||
|
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
|
||||||
|
)
|
||||||
|
lines.push('export type NoticeStatus =')
|
||||||
|
for (const status of contract.noticeStatuses) {
|
||||||
|
lines.push(` | ${JSON.stringify(status)}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push('/** Event name → payload type for every backend-emitted event. */')
|
||||||
|
lines.push('export interface VergeEventPayloads {')
|
||||||
|
for (const event of contract.events) {
|
||||||
|
const key = JSON.stringify(event.name)
|
||||||
|
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
|
||||||
|
}
|
||||||
|
lines.push('}')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
const content = `${lines.join('\n')}\n`
|
||||||
|
fs.writeFileSync(OUT, content)
|
||||||
|
|
||||||
|
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
|
||||||
|
execFileSync(
|
||||||
|
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||||
|
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
|
||||||
|
{ cwd: ROOT, stdio: 'ignore' },
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
|
||||||
|
)
|
||||||
+41
-19
@@ -30,29 +30,51 @@ function is_valid_ip() {
|
|||||||
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
|
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
||||||
|
function sc_value() {
|
||||||
|
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
||||||
|
function dns_of() {
|
||||||
|
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
|
||||||
|
dns=$(networksetup -getdnsservers "$1") || return 1
|
||||||
|
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
|
||||||
|
echo "empty"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
[ -n "$dns" ] || return 1
|
||||||
|
while read -r line; do
|
||||||
|
[[ $line =~ $ip ]] || return 1
|
||||||
|
done <<<"$dns"
|
||||||
|
echo "$dns"
|
||||||
|
}
|
||||||
|
|
||||||
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
|
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
|
||||||
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
|
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
|
||||||
|
|
||||||
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
|
||||||
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
|
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
||||||
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
|
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
|
||||||
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
|
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
|
||||||
')
|
|
||||||
|
|
||||||
original_dns=$(networksetup -getdnsservers "$hardware_port")
|
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
|
||||||
|
|
||||||
is_valid_dns=false
|
backup=".original_dns/$service_id"
|
||||||
for ip in $original_dns; do
|
mkdir -p .original_dns || exit 1
|
||||||
ip=$(echo "$ip" | tr -d '[:space:]')
|
# Older releases kept a single backup for whichever service was primary.
|
||||||
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
|
if [ -f .original_dns.txt ]; then
|
||||||
is_valid_dns=true
|
if [ -f "$backup" ]; then
|
||||||
break
|
rm -f .original_dns.txt || exit 1
|
||||||
|
else
|
||||||
|
mv .original_dns.txt "$backup" || exit 1
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$is_valid_dns" = false ]; then
|
|
||||||
echo "empty" >.original_dns.txt
|
|
||||||
else
|
|
||||||
echo "$original_dns" >.original_dns.txt
|
|
||||||
fi
|
fi
|
||||||
networksetup -setdnsservers "$hardware_port" "$1"
|
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
|
||||||
|
if [ "$original_dns" != "$1" ]; then
|
||||||
|
echo "$original_dns" >"$backup" || exit 1
|
||||||
|
networksetup -setdnsservers "$hardware_port" "$1"
|
||||||
|
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
|
||||||
|
elif [ ! -f "$backup" ]; then
|
||||||
|
echo "empty" >"$backup" || exit 1
|
||||||
|
fi
|
||||||
|
|||||||
+57
-8
@@ -1,13 +1,62 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
|
||||||
|
|
||||||
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
|
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
||||||
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
|
function sc_value() {
|
||||||
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
|
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
||||||
')
|
}
|
||||||
|
|
||||||
|
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
||||||
|
function dns_of() {
|
||||||
|
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
|
||||||
|
dns=$(networksetup -getdnsservers "$1") || return 1
|
||||||
|
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
|
||||||
|
echo "empty"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
[ -n "$dns" ] || return 1
|
||||||
|
while read -r line; do
|
||||||
|
[[ $line =~ $ip ]] || return 1
|
||||||
|
done <<<"$dns"
|
||||||
|
echo "$dns"
|
||||||
|
}
|
||||||
|
|
||||||
|
result=0
|
||||||
|
# Older releases kept a single backup for whichever service was primary.
|
||||||
if [ -f .original_dns.txt ]; then
|
if [ -f .original_dns.txt ]; then
|
||||||
original_dns=$(cat .original_dns.txt)
|
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
||||||
networksetup -setdnsservers "$hardware_port" $original_dns
|
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
|
||||||
rm -rf .original_dns.txt
|
result=1
|
||||||
|
elif [ -f ".original_dns/$service_id" ]; then
|
||||||
|
rm -f .original_dns.txt || result=1
|
||||||
|
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
|
||||||
|
result=1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
for backup in .original_dns/*; do
|
||||||
|
[ -f "$backup" ] || continue
|
||||||
|
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
|
||||||
|
# Kept for a service that is gone: there is nothing to write it back to.
|
||||||
|
[ -n "$hardware_port" ] || continue
|
||||||
|
if ! current_dns=$(dns_of "$hardware_port"); then
|
||||||
|
echo "Cannot read DNS servers of $hardware_port." >&2
|
||||||
|
result=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# A service no longer on the placeholder was changed by someone else, and that change stands.
|
||||||
|
if [ "$current_dns" = "$1" ]; then
|
||||||
|
original_dns=$(cat "$backup") || { result=1; continue; }
|
||||||
|
# Older releases could back up the placeholder itself.
|
||||||
|
[ "$original_dns" = "$1" ] && original_dns=empty
|
||||||
|
networksetup -setdnsservers "$hardware_port" $original_dns
|
||||||
|
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
|
||||||
|
echo "Failed to restore DNS for $hardware_port." >&2
|
||||||
|
result=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$backup" || result=1
|
||||||
|
done
|
||||||
|
rmdir .original_dns 2>/dev/null
|
||||||
|
exit $result
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Backend Architecture Guardrails
|
||||||
|
|
||||||
|
These rules supplement the repository's root `AGENTS.md`.
|
||||||
|
|
||||||
|
- **Configuration ownership.** `config` owns source configuration, drafts and
|
||||||
|
persistence. The effects layer owns patch orchestration. Rollbackable
|
||||||
|
transactions publish state after persistence and commit; already applied
|
||||||
|
runtime and independently committed state remain authoritative even when a
|
||||||
|
later step fails. Preserve that state, its notifications and the error.
|
||||||
|
- **Effect ownership.** The declarative registry classifies every Verge patch field,
|
||||||
|
including fields without effects and platform-specific fields. Effects
|
||||||
|
reconcile toward the requested state with explicit dependencies and ordering.
|
||||||
|
Configuration rollback does not imply reversal of OS or lifecycle effects.
|
||||||
|
- **Shared wire contract.** Notifications are typed. Event names, status values
|
||||||
|
and payload shapes stay compatible with frontend producers and consumers.
|
||||||
|
- **Synchronization ownership.** Configuration-write serialization precedes
|
||||||
|
configuration-update ownership, which precedes Core lifecycle ownership.
|
||||||
|
When both are held, the lifecycle lock precedes the DNS lock. Independent
|
||||||
|
transactions retain their ownership boundaries; asynchronous work does not
|
||||||
|
hold synchronous data guards across suspension.
|
||||||
|
- **Retry ownership.** Bounded retries share backend policy while preserving
|
||||||
|
attempts, delays and terminal errors. Background monitors own their lifetime
|
||||||
|
and cancellation separately; waiting for a condition or traversing distinct
|
||||||
|
candidates is not a retry policy.
|
||||||
+20
-20
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.5.7"
|
version = "2.5.8"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
|
|||||||
default-run = "clash-verge"
|
default-run = "clash-verge"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.99"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
|||||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.6.3", features = [] }
|
tauri-build = { version = "2.7.1", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clash-verge-draft = { workspace = true }
|
clash-verge-draft = { workspace = true }
|
||||||
@@ -66,7 +66,6 @@ dunce = "1.0.5"
|
|||||||
nanoid = "0.5"
|
nanoid = "0.5"
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
boa_engine = "0.22.0"
|
boa_engine = "0.22.0"
|
||||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
@@ -74,36 +73,37 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
|||||||
"guard",
|
"guard",
|
||||||
] }
|
] }
|
||||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||||
tauri-plugin-shell = "2.3.6"
|
tauri-plugin-shell = "2.4.0"
|
||||||
tauri-plugin-opener = "2.5.5"
|
tauri-plugin-opener = "2.7.0"
|
||||||
tauri-plugin-dialog = "2.7.3"
|
tauri-plugin-dialog = "2.8.1"
|
||||||
tauri-plugin-fs = "2.5.2"
|
tauri-plugin-fs = "2.6.0"
|
||||||
tauri-plugin-process = "2.3.1"
|
tauri-plugin-process = "2.4.0"
|
||||||
tauri-plugin-deep-link = "2.4.10"
|
tauri-plugin-deep-link = "2.6.1"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.5.0"
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
flate2 = "1.1.10"
|
flate2 = "1.1.10"
|
||||||
reqwest_dav = "0.3.3"
|
reqwest_dav = "0.3.3"
|
||||||
aes-gcm = { version = "0.11.1" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
|
minisign-verify = "0.3.0"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
tauri-plugin-notification = "2.4.0"
|
tauri-plugin-notification = "2.5.1"
|
||||||
tokio-stream = "0.1.19"
|
tokio-stream = "0.1.19"
|
||||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||||
tauri-plugin-http = "2.6.1"
|
tauri-plugin-http = "2.8.0"
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
||||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
|
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
|
||||||
"client",
|
"client",
|
||||||
] }
|
] }
|
||||||
arc-swap = "1.9.2"
|
arc-swap = "1.9.2"
|
||||||
tokio-rustls = "0.26.5"
|
tokio-rustls = "0.26.6"
|
||||||
rustls = { version = "0.23.45", features = ["ring"] }
|
rustls = { version = "0.23.45", features = ["ring"] }
|
||||||
webpki-roots = "1.0.9"
|
webpki-roots = "1.0.9"
|
||||||
dark-light = "3.0.0"
|
dark-light = "3.0.0"
|
||||||
@@ -111,11 +111,11 @@ bytes = "1.12.1"
|
|||||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2.189"
|
libc = "0.2.190"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||||
objc2 = "0.6.4"
|
objc2 = "0.6.5"
|
||||||
objc2-foundation = { version = "0.3.2", features = [
|
objc2-foundation = { version = "0.3.2", features = [
|
||||||
"NSString",
|
"NSString",
|
||||||
"NSDictionary",
|
"NSDictionary",
|
||||||
@@ -146,9 +146,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
|||||||
windows-sys.workspace = true
|
windows-sys.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2.5.1"
|
tauri-plugin-autostart = "2.7.0"
|
||||||
tauri-plugin-global-shortcut = "2.3.2"
|
tauri-plugin-global-shortcut = "2.4.0"
|
||||||
tauri-plugin-updater = "2.12.0"
|
tauri-plugin-updater = "2.13.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
|
|||||||
@@ -901,6 +901,7 @@ Section Install
|
|||||||
|
|
||||||
nsExec::Exec 'netsh int tcp res'
|
nsExec::Exec 'netsh int tcp res'
|
||||||
|
|
||||||
|
!include "Win\RestartManager.nsh"
|
||||||
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
|
||||||
|
//! the payload is derived from the same serializers the app uses at runtime.
|
||||||
|
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
|
||||||
|
//! TypeScript artifact.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("{}", app_lib::frontend_wire_contract());
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
|
use crate::{
|
||||||
|
cmd::StringifyErr as _,
|
||||||
|
core::{SilentUpdater, updater::DownloadEvent},
|
||||||
|
feat,
|
||||||
|
utils::dirs,
|
||||||
|
};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use tauri::{AppHandle, Manager as _};
|
use tauri::{AppHandle, Manager as _, ipc::Channel};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_app_dir() -> CmdResult<()> {
|
pub async fn open_app_dir() -> CmdResult<()> {
|
||||||
@@ -44,6 +49,25 @@ pub async fn restart_app() -> CmdResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_update(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
version: String,
|
||||||
|
on_event: Channel<DownloadEvent>,
|
||||||
|
) -> CmdResult<bool> {
|
||||||
|
SilentUpdater::global()
|
||||||
|
.install_update(&app_handle, &version, |event| {
|
||||||
|
let _ = on_event.send(event);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.stringify_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cancel_update_download() {
|
||||||
|
SilentUpdater::global().cancel_download();
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_dir() -> CmdResult<String> {
|
pub fn get_app_dir() -> CmdResult<String> {
|
||||||
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
|
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
|
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
|
||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::feat;
|
use crate::feat;
|
||||||
use crate::utils::{dirs, yaml_emitter};
|
use crate::utils::{dirs, yaml_emitter};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -56,14 +57,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
|||||||
|
|
||||||
match CoreManager::global().restart_core().await {
|
match CoreManager::global().restart_core().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
handle::Handle::notice_message("config_core::change_success", clash_core);
|
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let failed = err.context("core changed but failed to restart");
|
let failed = err.context("core changed but failed to restart");
|
||||||
let error_msg: String = format!("{failed:#}").into();
|
let error_msg: String = format!("{failed:#}").into();
|
||||||
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
||||||
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
|
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
|
||||||
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg: String = format!("{err:#}").into();
|
let error_msg: String = format!("{err:#}").into();
|
||||||
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
||||||
handle::Handle::notice_message("config_core::change_error", error_msg);
|
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
||||||
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::module::lightweight;
|
use crate::core::lightweight;
|
||||||
|
|
||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
||||||
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
||||||
use crate::config::profiles;
|
use crate::config::profiles;
|
||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{
|
config::{
|
||||||
@@ -282,7 +283,7 @@ async fn handle_update_error<E: std::fmt::Display>(
|
|||||||
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
|
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
|
||||||
discard_and_restore(current_profile).await?;
|
discard_and_restore(current_profile).await?;
|
||||||
let message: String = e.to_string().into();
|
let message: String = e.to_string().into();
|
||||||
handle::Handle::notice_message("config_validate::boot_error", message.clone());
|
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
|
||||||
Ok(ValidationOutcome::invalid_from_message(message))
|
Ok(ValidationOutcome::invalid_from_message(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-32
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
core::{
|
core::{
|
||||||
handle::Handle,
|
handle::Handle,
|
||||||
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||||
tray::Tray,
|
tray::Tray,
|
||||||
},
|
},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::{BTreeMap, HashSet},
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,20 +50,87 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors Mihomo: `include-all`/`include-all-providers` replace `use` with every provider, sorted.
|
||||||
|
fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope> {
|
||||||
|
let Some(config) = config else {
|
||||||
|
return BTreeMap::new();
|
||||||
|
};
|
||||||
|
let mut all_providers = config
|
||||||
|
.get("proxy-providers")
|
||||||
|
.and_then(|providers| providers.as_mapping())
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|providers| providers.keys())
|
||||||
|
.filter_map(|name| name.as_str())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
all_providers.sort();
|
||||||
|
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
|
||||||
|
|
||||||
|
config
|
||||||
|
.get("proxy-groups")
|
||||||
|
.and_then(|groups| groups.as_sequence())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|group| group.as_mapping())
|
||||||
|
.filter_map(|group| {
|
||||||
|
let name = group.get("name")?.as_str()?.to_owned();
|
||||||
|
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
|
||||||
|
all_providers.clone()
|
||||||
|
} else {
|
||||||
|
group
|
||||||
|
.get("use")
|
||||||
|
.and_then(|providers| providers.as_sequence())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|provider| provider.as_str())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let exclude_types = group
|
||||||
|
.get("exclude-type")
|
||||||
|
.and_then(|types| types.as_str())
|
||||||
|
.filter(|types| !types.is_empty())
|
||||||
|
.map(|types| types.split('|').map(str::to_owned).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some((
|
||||||
|
name,
|
||||||
|
GroupScope {
|
||||||
|
providers,
|
||||||
|
exclude_types,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
||||||
|
proxy_view(None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
|
||||||
let runtime = Config::runtime().await;
|
let runtime = Config::runtime().await;
|
||||||
let latest_runtime = runtime.latest_arc();
|
let latest_runtime = runtime.latest_arc();
|
||||||
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
||||||
|
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
|
||||||
|
|
||||||
let mihomo = Handle::mihomo();
|
let mihomo = Handle::mihomo();
|
||||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
|
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
|
||||||
|
match provider_timeout {
|
||||||
|
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok),
|
||||||
|
None => mihomo.get_proxy_providers().await.ok(),
|
||||||
|
}
|
||||||
|
});
|
||||||
let proxies = proxies.stringify_err()?;
|
let proxies = proxies.stringify_err()?;
|
||||||
|
|
||||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||||
runtime_group_order,
|
runtime_group_order,
|
||||||
|
group_scopes,
|
||||||
proxies,
|
proxies,
|
||||||
providers: providers.ok(),
|
providers,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +151,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_tray_sync_loop() {
|
async fn run_tray_sync_loop() {
|
||||||
|
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
|
||||||
loop {
|
loop {
|
||||||
match Tray::global().update_menu().await {
|
match Tray::global().update_menu().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -109,31 +177,3 @@ async fn run_tray_sync_loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[allow(clippy::expect_used)]
|
|
||||||
mod tests {
|
|
||||||
use serde_yaml_ng::Value;
|
|
||||||
|
|
||||||
use super::runtime_group_order;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_order_keeps_first_non_empty_non_global_name() {
|
|
||||||
let config: Value = serde_yaml_ng::from_str(
|
|
||||||
r#"
|
|
||||||
proxy-groups:
|
|
||||||
- name: Beta
|
|
||||||
- name: ""
|
|
||||||
- name: GLOBAL
|
|
||||||
- name: " Alpha "
|
|
||||||
- name: Beta
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.expect("parse runtime");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
runtime_group_order(config.as_mapping()),
|
|
||||||
["Beta".to_owned(), " Alpha ".to_owned()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ use crate::{
|
|||||||
cmd::StringifyErr as _,
|
cmd::StringifyErr as _,
|
||||||
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
|
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
|
||||||
config::{Config, IProfiles, PrfItem},
|
config::{Config, IProfiles, PrfItem},
|
||||||
|
core::auto_backup::{AutoBackupManager, AutoBackupTrigger},
|
||||||
core::{
|
core::{
|
||||||
CoreManager, handle,
|
CoreManager, handle,
|
||||||
validate::{CoreConfigValidator, ValidationOutcome},
|
validate::{CoreConfigValidator, ValidationOutcome},
|
||||||
},
|
},
|
||||||
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
|
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
|
|||||||
SERVICE_MANAGER.handle_service_status(status).await
|
SERVICE_MANAGER.handle_service_status(status).await
|
||||||
};
|
};
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
|
return finish_service_installation(result, error_code, |reason| async move {
|
||||||
|
manager.continue_with_sidecar(Some(&reason)).await
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
result
|
result
|
||||||
@@ -49,7 +52,7 @@ async fn finish_service_installation<Fallback, FallbackFuture>(
|
|||||||
fallback: Fallback,
|
fallback: Fallback,
|
||||||
) -> CmdResult<ServiceInstallOutcome>
|
) -> CmdResult<ServiceInstallOutcome>
|
||||||
where
|
where
|
||||||
Fallback: FnOnce() -> FallbackFuture,
|
Fallback: FnOnce(String) -> FallbackFuture,
|
||||||
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
||||||
{
|
{
|
||||||
let Err(error) = installation else {
|
let Err(error) = installation else {
|
||||||
@@ -72,12 +75,9 @@ where
|
|||||||
return Err(proxy_aware_coded_error(&error, error_code));
|
return Err(proxy_aware_coded_error(&error, error_code));
|
||||||
}
|
}
|
||||||
let reason = reasons.join("\n");
|
let reason = reasons.join("\n");
|
||||||
fallback().await.map_err(|error| {
|
fallback(reason.clone())
|
||||||
proxy_aware_coded_error(
|
.await
|
||||||
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
|
||||||
"SERVICE_SIDECAR_FAILED",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(ServiceInstallOutcome::Sidecar { reason })
|
Ok(ServiceInstallOutcome::Sidecar { reason })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +107,16 @@ pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn continue_with_sidecar() -> CmdResult {
|
pub async fn continue_with_sidecar() -> CmdResult {
|
||||||
crate::core::CoreManager::global()
|
crate::core::CoreManager::global()
|
||||||
.continue_with_sidecar()
|
.continue_with_sidecar(None)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
|
||||||
|
crate::core::notification::sidecar_failure_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
||||||
crate::core::service::take_service_fallback_notice()
|
crate::core::service::take_service_fallback_notice()
|
||||||
@@ -175,7 +180,7 @@ mod tests {
|
|||||||
None => anyhow::anyhow!("{reason}"),
|
None => anyhow::anyhow!("{reason}"),
|
||||||
}
|
}
|
||||||
.context("install service failed");
|
.context("install service failed");
|
||||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
|
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
|
||||||
fallback_called.set(true);
|
fallback_called.set(true);
|
||||||
anyhow::ensure!(fallback_ready, "another core is running");
|
anyhow::ensure!(fallback_ready, "another core is running");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -201,7 +206,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if rejected {
|
if rejected {
|
||||||
assert!(error.detail.contains(reason));
|
|
||||||
assert!(error.detail.contains("another core is running"));
|
assert!(error.detail.contains("another core is running"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
handle,
|
handle,
|
||||||
validate::{ValidationErrorKind, ValidationOutcome},
|
validate::{ValidationErrorKind, ValidationOutcome},
|
||||||
@@ -11,27 +12,27 @@ pub enum ValidationNoticeTarget {
|
|||||||
Script,
|
Script,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> &'static str {
|
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> NoticeStatus {
|
||||||
match kind {
|
match kind {
|
||||||
ValidationErrorKind::FileMissing => "config_validate::file_not_found",
|
ValidationErrorKind::FileMissing => NoticeStatus::ConfigValidateFileNotFound,
|
||||||
ValidationErrorKind::FileRead => match target {
|
ValidationErrorKind::FileRead => match target {
|
||||||
ValidationNoticeTarget::Script => "config_validate::script_error",
|
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
||||||
_ => "config_validate::yaml_read_error",
|
_ => NoticeStatus::ConfigValidateYamlReadError,
|
||||||
},
|
},
|
||||||
ValidationErrorKind::YamlSyntax => match target {
|
ValidationErrorKind::YamlSyntax => match target {
|
||||||
ValidationNoticeTarget::Merge => "config_validate::merge_syntax_error",
|
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeSyntaxError,
|
||||||
ValidationNoticeTarget::Script => "config_validate::script_error",
|
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
||||||
ValidationNoticeTarget::Runtime => "config_validate::yaml_syntax_error",
|
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlSyntaxError,
|
||||||
},
|
},
|
||||||
ValidationErrorKind::YamlMapping => match target {
|
ValidationErrorKind::YamlMapping => match target {
|
||||||
ValidationNoticeTarget::Merge => "config_validate::merge_mapping_error",
|
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeMappingError,
|
||||||
ValidationNoticeTarget::Script => "config_validate::script_error",
|
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
||||||
ValidationNoticeTarget::Runtime => "config_validate::yaml_mapping_error",
|
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlMappingError,
|
||||||
},
|
},
|
||||||
ValidationErrorKind::ScriptSyntax => "config_validate::script_syntax_error",
|
ValidationErrorKind::ScriptSyntax => NoticeStatus::ConfigValidateScriptSyntaxError,
|
||||||
ValidationErrorKind::ScriptMissingMain => "config_validate::script_missing_main",
|
ValidationErrorKind::ScriptMissingMain => NoticeStatus::ConfigValidateScriptMissingMain,
|
||||||
ValidationErrorKind::ProcessTerminated => "config_validate::process_terminated",
|
ValidationErrorKind::ProcessTerminated => NoticeStatus::ConfigValidateProcessTerminated,
|
||||||
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => "config_validate::error",
|
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => NoticeStatus::ConfigValidateError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +41,12 @@ pub fn handle_validation_notice(outcome: &ValidationOutcome, target: ValidationN
|
|||||||
ValidationOutcome::Invalid { kind, message } => {
|
ValidationOutcome::Invalid { kind, message } => {
|
||||||
let status = notice_key(*kind, target);
|
let status = notice_key(*kind, target);
|
||||||
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message);
|
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message);
|
||||||
handle::Handle::notice_message(status, message.to_owned());
|
handle::Handle::notice(status, message.as_str());
|
||||||
}
|
}
|
||||||
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
|
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
|
||||||
let message = outcome.to_string();
|
let message = outcome.to_string();
|
||||||
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
|
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
|
||||||
handle::Handle::notice_message("config_validate::error", message);
|
handle::Handle::notice(NoticeStatus::ConfigValidateError, message.as_str());
|
||||||
}
|
}
|
||||||
ValidationOutcome::Valid => {}
|
ValidationOutcome::Valid => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
|
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
|
||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
|
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
|
||||||
constants::{files, timing},
|
constants::{files, timing},
|
||||||
@@ -69,6 +70,12 @@ impl Config {
|
|||||||
CONFIG_WRITE_LOCK.lock().await
|
CONFIG_WRITE_LOCK.lock().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn try_lock_config_write() -> Result<MutexGuard<'static, ()>> {
|
||||||
|
CONFIG_WRITE_LOCK
|
||||||
|
.try_lock()
|
||||||
|
.map_err(|_| anyhow!("configuration update is already running"))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn init_config_before_window() -> Result<()> {
|
pub async fn init_config_before_window() -> Result<()> {
|
||||||
Self::ensure_default_profile_items().await?;
|
Self::ensure_default_profile_items().await?;
|
||||||
|
|
||||||
@@ -106,7 +113,7 @@ impl Config {
|
|||||||
|
|
||||||
if let Some((msg_type, msg_content)) = validation_result {
|
if let Some((msg_type, msg_content)) = validation_result {
|
||||||
sleep(timing::STARTUP_ERROR_DELAY).await;
|
sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||||
handle::Handle::notice_message(msg_type, msg_content);
|
handle::Handle::notice(msg_type, msg_content.as_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::runtime().await.apply();
|
Self::runtime().await.apply();
|
||||||
@@ -137,14 +144,14 @@ impl Config {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_and_validate() -> Result<Option<(&'static str, String)>> {
|
async fn generate_and_validate() -> Result<Option<(NoticeStatus, String)>> {
|
||||||
if let Err(err) = Self::generate().await {
|
if let Err(err) = Self::generate().await {
|
||||||
let error_msg: String = err.to_string().into();
|
let error_msg: String = err.to_string().into();
|
||||||
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
|
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config("config_validate::boot_error", &error_msg)
|
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(Some(("config_validate::boot_error", error_msg)));
|
return Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)));
|
||||||
}
|
}
|
||||||
logging!(debug, Type::Config, "生成运行时配置成功");
|
logging!(debug, Type::Config, "生成运行时配置成功");
|
||||||
|
|
||||||
@@ -171,25 +178,25 @@ impl Config {
|
|||||||
error_msg
|
error_msg
|
||||||
);
|
);
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config("config_validate::boot_error", &error_msg)
|
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some(("config_validate::boot_error", error_msg)))
|
Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
|
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config("config_validate::process_terminated", "")
|
.use_default_config(NoticeStatus::ConfigValidateProcessTerminated, "")
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some(("config_validate::process_terminated", String::new())))
|
Ok(Some((NoticeStatus::ConfigValidateProcessTerminated, String::new())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
|
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
|
||||||
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
|
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config("config_validate::error", "")
|
.use_default_config(NoticeStatus::ConfigValidateError, "")
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some(("config_validate::error", String::new())))
|
Ok(Some((NoticeStatus::ConfigValidateError, String::new())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -138,12 +139,13 @@ impl Config {
|
|||||||
}
|
}
|
||||||
// The runtime is already committed; a disk failure cannot undo its effective settings.
|
// The runtime is already committed; a disk failure cannot undo its effective settings.
|
||||||
transaction.commit();
|
transaction.commit();
|
||||||
Handle::refresh_verge();
|
|
||||||
if state.requested && !state.enabled {
|
if state.requested && !state.enabled {
|
||||||
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
|
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
|
||||||
Handle::notice_message("dns_override::auto_disabled", "");
|
Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, "");
|
||||||
}
|
}
|
||||||
verge.data_arc().save_file().await
|
let result = verge.data_arc().save_file().await;
|
||||||
|
Handle::refresh_verge();
|
||||||
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::{Config, IClashTemp, IVerge, MixedPort};
|
use super::{Config, IClashTemp, IVerge, MixedPort};
|
||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::timing,
|
constants::timing,
|
||||||
core::{
|
core::{
|
||||||
@@ -14,15 +15,9 @@ use crate::{
|
|||||||
use anyhow::{Context as _, Result, anyhow, bail};
|
use anyhow::{Context as _, Result, anyhow, bail};
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_yaml_ng::Value;
|
use serde_yaml_ng::Value;
|
||||||
use std::{
|
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
|
||||||
collections::HashSet,
|
|
||||||
net::SocketAddr,
|
|
||||||
str::FromStr as _,
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct MixedPortFallback {
|
struct MixedPortFallback {
|
||||||
@@ -30,9 +25,8 @@ struct MixedPortFallback {
|
|||||||
current: u16,
|
current: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
|
static PENDING_FALLBACK_NOTICE: Mutex<Option<MixedPortFallback>> = Mutex::new(None);
|
||||||
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
|
static STARTUP_CORE_BLOCK_REASON: Mutex<Option<String>> = Mutex::new(None);
|
||||||
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
|
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
|
||||||
@@ -147,17 +141,12 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn block_startup_core(error: &anyhow::Error) {
|
pub(crate) fn block_startup_core(error: &anyhow::Error) {
|
||||||
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
|
|
||||||
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
|
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
|
||||||
report_fallback_error(error.to_string());
|
report_fallback_error(error.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn startup_core_block_reason() -> Option<String> {
|
pub(crate) fn startup_core_block_reason() -> Option<String> {
|
||||||
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
|
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
||||||
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn notify_startup_mixed_port_fallback() {
|
pub(crate) fn notify_startup_mixed_port_fallback() {
|
||||||
@@ -166,8 +155,8 @@ impl Config {
|
|||||||
};
|
};
|
||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||||
Handle::notice_message(
|
Handle::notice(
|
||||||
"mixed_port::fallback",
|
NoticeStatus::MixedPortFallback,
|
||||||
format!("{},{}", change.original, change.current),
|
format!("{},{}", change.original, change.current),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -196,7 +185,7 @@ fn report_fallback_error(message: String) {
|
|||||||
);
|
);
|
||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||||
Handle::notice_message("mixed_port::fallback_error", message);
|
Handle::notice(NoticeStatus::MixedPortFallbackError, message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ pub struct PrfItem {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub selected: Option<Vec<PrfSelected>>,
|
pub selected: Option<Vec<PrfSelected>>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub extra: Option<PrfExtra>,
|
pub extra: Option<PrfExtra>,
|
||||||
|
|
||||||
@@ -217,6 +220,7 @@ impl PrfItem {
|
|||||||
desc: Some(desc),
|
desc: Some(desc),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
url: None,
|
url: None,
|
||||||
|
latency_test_urls: None,
|
||||||
selected: None,
|
selected: None,
|
||||||
extra: None,
|
extra: None,
|
||||||
option: Some(PrfOption {
|
option: Some(PrfOption {
|
||||||
@@ -396,6 +400,7 @@ impl PrfItem {
|
|||||||
desc: desc.cloned(),
|
desc: desc.cloned(),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
url: Some(url.as_str().into()),
|
url: Some(url.as_str().into()),
|
||||||
|
latency_test_urls: None,
|
||||||
selected: None,
|
selected: None,
|
||||||
extra,
|
extra,
|
||||||
option: Some(PrfOption {
|
option: Some(PrfOption {
|
||||||
|
|||||||
+156
-148
@@ -2,6 +2,8 @@ use super::{
|
|||||||
PrfOption,
|
PrfOption,
|
||||||
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
||||||
};
|
};
|
||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
|
use crate::utils::retry::{RetryError, RetryPolicy, retry, retry_with_state};
|
||||||
use crate::{
|
use crate::{
|
||||||
core::{handle, tray::Tray},
|
core::{handle, tray::Tray},
|
||||||
utils::{
|
utils::{
|
||||||
@@ -16,6 +18,7 @@ use parking_lot::Mutex;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
path::{Component, Path},
|
path::{Component, Path},
|
||||||
@@ -245,6 +248,7 @@ impl IProfiles {
|
|||||||
patch!(each, item, file);
|
patch!(each, item, file);
|
||||||
patch!(each, item, url);
|
patch!(each, item, url);
|
||||||
patch!(each, item, selected);
|
patch!(each, item, selected);
|
||||||
|
patch!(each, item, latency_test_urls);
|
||||||
patch!(each, item, extra);
|
patch!(each, item, extra);
|
||||||
patch!(each, item, updated);
|
patch!(each, item, updated);
|
||||||
patch!(each, item, option);
|
patch!(each, item, option);
|
||||||
@@ -600,19 +604,21 @@ fn is_activation_current(generation: u64) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
||||||
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
|
let interval = Duration::from_millis(500);
|
||||||
loop {
|
let attempts =
|
||||||
match handle::Handle::mihomo().get_proxies().await {
|
NonZeroUsize::MIN.saturating_add((MIHOMO_OPERATION_TIMEOUT.as_millis() / interval.as_millis()) as usize + 1);
|
||||||
Ok(proxies) => return proxies,
|
tokio::time::timeout(
|
||||||
Err(err) => {
|
MIHOMO_OPERATION_TIMEOUT,
|
||||||
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err:#}");
|
retry(RetryPolicy::fixed(attempts, interval), |_| async {
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
handle::Handle::mihomo().get_proxies().await.map_err(|error| {
|
||||||
}
|
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {error:#}");
|
||||||
}
|
RetryError::Retry(error)
|
||||||
}
|
})
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.context("timed out while waiting for mihomo proxies")
|
.context("timed out while waiting for mihomo proxies")?
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
||||||
@@ -691,51 +697,58 @@ async fn update_tray_after_activation(generation: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn persist_selected_update<F, Fut>(supersede: bool, edit: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnOnce(IProfiles) -> Fut + Send,
|
||||||
|
Fut: std::future::Future<Output = Result<(IProfiles, bool)>> + Send,
|
||||||
|
{
|
||||||
|
let changed = Config::profiles().await.with_data_modify(edit).await?;
|
||||||
|
if changed {
|
||||||
|
if supersede {
|
||||||
|
supersede_selected_activation();
|
||||||
|
}
|
||||||
|
announce(Refresh::Profiles);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Records a backend-made selection so the next core start restores it.
|
/// Records a backend-made selection so the next core start restores it.
|
||||||
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
||||||
let group_name = String::from(group_name);
|
let group_name = String::from(group_name);
|
||||||
let node = String::from(node);
|
let node = String::from(node);
|
||||||
let recorded = Config::profiles()
|
persist_selected_update(true, move |mut profiles| async move {
|
||||||
.await
|
let Some(current) = profiles.current.clone() else {
|
||||||
.with_data_modify(move |mut profiles| async move {
|
return Ok((profiles, false));
|
||||||
let Some(current) = profiles.current.clone() else {
|
};
|
||||||
return Ok((profiles, false));
|
let Some(item) = profiles
|
||||||
};
|
.items
|
||||||
let Some(item) = profiles
|
.as_mut()
|
||||||
.items
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||||
.as_mut()
|
else {
|
||||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
return Ok((profiles, false));
|
||||||
else {
|
};
|
||||||
return Ok((profiles, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut selected = item.selected.clone().unwrap_or_default();
|
let mut selected = item.selected.clone().unwrap_or_default();
|
||||||
match selected
|
match selected
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|entry| entry.name.as_ref() == Some(&group_name))
|
.find(|entry| entry.name.as_ref() == Some(&group_name))
|
||||||
{
|
{
|
||||||
Some(entry) => {
|
Some(entry) => {
|
||||||
if entry.now.as_ref() == Some(&node) {
|
if entry.now.as_ref() == Some(&node) {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
}
|
|
||||||
entry.now = Some(node);
|
|
||||||
}
|
}
|
||||||
None => selected.push(PrfSelected {
|
entry.now = Some(node);
|
||||||
name: Some(group_name),
|
|
||||||
now: Some(node),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
item.selected = Some(selected);
|
None => selected.push(PrfSelected {
|
||||||
profiles.save_file().await?;
|
name: Some(group_name),
|
||||||
Ok((profiles, true))
|
now: Some(node),
|
||||||
})
|
}),
|
||||||
.await?;
|
}
|
||||||
|
item.selected = Some(selected);
|
||||||
if recorded {
|
profiles.save_file().await?;
|
||||||
supersede_selected_activation();
|
Ok((profiles, true))
|
||||||
handle::Handle::refresh_profiles();
|
})
|
||||||
}
|
.await
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
|
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
|
||||||
@@ -745,36 +758,28 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
||||||
let cleared = Config::profiles()
|
persist_selected_update(true, move |mut profiles| async move {
|
||||||
.await
|
let Some(current) = profiles.current.clone() else {
|
||||||
.with_data_modify(move |mut profiles| async move {
|
return Ok((profiles, false));
|
||||||
let Some(current) = profiles.current.clone() else {
|
};
|
||||||
return Ok((profiles, false));
|
let Some(item) = profiles
|
||||||
};
|
.items
|
||||||
let Some(item) = profiles
|
.as_mut()
|
||||||
.items
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||||
.as_mut()
|
else {
|
||||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
return Ok((profiles, false));
|
||||||
else {
|
};
|
||||||
return Ok((profiles, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut selected = item.selected.clone().unwrap_or_default();
|
let mut selected = item.selected.clone().unwrap_or_default();
|
||||||
if !remove_selected_node(&mut selected, group_name) {
|
if !remove_selected_node(&mut selected, group_name) {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
item.selected = (!selected.is_empty()).then_some(selected);
|
item.selected = (!selected.is_empty()).then_some(selected);
|
||||||
profiles.save_file().await?;
|
profiles.save_file().await?;
|
||||||
Ok((profiles, true))
|
Ok((profiles, true))
|
||||||
})
|
})
|
||||||
.await?;
|
.await
|
||||||
|
|
||||||
if cleared {
|
|
||||||
supersede_selected_activation();
|
|
||||||
handle::Handle::refresh_profiles();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_reconciled_selected(
|
async fn persist_reconciled_selected(
|
||||||
@@ -787,34 +792,27 @@ async fn persist_reconciled_selected(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let profiles = Config::profiles().await;
|
|
||||||
let profile_uid = profile_uid.clone();
|
let profile_uid = profile_uid.clone();
|
||||||
let original_selected = original_selected.to_vec();
|
let original_selected = original_selected.to_vec();
|
||||||
let updated = profiles
|
persist_selected_update(false, move |mut profiles| async move {
|
||||||
.with_data_modify(move |mut profiles| async move {
|
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
|
||||||
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
|
return Ok((profiles, false));
|
||||||
return Ok((profiles, false));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let profile = profiles
|
let profile = profiles
|
||||||
.items
|
.items
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
|
||||||
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
|
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
|
||||||
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
|
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.selected = (!selected.is_empty()).then_some(selected);
|
profile.selected = (!selected.is_empty()).then_some(selected);
|
||||||
profiles.save_file().await?;
|
profiles.save_file().await?;
|
||||||
Ok((profiles, true))
|
Ok((profiles, true))
|
||||||
})
|
})
|
||||||
.await?;
|
.await
|
||||||
|
|
||||||
if updated {
|
|
||||||
handle::Handle::refresh_profiles();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns recorded selections whose groups have not reached the requested node.
|
/// Returns recorded selections whose groups have not reached the requested node.
|
||||||
@@ -836,48 +834,58 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<Stri
|
|||||||
/// Retries selections while provider-backed groups finish loading.
|
/// Retries selections while provider-backed groups finish loading.
|
||||||
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
|
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
|
||||||
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
|
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
|
||||||
loop {
|
let attempts = NonZeroUsize::MIN.saturating_add(
|
||||||
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
|
(SELECTED_NODES_SETTLE_DEADLINE.as_millis() / SELECTED_NODES_SETTLE_INTERVAL.as_millis()) as usize,
|
||||||
if !is_activation_current(generation) {
|
);
|
||||||
return;
|
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
|
||||||
}
|
let _ = retry_with_state(
|
||||||
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
|
RetryPolicy::fixed(attempts, SELECTED_NODES_SETTLE_INTERVAL),
|
||||||
// Unreachable core: the deadline still applies, so this cannot spin forever.
|
(selected, completed, generation, deadline),
|
||||||
if Instant::now() >= deadline {
|
|state, _| {
|
||||||
return;
|
Box::pin(async move {
|
||||||
}
|
let (selected, completed, generation, deadline) = state;
|
||||||
continue;
|
if !is_activation_current(*generation) {
|
||||||
};
|
return Ok(());
|
||||||
if !is_activation_current(generation) {
|
}
|
||||||
return;
|
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
|
||||||
}
|
return if Instant::now() >= *deadline {
|
||||||
|
Ok(())
|
||||||
let pending = unsettled_selections(selected, &snapshot);
|
} else {
|
||||||
if pending.is_empty() {
|
Err(RetryError::Retry(()))
|
||||||
return;
|
};
|
||||||
}
|
};
|
||||||
if Instant::now() >= deadline {
|
if !is_activation_current(*generation) {
|
||||||
logging!(
|
return Ok(());
|
||||||
warn,
|
}
|
||||||
Type::Config,
|
let pending = unsettled_selections(selected, &snapshot);
|
||||||
"gave up putting back {} selected node(s) the core never loaded: {}",
|
if pending.is_empty() {
|
||||||
pending.len(),
|
return Ok(());
|
||||||
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
|
}
|
||||||
);
|
if Instant::now() >= *deadline {
|
||||||
return;
|
logging!(
|
||||||
}
|
warn,
|
||||||
|
Type::Config,
|
||||||
let plan = reconcile_selected_nodes(selected, None, &snapshot);
|
"gave up putting back {} selected node(s) the core never loaded: {}",
|
||||||
if apply_activations(&plan.activations, completed, generation)
|
pending.len(),
|
||||||
.await
|
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
|
||||||
.is_none()
|
);
|
||||||
{
|
return Ok(());
|
||||||
return;
|
}
|
||||||
}
|
let plan = reconcile_selected_nodes(selected, None, &snapshot);
|
||||||
if is_activation_current(generation) {
|
if apply_activations(&plan.activations, completed, *generation)
|
||||||
handle::Handle::refresh_clash();
|
.await
|
||||||
}
|
.is_none()
|
||||||
}
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if is_activation_current(*generation) {
|
||||||
|
announce(Refresh::Clash);
|
||||||
|
}
|
||||||
|
Err(RetryError::Retry(()))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases the first-pass waiter even when restoration exits early.
|
/// Releases the first-pass waiter even when restoration exits early.
|
||||||
@@ -920,7 +928,7 @@ async fn activate_selected_nodes_worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_activation_current(generation) {
|
if is_activation_current(generation) {
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
}
|
}
|
||||||
|
|
||||||
let plan = if needs_confirmation {
|
let plan = if needs_confirmation {
|
||||||
@@ -944,7 +952,7 @@ async fn activate_selected_nodes_worker(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if confirmed_activated_count > 0 && is_activation_current(generation) {
|
if confirmed_activated_count > 0 && is_activation_current(generation) {
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
}
|
}
|
||||||
confirmed_plan
|
confirmed_plan
|
||||||
} else {
|
} else {
|
||||||
@@ -1034,7 +1042,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
|
|||||||
|
|
||||||
if selected.is_empty() {
|
if selected.is_empty() {
|
||||||
if is_activation_current(generation) {
|
if is_activation_current(generation) {
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1054,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
|
|||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
|
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
|
||||||
// The profile itself is already active even if node restoration failed.
|
// The profile itself is already active even if node restoration failed.
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
}
|
}
|
||||||
update_tray_after_activation(generation).await;
|
update_tray_after_activation(generation).await;
|
||||||
logging!(debug, Type::Config, "activating selected nodes done!");
|
logging!(debug, Type::Config, "activating selected nodes done!");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail};
|
|||||||
use std::{io::ErrorKind, path::PathBuf};
|
use std::{io::ErrorKind, path::PathBuf};
|
||||||
|
|
||||||
pub(crate) struct FileSnapshot {
|
pub(crate) struct FileSnapshot {
|
||||||
path: PathBuf,
|
pub(crate) path: PathBuf,
|
||||||
content: Option<Vec<u8>>,
|
content: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub mod files {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod tun {
|
pub mod tun {
|
||||||
pub const DEFAULT_STACK: &str = "gvisor";
|
pub const DEFAULT_STACK: &str = "mips";
|
||||||
|
|
||||||
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ use crate::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::OnceCell;
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc, OnceLock,
|
||||||
atomic::{AtomicBool, AtomicI64, Ordering},
|
atomic::{AtomicBool, AtomicI64, Ordering},
|
||||||
},
|
},
|
||||||
time::{Duration, UNIX_EPOCH},
|
time::{Duration, UNIX_EPOCH},
|
||||||
@@ -92,7 +91,7 @@ pub struct AutoBackupManager {
|
|||||||
|
|
||||||
impl AutoBackupManager {
|
impl AutoBackupManager {
|
||||||
pub fn global() -> &'static Self {
|
pub fn global() -> &'static Self {
|
||||||
static INSTANCE: OnceCell<AutoBackupManager> = OnceCell::new();
|
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
||||||
INSTANCE.get_or_init(|| {
|
INSTANCE.get_or_init(|| {
|
||||||
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
||||||
Self {
|
Self {
|
||||||
@@ -4,7 +4,6 @@ use anyhow::Error;
|
|||||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||||
use backon::{ConstantBuilder, Retryable as _};
|
use backon::{ConstantBuilder, Retryable as _};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::OnceCell;
|
|
||||||
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
|
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{
|
use std::{
|
||||||
@@ -12,7 +11,7 @@ use std::{
|
|||||||
env::{consts::OS, temp_dir},
|
env::{consts::OS, temp_dir},
|
||||||
io::Write as _,
|
io::Write as _,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::Arc,
|
sync::{Arc, OnceLock},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::{fs, time::timeout};
|
use tokio::{fs, time::timeout};
|
||||||
@@ -58,7 +57,7 @@ pub struct WebDavClient {
|
|||||||
|
|
||||||
impl WebDavClient {
|
impl WebDavClient {
|
||||||
pub fn global() -> &'static Self {
|
pub fn global() -> &'static Self {
|
||||||
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
|
static WEBDAV_CLIENT: OnceLock<WebDavClient> = OnceLock::new();
|
||||||
WEBDAV_CLIENT.get_or_init(|| Self {
|
WEBDAV_CLIENT.get_or_init(|| Self {
|
||||||
config: ArcSwapOption::new(None),
|
config: ArcSwapOption::new(None),
|
||||||
clients: ArcSwap::new(Arc::new(HashMap::new())),
|
clients: ArcSwap::new(Arc::new(HashMap::new())),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::process::AsyncHandler;
|
use crate::process::AsyncHandler;
|
||||||
use crate::singleton;
|
use crate::singleton;
|
||||||
use crate::utils::notification::{NotificationEvent, notify_event};
|
use crate::utils::notification::{NotificationEvent, notify_event};
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
|
use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_mode, feat};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
@@ -177,7 +178,7 @@ impl Hotkey {
|
|||||||
"Hotkey profile reactivation failed validation: {}",
|
"Hotkey profile reactivation failed validation: {}",
|
||||||
message.as_str()
|
message.as_str()
|
||||||
);
|
);
|
||||||
handle::Handle::notice_message("reactivate_profiles::error", message);
|
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, message);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(
|
logging!(
|
||||||
@@ -186,7 +187,7 @@ impl Hotkey {
|
|||||||
"Failed to reactivate subscriptions via hotkey: {}",
|
"Failed to reactivate subscriptions via hotkey: {}",
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
handle::Handle::notice_message("reactivate_profiles::error", err.to_string());
|
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, err.to_string());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,10 +271,7 @@ fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
|
|||||||
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized = bind_address
|
let normalized = bind_address.strip_circumfix('[', ']').unwrap_or(bind_address);
|
||||||
.strip_prefix('[')
|
|
||||||
.and_then(|address| address.strip_suffix(']'))
|
|
||||||
.unwrap_or(bind_address);
|
|
||||||
Ok(vec![
|
Ok(vec![
|
||||||
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
|
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
|
||||||
])
|
])
|
||||||
@@ -298,11 +295,7 @@ fn parse_listener_address(address: &str) -> Result<(Vec<IpAddr>, u16)> {
|
|||||||
if port == 0 {
|
if port == 0 {
|
||||||
bail!("listener port must be between 1 and 65535");
|
bail!("listener port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
let host = host
|
let host = host.trim().strip_circumfix('[', ']').unwrap_or_else(|| host.trim());
|
||||||
.trim()
|
|
||||||
.strip_prefix('[')
|
|
||||||
.and_then(|host| host.strip_suffix(']'))
|
|
||||||
.unwrap_or_else(|| host.trim());
|
|
||||||
if host.eq_ignore_ascii_case("localhost") {
|
if host.eq_ignore_ascii_case("localhost") {
|
||||||
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
|
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
|
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
|
||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::core::service::StageRequest;
|
use crate::core::service::StageRequest;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IProfiles, runtime::IRuntime},
|
config::{Config, IProfiles, runtime::IRuntime},
|
||||||
@@ -18,6 +19,7 @@ use smartstring::alias::String;
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tauri_plugin_mihomo::Error as MihomoError;
|
use tauri_plugin_mihomo::Error as MihomoError;
|
||||||
@@ -129,7 +131,31 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CoreManager {
|
impl CoreManager {
|
||||||
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
|
pub(crate) fn claim_config_update(
|
||||||
|
&self,
|
||||||
|
_config_write: &tokio::sync::MutexGuard<'_, ()>,
|
||||||
|
) -> Result<ConfigUpdateGuard<'_>> {
|
||||||
|
if !self.try_start_config_update() {
|
||||||
|
anyhow::bail!("configuration update is already running");
|
||||||
|
}
|
||||||
|
Ok(ConfigUpdateGuard(self))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn update_config_in_patch(&self, _update: &ConfigUpdateGuard<'_>) -> Result<()> {
|
||||||
|
if handle::Handle::global().is_exiting() {
|
||||||
|
anyhow::bail!("application is exiting");
|
||||||
|
}
|
||||||
|
self.set_last_update(Instant::now());
|
||||||
|
Config::generate().await?;
|
||||||
|
let outcome = self.validate_and_apply_draft().await?;
|
||||||
|
if outcome.is_valid() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("{outcome}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn use_default_config(&self, status: NoticeStatus, message: &str) -> Result<()> {
|
||||||
use crate::constants::files::RUNTIME_CONFIG;
|
use crate::constants::files::RUNTIME_CONFIG;
|
||||||
|
|
||||||
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
|
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
|
||||||
@@ -145,7 +171,7 @@ impl CoreManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?;
|
help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?;
|
||||||
handle::Handle::notice_message(error_key, error_msg);
|
handle::Handle::notice(status, message);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +260,7 @@ impl CoreManager {
|
|||||||
let outcome = self.perform_config_update(Some(profiles)).await?;
|
let outcome = self.perform_config_update(Some(profiles)).await?;
|
||||||
if outcome.is_valid() {
|
if outcome.is_valid() {
|
||||||
crate::config::profiles::restore_selected_nodes().await;
|
crate::config::profiles::restore_selected_nodes().await;
|
||||||
|
handle::Handle::refresh_clash();
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("failed to restore previous Core configuration: {outcome}"))
|
Err(anyhow!("failed to restore previous Core configuration: {outcome}"))
|
||||||
@@ -297,8 +324,27 @@ impl CoreManager {
|
|||||||
self.validate_and_apply(transaction).await
|
self.validate_and_apply(transaction).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates and applies the caller's transaction, committing only on success.
|
/// Commits the applied runtime even if restoring the system proxy subsequently fails.
|
||||||
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
|
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
|
||||||
|
let runtime = Config::runtime().await;
|
||||||
|
let original_runtime = runtime.data_arc();
|
||||||
|
let outcome = match self.validate_and_apply_draft().await {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(error) => {
|
||||||
|
transaction.rollback();
|
||||||
|
if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) {
|
||||||
|
handle::Handle::refresh_clash();
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if outcome.is_valid() {
|
||||||
|
transaction.commit();
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_and_apply_draft(&self) -> Result<ValidationOutcome> {
|
||||||
// One serialization feeds check and run files; the core never applies unvalidated bytes.
|
// One serialization feeds check and run files; the core never applies unvalidated bytes.
|
||||||
let yaml = Config::runtime_config_yaml().await?;
|
let yaml = Config::runtime_config_yaml().await?;
|
||||||
let outcome = CoreConfigValidator::global()
|
let outcome = CoreConfigValidator::global()
|
||||||
@@ -309,11 +355,34 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let run_path = Config::write_runtime_file(&yaml).await?;
|
let run_path = Config::write_runtime_file(&yaml).await?;
|
||||||
self.apply_config(run_path).await?;
|
let previous = self.current_core_readiness_generation();
|
||||||
transaction.commit();
|
let result = self.apply_config(run_path).await;
|
||||||
|
self.retain_applied_runtime(&Config::runtime().await, previous, &result);
|
||||||
|
result?;
|
||||||
|
// Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let _lifecycle = self.lifecycle_lock.lock().await;
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
|
}
|
||||||
Ok(ValidationOutcome::Valid)
|
Ok(ValidationOutcome::Valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retain_applied_runtime(
|
||||||
|
&self,
|
||||||
|
runtime: &clash_verge_draft::Draft<IRuntime>,
|
||||||
|
previous: Option<u64>,
|
||||||
|
result: &Result<()>,
|
||||||
|
) {
|
||||||
|
if result.is_ok()
|
||||||
|
|| self
|
||||||
|
.current_core_readiness_generation()
|
||||||
|
.is_some_and(|current| Some(current) != previous)
|
||||||
|
{
|
||||||
|
runtime.apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies a generated configuration through the active core owner.
|
/// Applies a generated configuration through the active core owner.
|
||||||
async fn apply_config(&self, path: PathBuf) -> Result<()> {
|
async fn apply_config(&self, path: PathBuf) -> Result<()> {
|
||||||
if matches!(*self.get_running_mode(), RunningMode::Service) {
|
if matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||||
@@ -405,6 +474,31 @@ mod tests {
|
|||||||
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
|
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
|
||||||
use std::{cell::Cell, time::Duration};
|
use std::{cell::Cell, time::Duration};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ready_replacement_keeps_the_applied_runtime_when_proxy_restore_fails() -> anyhow::Result<()> {
|
||||||
|
use crate::{config::runtime::IRuntime, core::CoreManager};
|
||||||
|
use clash_verge_draft::{Draft, DraftTransaction};
|
||||||
|
|
||||||
|
for replaced in [false, true] {
|
||||||
|
let manager = CoreManager::default();
|
||||||
|
manager.mark_core_ready();
|
||||||
|
let previous = manager.current_core_readiness_generation();
|
||||||
|
let runtime = Draft::new(IRuntime::default());
|
||||||
|
let transaction = DraftTransaction::begin(vec![&runtime])?;
|
||||||
|
runtime.edit_draft(|draft| {
|
||||||
|
draft.exists_keys.insert("replacement".into());
|
||||||
|
});
|
||||||
|
if replaced {
|
||||||
|
manager.mark_core_ready();
|
||||||
|
}
|
||||||
|
let result = Err(anyhow::anyhow!("system proxy restore failed"));
|
||||||
|
manager.retain_applied_runtime(&runtime, previous, &result);
|
||||||
|
transaction.rollback();
|
||||||
|
assert_eq!(runtime.data_arc().exists_keys.contains("replacement"), replaced);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
|
const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
fn staged() -> StageAttempt {
|
fn staged() -> StageAttempt {
|
||||||
|
|||||||
@@ -334,6 +334,18 @@ enum HandoffOutcome {
|
|||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error {
|
||||||
|
let error = match service_rejection {
|
||||||
|
Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||||
|
None => error,
|
||||||
|
};
|
||||||
|
// Proxy failures have their own pending state and recovery dialog.
|
||||||
|
if SysproxyFailure::from_chain(&error).is_none() {
|
||||||
|
crate::core::notification::record_sidecar_failure(format!("{error:#}").into());
|
||||||
|
}
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
impl CoreManager {
|
impl CoreManager {
|
||||||
async fn rollback_failed_start(&self) {
|
async fn rollback_failed_start(&self) {
|
||||||
proxy_control::stop_guard().await;
|
proxy_control::stop_guard().await;
|
||||||
@@ -364,9 +376,12 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
|
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
|
||||||
pub async fn continue_with_sidecar(&self) -> Result<()> {
|
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
|
||||||
if !self.try_start_config_update() {
|
if !self.try_start_config_update() {
|
||||||
anyhow::bail!("configuration update is already running");
|
return Err(report_sidecar_failure(
|
||||||
|
anyhow::anyhow!("configuration update is already running"),
|
||||||
|
service_rejection,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
defer! {
|
defer! {
|
||||||
self.finish_config_update();
|
self.finish_config_update();
|
||||||
@@ -375,61 +390,68 @@ impl CoreManager {
|
|||||||
// draft without claiming it, so without this it can commit another transaction's staged patch.
|
// draft without claiming it, so without this it can commit another transaction's staged patch.
|
||||||
let config_write = Config::lock_config_write().await;
|
let config_write = Config::lock_config_write().await;
|
||||||
let _life = self.lifecycle_lock.lock().await;
|
let _life = self.lifecycle_lock.lock().await;
|
||||||
let status = SERVICE_MANAGER.current().await;
|
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
|
||||||
tracing::Span::current().record("status", tracing::field::debug(&status));
|
|
||||||
let mode = self.get_running_mode();
|
|
||||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
|
||||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
if matches!(*mode, RunningMode::NotRunning) {
|
|
||||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
|
||||||
}
|
|
||||||
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
|
||||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
|
||||||
}
|
|
||||||
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
|
||||||
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
|
||||||
let prepared = async {
|
|
||||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
|
||||||
if crate::core::runstate::RUN_STATE
|
|
||||||
.state()
|
|
||||||
.tun_should_be_disabled(tun_enabled)
|
|
||||||
{
|
|
||||||
tracing::Span::current().record("tun_disabled", true);
|
|
||||||
Config::disable_tun_and_persist().await?;
|
|
||||||
}
|
|
||||||
Config::generate().await
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
drop(config_write);
|
|
||||||
if let Err(error) = prepared {
|
|
||||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
|
|
||||||
proxy_control::stop_guard().await;
|
|
||||||
let result = async {
|
let result = async {
|
||||||
self.start_core_inner().await?;
|
let status = SERVICE_MANAGER.current().await;
|
||||||
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
tracing::Span::current().record("status", tracing::field::debug(&status));
|
||||||
|| self.current_core_readiness_generation().is_none()
|
let mode = self.get_running_mode();
|
||||||
{
|
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||||
anyhow::bail!("Sidecar did not become ready");
|
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||||
}
|
}
|
||||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
#[cfg(target_os = "windows")]
|
||||||
self.apply_proxy_after_start().await
|
if matches!(*mode, RunningMode::NotRunning) {
|
||||||
|
clash_verge_service_ipc::execution::check_sidecar_available()
|
||||||
|
.await
|
||||||
|
.map_err(super::state::explain_core_occupancy)?;
|
||||||
|
}
|
||||||
|
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
||||||
|
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||||
|
}
|
||||||
|
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
||||||
|
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||||
|
let prepared = async {
|
||||||
|
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||||
|
if crate::core::runstate::RUN_STATE
|
||||||
|
.state()
|
||||||
|
.tun_should_be_disabled(tun_enabled)
|
||||||
|
{
|
||||||
|
tracing::Span::current().record("tun_disabled", true);
|
||||||
|
Config::disable_tun_and_persist().await?;
|
||||||
|
}
|
||||||
|
Config::generate().await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
drop(config_write);
|
||||||
|
if let Err(error) = prepared {
|
||||||
|
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
|
||||||
|
proxy_control::stop_guard().await;
|
||||||
|
let result = async {
|
||||||
|
self.start_core_inner().await?;
|
||||||
|
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
||||||
|
|| self.current_core_readiness_generation().is_none()
|
||||||
|
{
|
||||||
|
anyhow::bail!("Sidecar did not become ready");
|
||||||
|
}
|
||||||
|
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||||
|
self.apply_proxy_after_start().await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
||||||
|
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||||
|
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||||
|
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||||
|
.context("failed to clear the proxy before rolling back"));
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
if let Err(error) = result {
|
result.map_err(|error| report_sidecar_failure(error, service_rejection))
|
||||||
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
|
||||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
|
||||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
|
||||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
|
||||||
.context("failed to clear the proxy before rolling back"));
|
|
||||||
}
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
|
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
|
||||||
@@ -495,7 +517,13 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
|
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
|
||||||
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
|
let result =
|
||||||
|
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
|
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
|
||||||
@@ -524,6 +552,8 @@ impl CoreManager {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
|
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
|
||||||
})?;
|
})?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
// At login the app usually beats the network. Leave the write to the network watcher
|
// At login the app usually beats the network. Leave the write to the network watcher
|
||||||
// rather than fail — only if the watcher is actually live; a user toggling while
|
// rather than fail — only if the watcher is actually live; a user toggling while
|
||||||
// offline still fails fast.
|
// offline still fails fast.
|
||||||
@@ -572,6 +602,7 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
// tell the window when a background apply lands
|
// tell the window when a background apply lands
|
||||||
Handle::refresh_verge();
|
Handle::refresh_verge();
|
||||||
|
crate::core::notification::retire_sidecar_failure();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +625,8 @@ impl CoreManager {
|
|||||||
tracing::Span::current().record("decision", tracing::field::debug(&startup));
|
tracing::Span::current().record("decision", tracing::field::debug(&startup));
|
||||||
if matches!(startup, StartupDecision::Wait) {
|
if matches!(startup, StartupDecision::Wait) {
|
||||||
self.rollback_failed_start().await;
|
self.rollback_failed_start().await;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
self.try_sidecar_after_service_unavailable().await?;
|
self.try_sidecar_after_service_unavailable().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -609,7 +642,11 @@ impl CoreManager {
|
|||||||
{
|
{
|
||||||
run_service_start_with_sidecar_fallback(
|
run_service_start_with_sidecar_fallback(
|
||||||
|| self.start_core_by_service(),
|
|| self.start_core_by_service(),
|
||||||
clash_verge_service_ipc::execution::check_sidecar_available,
|
|| async {
|
||||||
|
clash_verge_service_ipc::execution::check_sidecar_available()
|
||||||
|
.await
|
||||||
|
.map_err(super::state::explain_core_occupancy)
|
||||||
|
},
|
||||||
|error| self.start_sidecar_after_service_failure(error),
|
|error| self.start_sidecar_after_service_failure(error),
|
||||||
|reason| {
|
|reason| {
|
||||||
crate::core::runstate::RUN_STATE
|
crate::core::runstate::RUN_STATE
|
||||||
@@ -628,6 +665,8 @@ impl CoreManager {
|
|||||||
// A failed start must not leave a locally reported running mode.
|
// A failed start must not leave a locally reported running mode.
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
self.rollback_failed_start().await;
|
self.rollback_failed_start().await;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,7 +691,10 @@ impl CoreManager {
|
|||||||
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
|
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
|
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available()
|
||||||
|
.await
|
||||||
|
.map_err(super::state::explain_core_occupancy)
|
||||||
|
{
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
@@ -716,11 +758,17 @@ impl CoreManager {
|
|||||||
|
|
||||||
async fn stop_core_unprepared_inner(&self) -> Result<()> {
|
async fn stop_core_unprepared_inner(&self) -> Result<()> {
|
||||||
CLASH_LOGGER.clear_logs();
|
CLASH_LOGGER.clear_logs();
|
||||||
match *self.get_running_mode() {
|
let result = match *self.get_running_mode() {
|
||||||
RunningMode::Service => self.stop_core_by_service().await,
|
RunningMode::Service => self.stop_core_by_service().await,
|
||||||
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
|
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
|
||||||
RunningMode::NotRunning => Ok(()),
|
RunningMode::NotRunning => Ok(()),
|
||||||
|
};
|
||||||
|
// A Sidecar whose exit wait failed is already marked stopped; a core still running keeps its DNS.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
}
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info")]
|
#[tracing::instrument(skip_all, level = "info")]
|
||||||
@@ -802,7 +850,12 @@ impl CoreManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if crate::core::runstate::RUN_STATE.state().service_usable() {
|
let state = crate::core::runstate::RUN_STATE.state();
|
||||||
|
// Waiting cannot bring up a service nothing will start.
|
||||||
|
if state.service_usable()
|
||||||
|
|| matches!(&state.health, crate::core::runstate::ServiceHealth::Unavailable(reason)
|
||||||
|
if crate::core::service::is_not_auto_started(reason))
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,29 +893,34 @@ impl CoreManager {
|
|||||||
AsyncHandler::spawn(|| async move {
|
AsyncHandler::spawn(|| async move {
|
||||||
let manager = Self::global();
|
let manager = Self::global();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
loop {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
|
let attempts = std::num::NonZeroUsize::MIN.saturating_add(
|
||||||
|
(timing::SERVICE_HANDOFF_WINDOW.as_millis() / timing::SERVICE_HANDOFF_INTERVAL.as_millis()) as usize,
|
||||||
|
);
|
||||||
|
let _ = retry(RetryPolicy::fixed(attempts, std::time::Duration::ZERO), |_| async {
|
||||||
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
|
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
"service handoff window elapsed; staying in sidecar mode"
|
"service handoff window elapsed; staying in sidecar mode"
|
||||||
);
|
);
|
||||||
break;
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// The window is checked before waiting, including the final handoff attempt.
|
||||||
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
|
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
|
||||||
|
|
||||||
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
|
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
|
||||||
break;
|
return Ok(());
|
||||||
}
|
}
|
||||||
match manager.try_handoff_sidecar_to_service().await {
|
match manager.try_handoff_sidecar_to_service().await {
|
||||||
HandoffOutcome::Done => break,
|
HandoffOutcome::Done => Ok(()),
|
||||||
HandoffOutcome::Failed => {
|
HandoffOutcome::Failed => {
|
||||||
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
|
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
|
||||||
break;
|
Ok(())
|
||||||
}
|
}
|
||||||
HandoffOutcome::NotReady => {}
|
HandoffOutcome::NotReady => Err(RetryError::Retry(())),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
manager.handoff_watcher_running.store(false, Ordering::Release);
|
manager.handoff_watcher_running.store(false, Ordering::Release);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
pub(crate) use config::ConfigUpdateGuard;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||||
use clash_verge_logging::{LogRing, Type, logging};
|
use clash_verge_logging::{LogRing, Type, logging};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::{
|
use std::{
|
||||||
fmt,
|
fmt,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc, LazyLock,
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
},
|
},
|
||||||
time::Instant,
|
time::Instant,
|
||||||
@@ -21,7 +21,7 @@ use crate::singleton;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use std::os::windows::io::OwnedHandle;
|
use std::os::windows::io::OwnedHandle;
|
||||||
|
|
||||||
pub(crate) static CLASH_LOGGER: Lazy<Arc<LogRing>> = Lazy::new(|| Arc::new(LogRing::new()));
|
pub(crate) static CLASH_LOGGER: LazyLock<Arc<LogRing>> = LazyLock::new(|| Arc::new(LogRing::new()));
|
||||||
|
|
||||||
tokio::task_local! {
|
tokio::task_local! {
|
||||||
static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
|
static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
|
||||||
@@ -237,14 +237,14 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_core_readiness_generation(&self) -> Option<u64> {
|
pub(crate) fn current_core_readiness_generation(&self) -> Option<u64> {
|
||||||
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
|
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn invalidate_core_readiness(&self) {
|
pub(crate) fn invalidate_core_readiness(&self) {
|
||||||
let _ = self
|
let _ = self
|
||||||
.core_readiness_state
|
.core_readiness_state
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||||
active_core_readiness_generation(current).map(inactive_core_readiness_state)
|
active_core_readiness_generation(current).map(inactive_core_readiness_state)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,48 +284,56 @@ impl CoreManager {
|
|||||||
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
|
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut retries = 0;
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
loop {
|
retry(
|
||||||
match self.start_core().await {
|
RetryPolicy::fixed(
|
||||||
Ok(()) => {
|
std::num::NonZeroUsize::MIN.saturating_add(MAX_PORT_FALLBACK_RETRIES),
|
||||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
std::time::Duration::ZERO,
|
||||||
return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning));
|
),
|
||||||
}
|
|retries| async move {
|
||||||
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
|
match self.start_core().await {
|
||||||
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
Ok(()) => {
|
||||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||||
return Err(start_error);
|
Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning))
|
||||||
}
|
}
|
||||||
match crate::config::Config::resolve_startup_mixed_port().await {
|
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
|
||||||
Ok(true) => {
|
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||||
retries += 1;
|
|
||||||
tracing::Span::current().record("retries", retries);
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Core,
|
|
||||||
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
|
|
||||||
retries,
|
|
||||||
MAX_PORT_FALLBACK_RETRIES
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||||
return Err(start_error);
|
return Err(RetryError::Stop(start_error));
|
||||||
}
|
}
|
||||||
Err(fallback_error) => {
|
match crate::config::Config::resolve_startup_mixed_port().await {
|
||||||
crate::config::Config::block_startup_core(&fallback_error);
|
Ok(true) => {
|
||||||
return Err(start_error.context(format!(
|
let retries = retries + 1;
|
||||||
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
|
tracing::Span::current().record("retries", retries);
|
||||||
)));
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
|
||||||
|
retries,
|
||||||
|
MAX_PORT_FALLBACK_RETRIES
|
||||||
|
);
|
||||||
|
Err(RetryError::Retry(start_error))
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||||
|
Err(RetryError::Stop(start_error))
|
||||||
|
}
|
||||||
|
Err(fallback_error) => {
|
||||||
|
crate::config::Config::block_startup_core(&fallback_error);
|
||||||
|
Err(RetryError::Stop(start_error.context(format!(
|
||||||
|
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
|
||||||
|
))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||||
|
Err(RetryError::Stop(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
},
|
||||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
)
|
||||||
return Err(error);
|
.await
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::claim_core_readiness_generation;
|
use super::claim_core_readiness_generation;
|
||||||
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
|
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
|
||||||
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
use crate::{
|
use crate::{
|
||||||
AsyncHandler,
|
AsyncHandler,
|
||||||
config::Config,
|
config::Config,
|
||||||
@@ -11,10 +12,20 @@ use crate::{
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use clash_verge_logging::Type;
|
use clash_verge_logging::Type;
|
||||||
use log::Level;
|
use log::Level;
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri_plugin_mihomo::MihomoExt as _;
|
use tauri_plugin_mihomo::MihomoExt as _;
|
||||||
use tauri_plugin_shell::ShellExt as _;
|
use tauri_plugin_shell::ShellExt as _;
|
||||||
|
|
||||||
|
pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
|
||||||
|
let detail = error.to_string();
|
||||||
|
if detail.starts_with("process verge-mihomo") && detail.ends_with("; refusing a second core") {
|
||||||
|
error.context(clash_verge_i18n::t!("core.alreadyRunning").into_owned())
|
||||||
|
} else {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
|
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
@@ -41,14 +52,15 @@ async fn retry_service_start<Start, StartFuture>(
|
|||||||
mut start: Start,
|
mut start: Start,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
Start: FnMut() -> StartFuture,
|
Start: FnMut() -> StartFuture + Send,
|
||||||
StartFuture: std::future::Future<Output = Result<()>>,
|
StartFuture: std::future::Future<Output = Result<()>> + Send,
|
||||||
{
|
{
|
||||||
let mut last_error = None;
|
let attempts = NonZeroUsize::new(attempts).ok_or_else(|| anyhow::anyhow!("service start failed"))?;
|
||||||
for attempt in 0..attempts {
|
retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
|
||||||
match start().await {
|
// The Windows Service IPC future would otherwise inflate every enclosing lifecycle future.
|
||||||
Ok(()) => return Ok(()),
|
let future = Box::pin(start());
|
||||||
Err(error) => {
|
async move {
|
||||||
|
future.await.map_err(|error| {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
@@ -60,16 +72,14 @@ where
|
|||||||
.downcast_ref::<service::ServiceStartRefusal>()
|
.downcast_ref::<service::ServiceStartRefusal>()
|
||||||
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
|
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
|
||||||
{
|
{
|
||||||
return Err(error);
|
RetryError::Stop(error)
|
||||||
|
} else {
|
||||||
|
RetryError::Retry(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")))
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoreManager {
|
impl CoreManager {
|
||||||
@@ -95,14 +105,16 @@ async fn poll_sidecar_readiness<F, Fut>(
|
|||||||
mut probe: F,
|
mut probe: F,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnMut() -> Fut,
|
F: FnMut() -> Fut + Send,
|
||||||
Fut: std::future::Future<Output = Result<()>>,
|
Fut: std::future::Future<Output = Result<()>> + Send,
|
||||||
{
|
{
|
||||||
let mut last_error = None;
|
let attempts = NonZeroUsize::new(max_attempts)
|
||||||
for attempt in 0..max_attempts {
|
.context("sidecar readiness was configured with no attempts")
|
||||||
match probe().await {
|
.context("Mihomo API did not become ready")?;
|
||||||
Ok(()) => return Ok(()),
|
retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
|
||||||
Err(error) => {
|
let future = probe();
|
||||||
|
async move {
|
||||||
|
future.await.map_err(|error| {
|
||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
@@ -110,16 +122,12 @@ where
|
|||||||
attempt + 1,
|
attempt + 1,
|
||||||
max_attempts
|
max_attempts
|
||||||
);
|
);
|
||||||
last_error = Some(error);
|
RetryError::Retry(error)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
if attempt + 1 < max_attempts {
|
})
|
||||||
tokio::time::sleep(retry_delay).await;
|
.await
|
||||||
}
|
.context("Mihomo API did not become ready")
|
||||||
}
|
|
||||||
Err(last_error
|
|
||||||
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
|
|
||||||
.context("Mihomo API did not become ready"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
|
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
|
||||||
@@ -155,7 +163,8 @@ impl CoreManager {
|
|||||||
self.wait_for_sidecar_exit().await?;
|
self.wait_for_sidecar_exit().await?;
|
||||||
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
|
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
|
||||||
.await
|
.await
|
||||||
.inspect_err(service::record_residual_service)?;
|
.inspect_err(service::record_residual_service)
|
||||||
|
.map_err(explain_core_occupancy)?;
|
||||||
self.core_stopped();
|
self.core_stopped();
|
||||||
|
|
||||||
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
||||||
@@ -248,7 +257,7 @@ impl CoreManager {
|
|||||||
match event {
|
match event {
|
||||||
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
||||||
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
||||||
let message = String::from_utf8_lossy(&line).into_owned();
|
let message = String::from_utf8_lossy_owned(line);
|
||||||
Logger::global().writer_sidecar_log(Level::Error, &message);
|
Logger::global().writer_sidecar_log(Level::Error, &message);
|
||||||
CLASH_LOGGER.append_log(message);
|
CLASH_LOGGER.append_log(message);
|
||||||
}
|
}
|
||||||
@@ -394,6 +403,8 @@ impl CoreManager {
|
|||||||
self.set_job_handle(None);
|
self.set_job_handle(None);
|
||||||
proxy_control::stop_guard().await;
|
proxy_control::stop_guard().await;
|
||||||
self.core_stopped();
|
self.core_stopped();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +521,20 @@ mod readiness_tests {
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_occupancy_explanation_preserves_the_original_error() {
|
||||||
|
for detail in [
|
||||||
|
"process verge-mihomo remains after IPC failure; refusing a second core",
|
||||||
|
"process verge-mihomo-alpha.exe (PID 123) is still running; refusing a second core",
|
||||||
|
] {
|
||||||
|
let error = super::explain_core_occupancy(anyhow::anyhow!(detail));
|
||||||
|
assert_eq!(error.to_string(), clash_verge_i18n::t!("core.alreadyRunning"));
|
||||||
|
assert_eq!(error.root_cause().to_string(), detail);
|
||||||
|
}
|
||||||
|
let error = super::explain_core_occupancy(anyhow::anyhow!("could not inspect remaining cores"));
|
||||||
|
assert_eq!(error.to_string(), "could not inspect remaining cores");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
|
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
|
||||||
let attempts = Arc::new(AtomicUsize::new(0));
|
let attempts = Arc::new(AtomicUsize::new(0));
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
pub mod auto_backup;
|
||||||
pub mod autostart;
|
pub mod autostart;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod handle;
|
|
||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
|
pub mod lightweight;
|
||||||
pub mod listener;
|
pub mod listener;
|
||||||
pub mod logger;
|
pub mod logger;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub mod network_watch;
|
pub mod network_watch;
|
||||||
pub mod notification;
|
pub mod notify;
|
||||||
pub(crate) mod owner_identity;
|
pub(crate) mod owner_identity;
|
||||||
pub mod proxy_control;
|
pub mod proxy_control;
|
||||||
pub mod proxy_view;
|
pub mod proxy_view;
|
||||||
@@ -22,3 +23,4 @@ pub mod validate;
|
|||||||
pub mod win_uwp;
|
pub mod win_uwp;
|
||||||
|
|
||||||
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
|
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
|
||||||
|
pub use notify::{handle, notification};
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use super::{handle::Handle, notification::FrontendEvent};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Refresh {
|
||||||
|
Clash,
|
||||||
|
Verge,
|
||||||
|
Profiles,
|
||||||
|
Proxies,
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFRESH_WINDOW: Duration = Duration::from_millis(20);
|
||||||
|
static BATCH: RefreshBatch = RefreshBatch(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RefreshBatch(Mutex<Vec<Refresh>>);
|
||||||
|
|
||||||
|
impl RefreshBatch {
|
||||||
|
fn enqueue(&self, refresh: Refresh) -> bool {
|
||||||
|
let mut pending = self.0.lock();
|
||||||
|
let start_window = pending.is_empty();
|
||||||
|
if !pending.contains(&refresh) {
|
||||||
|
pending.push(refresh);
|
||||||
|
}
|
||||||
|
start_window
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flush(&self, emit: impl Fn(Refresh)) {
|
||||||
|
// A fixed window bounds latency even under a continuous stream of refreshes.
|
||||||
|
tokio::time::sleep(REFRESH_WINDOW).await;
|
||||||
|
let refreshes = std::mem::take(&mut *self.0.lock());
|
||||||
|
for refresh in refreshes {
|
||||||
|
emit(refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn announce(refresh: Refresh) {
|
||||||
|
if BATCH.enqueue(refresh) {
|
||||||
|
crate::AsyncHandler::spawn(|| BATCH.flush(emit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(refresh: Refresh) {
|
||||||
|
Handle::send_event(match refresh {
|
||||||
|
Refresh::Clash => FrontendEvent::RefreshClash,
|
||||||
|
Refresh::Verge => FrontendEvent::RefreshVerge,
|
||||||
|
Refresh::Profiles => FrontendEvent::RefreshProfiles,
|
||||||
|
Refresh::Proxies => FrontendEvent::RefreshProxyConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> anyhow::Result<()> {
|
||||||
|
let batch = RefreshBatch::default();
|
||||||
|
let delivered = Mutex::new(Vec::new());
|
||||||
|
assert!(batch.enqueue(Refresh::Clash));
|
||||||
|
for refresh in [Refresh::Verge, Refresh::Clash, Refresh::Profiles, Refresh::Proxies] {
|
||||||
|
assert!(!batch.enqueue(refresh));
|
||||||
|
}
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
batch
|
||||||
|
.flush(|refresh| {
|
||||||
|
delivered.lock().push(refresh);
|
||||||
|
if refresh == Refresh::Clash {
|
||||||
|
assert!(batch.enqueue(Refresh::Clash));
|
||||||
|
assert!(!batch.enqueue(Refresh::Clash));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
batch.flush(|refresh| delivered.lock().push(refresh)).await;
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
*delivered.lock(),
|
||||||
|
[
|
||||||
|
Refresh::Clash,
|
||||||
|
Refresh::Verge,
|
||||||
|
Refresh::Profiles,
|
||||||
|
Refresh::Proxies,
|
||||||
|
Refresh::Clash
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(batch.enqueue(Refresh::Verge));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use super::NoticeStatus;
|
||||||
use crate::{APP_HANDLE, singleton};
|
use crate::{APP_HANDLE, singleton};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
||||||
|
|
||||||
@@ -36,19 +40,11 @@ impl Handle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_clash() {
|
pub fn refresh_clash() {
|
||||||
Self::send_event(FrontendEvent::RefreshClash);
|
super::announce(super::Refresh::Clash);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_verge() {
|
pub fn refresh_verge() {
|
||||||
Self::send_event(FrontendEvent::RefreshVerge);
|
super::announce(super::Refresh::Verge);
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_profiles() {
|
|
||||||
Self::send_event(FrontendEvent::RefreshProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_proxy_config() {
|
|
||||||
Self::send_event(FrontendEvent::RefreshProxyConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a Run State snapshot to the frontend.
|
/// Push a Run State snapshot to the frontend.
|
||||||
@@ -80,13 +76,10 @@ impl Handle {
|
|||||||
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notice_message<S: AsRef<str>, M: Into<String>>(status: S, msg: M) {
|
pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
|
||||||
let status_str = status.as_ref();
|
|
||||||
let msg_str = msg.into();
|
|
||||||
|
|
||||||
Self::send_event(FrontendEvent::NoticeMessage {
|
Self::send_event(FrontendEvent::NoticeMessage {
|
||||||
status: status_str,
|
status,
|
||||||
message: msg_str,
|
message: message.into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +95,7 @@ impl Handle {
|
|||||||
self.is_exiting.load(Ordering::Acquire)
|
self.is_exiting.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_event(event: FrontendEvent) {
|
pub(super) fn send_event(event: FrontendEvent) {
|
||||||
let handle = Self::global();
|
let handle = Self::global();
|
||||||
if handle.is_exiting() {
|
if handle.is_exiting() {
|
||||||
return;
|
return;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
mod announce;
|
||||||
|
pub use announce::{Refresh, announce};
|
||||||
|
pub mod desktop;
|
||||||
|
pub mod handle;
|
||||||
|
pub mod notification;
|
||||||
|
pub use notification::frontend_wire_contract;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub enum NoticeStatus {
|
||||||
|
#[serde(rename = "info")]
|
||||||
|
Info,
|
||||||
|
#[serde(rename = "import_sub_url::ok")]
|
||||||
|
ImportSubUrlOk,
|
||||||
|
#[serde(rename = "import_sub_url::error")]
|
||||||
|
ImportSubUrlError,
|
||||||
|
#[serde(rename = "set_config::error")]
|
||||||
|
SetConfigError,
|
||||||
|
#[serde(rename = "core_start::error")]
|
||||||
|
CoreStartError,
|
||||||
|
#[serde(rename = "service_core::repair_required")]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
ServiceCoreRepairRequired,
|
||||||
|
#[serde(rename = "service_core::app_data_not_owned")]
|
||||||
|
ServiceCoreAppDataNotOwned,
|
||||||
|
#[serde(rename = "service_core::sidecar_fallback")]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
ServiceCoreSidecarFallback,
|
||||||
|
#[serde(rename = "dns_override::auto_disabled")]
|
||||||
|
DnsOverrideAutoDisabled,
|
||||||
|
#[serde(rename = "enhance::discarded_keys")]
|
||||||
|
EnhanceDiscardedKeys,
|
||||||
|
#[serde(rename = "tun_mode::auto_disabled")]
|
||||||
|
TunModeAutoDisabled,
|
||||||
|
#[serde(rename = "tun_mode::auto_disable_failed")]
|
||||||
|
TunModeAutoDisableFailed,
|
||||||
|
#[serde(rename = "app_restart::core_stop_failed")]
|
||||||
|
AppRestartCoreStopFailed,
|
||||||
|
#[serde(rename = "app_quit::core_stop_failed")]
|
||||||
|
AppQuitCoreStopFailed,
|
||||||
|
#[serde(rename = "update_with_clash_proxy")]
|
||||||
|
UpdateWithClashProxy,
|
||||||
|
#[serde(rename = "update_failed_even_with_clash")]
|
||||||
|
UpdateFailedEvenWithClash,
|
||||||
|
#[serde(rename = "reactivate_profiles::error")]
|
||||||
|
ReactivateProfilesError,
|
||||||
|
#[serde(rename = "update_failed")]
|
||||||
|
UpdateFailed,
|
||||||
|
#[serde(rename = "config_validate::boot_error")]
|
||||||
|
ConfigValidateBootError,
|
||||||
|
#[serde(rename = "config_validate::error")]
|
||||||
|
ConfigValidateError,
|
||||||
|
#[serde(rename = "config_validate::process_terminated")]
|
||||||
|
ConfigValidateProcessTerminated,
|
||||||
|
#[serde(rename = "config_validate::script_error")]
|
||||||
|
ConfigValidateScriptError,
|
||||||
|
#[serde(rename = "config_validate::script_syntax_error")]
|
||||||
|
ConfigValidateScriptSyntaxError,
|
||||||
|
#[serde(rename = "config_validate::script_missing_main")]
|
||||||
|
ConfigValidateScriptMissingMain,
|
||||||
|
#[serde(rename = "config_validate::file_not_found")]
|
||||||
|
ConfigValidateFileNotFound,
|
||||||
|
#[serde(rename = "config_validate::yaml_syntax_error")]
|
||||||
|
ConfigValidateYamlSyntaxError,
|
||||||
|
#[serde(rename = "config_validate::yaml_read_error")]
|
||||||
|
ConfigValidateYamlReadError,
|
||||||
|
#[serde(rename = "config_validate::yaml_mapping_error")]
|
||||||
|
ConfigValidateYamlMappingError,
|
||||||
|
#[serde(rename = "config_validate::merge_syntax_error")]
|
||||||
|
ConfigValidateMergeSyntaxError,
|
||||||
|
#[serde(rename = "config_validate::merge_mapping_error")]
|
||||||
|
ConfigValidateMergeMappingError,
|
||||||
|
#[serde(rename = "config_core::change_success")]
|
||||||
|
ConfigCoreChangeSuccess,
|
||||||
|
#[serde(rename = "config_core::change_error")]
|
||||||
|
ConfigCoreChangeError,
|
||||||
|
#[serde(rename = "mixed_port::fallback")]
|
||||||
|
MixedPortFallback,
|
||||||
|
#[serde(rename = "mixed_port::fallback_error")]
|
||||||
|
MixedPortFallbackError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoticeStatus {
|
||||||
|
/// Every variant in golden order. The wire-contract export and the golden
|
||||||
|
/// test both derive their variant list from here; expected strings stay
|
||||||
|
/// hand-written in the test so renames cannot slip through.
|
||||||
|
pub const ALL: [Self; 34] = [
|
||||||
|
Self::Info,
|
||||||
|
Self::ImportSubUrlOk,
|
||||||
|
Self::ImportSubUrlError,
|
||||||
|
Self::SetConfigError,
|
||||||
|
Self::CoreStartError,
|
||||||
|
Self::ServiceCoreRepairRequired,
|
||||||
|
Self::ServiceCoreAppDataNotOwned,
|
||||||
|
Self::ServiceCoreSidecarFallback,
|
||||||
|
Self::DnsOverrideAutoDisabled,
|
||||||
|
Self::EnhanceDiscardedKeys,
|
||||||
|
Self::TunModeAutoDisabled,
|
||||||
|
Self::TunModeAutoDisableFailed,
|
||||||
|
Self::AppRestartCoreStopFailed,
|
||||||
|
Self::AppQuitCoreStopFailed,
|
||||||
|
Self::UpdateWithClashProxy,
|
||||||
|
Self::UpdateFailedEvenWithClash,
|
||||||
|
Self::ReactivateProfilesError,
|
||||||
|
Self::UpdateFailed,
|
||||||
|
Self::ConfigValidateBootError,
|
||||||
|
Self::ConfigValidateError,
|
||||||
|
Self::ConfigValidateProcessTerminated,
|
||||||
|
Self::ConfigValidateScriptError,
|
||||||
|
Self::ConfigValidateScriptSyntaxError,
|
||||||
|
Self::ConfigValidateScriptMissingMain,
|
||||||
|
Self::ConfigValidateFileNotFound,
|
||||||
|
Self::ConfigValidateYamlSyntaxError,
|
||||||
|
Self::ConfigValidateYamlReadError,
|
||||||
|
Self::ConfigValidateYamlMappingError,
|
||||||
|
Self::ConfigValidateMergeSyntaxError,
|
||||||
|
Self::ConfigValidateMergeMappingError,
|
||||||
|
Self::ConfigCoreChangeSuccess,
|
||||||
|
Self::ConfigCoreChangeError,
|
||||||
|
Self::MixedPortFallback,
|
||||||
|
Self::MixedPortFallbackError,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::NoticeStatus;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notice_status_wire_contract() -> Result<(), serde_json::Error> {
|
||||||
|
let statuses = NoticeStatus::ALL;
|
||||||
|
let expected = [
|
||||||
|
"info",
|
||||||
|
"import_sub_url::ok",
|
||||||
|
"import_sub_url::error",
|
||||||
|
"set_config::error",
|
||||||
|
"core_start::error",
|
||||||
|
"service_core::repair_required",
|
||||||
|
"service_core::app_data_not_owned",
|
||||||
|
"service_core::sidecar_fallback",
|
||||||
|
"dns_override::auto_disabled",
|
||||||
|
"enhance::discarded_keys",
|
||||||
|
"tun_mode::auto_disabled",
|
||||||
|
"tun_mode::auto_disable_failed",
|
||||||
|
"app_restart::core_stop_failed",
|
||||||
|
"app_quit::core_stop_failed",
|
||||||
|
"update_with_clash_proxy",
|
||||||
|
"update_failed_even_with_clash",
|
||||||
|
"reactivate_profiles::error",
|
||||||
|
"update_failed",
|
||||||
|
"config_validate::boot_error",
|
||||||
|
"config_validate::error",
|
||||||
|
"config_validate::process_terminated",
|
||||||
|
"config_validate::script_error",
|
||||||
|
"config_validate::script_syntax_error",
|
||||||
|
"config_validate::script_missing_main",
|
||||||
|
"config_validate::file_not_found",
|
||||||
|
"config_validate::yaml_syntax_error",
|
||||||
|
"config_validate::yaml_read_error",
|
||||||
|
"config_validate::yaml_mapping_error",
|
||||||
|
"config_validate::merge_syntax_error",
|
||||||
|
"config_validate::merge_mapping_error",
|
||||||
|
"config_core::change_success",
|
||||||
|
"config_core::change_error",
|
||||||
|
"mixed_port::fallback",
|
||||||
|
"mixed_port::fallback_error",
|
||||||
|
];
|
||||||
|
let actual: Vec<_> = statuses
|
||||||
|
.into_iter()
|
||||||
|
.map(serde_json::to_value)
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
assert_eq!(actual, expected.map(serde_json::Value::from));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
|
use super::NoticeStatus;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{
|
use std::sync::Arc;
|
||||||
collections::HashMap,
|
use std::sync::LazyLock;
|
||||||
future::Future,
|
use std::{collections::HashMap, future::Future};
|
||||||
sync::atomic::{AtomicU64, Ordering},
|
|
||||||
};
|
|
||||||
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
|
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum FrontendEvent<'a> {
|
pub enum FrontendEvent<'a> {
|
||||||
RefreshClash,
|
RefreshClash,
|
||||||
RefreshVerge,
|
RefreshVerge,
|
||||||
RefreshProfiles,
|
RefreshProfiles,
|
||||||
RefreshProxyConfig,
|
RefreshProxyConfig,
|
||||||
NoticeMessage {
|
NoticeMessage {
|
||||||
status: &'a str,
|
status: NoticeStatus,
|
||||||
message: String,
|
message: Arc<str>,
|
||||||
},
|
},
|
||||||
ProfileChanged {
|
ProfileChanged {
|
||||||
current_profile_id: &'a String,
|
current_profile_id: &'a String,
|
||||||
@@ -89,16 +87,23 @@ pub struct PendingFailure {
|
|||||||
/// Non-destructive pending state, indexed by stable code.
|
/// Non-destructive pending state, indexed by stable code.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct FailureTable {
|
struct FailureTable {
|
||||||
entries: Mutex<HashMap<String, PendingFailure>>,
|
entries: Mutex<FailureEntries>,
|
||||||
sequence: AtomicU64,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct FailureEntries {
|
||||||
|
map: HashMap<String, PendingFailure>,
|
||||||
|
sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FailureTable {
|
impl FailureTable {
|
||||||
fn record(&self, operation: FailedOperation, code: &str, detail: String) {
|
fn record(&self, operation: FailedOperation, code: &str, detail: String) {
|
||||||
// Sequence assignment and replacement must share one ordering lock.
|
// Sequence assignment and replacement must share one ordering lock.
|
||||||
let mut entries = self.entries.lock();
|
let mut entries = self.entries.lock();
|
||||||
|
entries.sequence = entries.sequence.wrapping_add(1);
|
||||||
// Preserve an unanswered request over a later restore.
|
// Preserve an unanswered request over a later restore.
|
||||||
let operation = entries
|
let operation = entries
|
||||||
|
.map
|
||||||
.get(code)
|
.get(code)
|
||||||
.filter(|existing| existing.operation.outranks(operation))
|
.filter(|existing| existing.operation.outranks(operation))
|
||||||
.map_or(operation, |existing| existing.operation);
|
.map_or(operation, |existing| existing.operation);
|
||||||
@@ -106,13 +111,13 @@ impl FailureTable {
|
|||||||
code: code.into(),
|
code: code.into(),
|
||||||
detail,
|
detail,
|
||||||
operation,
|
operation,
|
||||||
sequence: self.sequence.fetch_add(1, Ordering::AcqRel).wrapping_add(1),
|
sequence: entries.sequence,
|
||||||
};
|
};
|
||||||
entries.insert(code.into(), failure);
|
entries.map.insert(code.into(), failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(&self) -> Vec<PendingFailure> {
|
fn snapshot(&self) -> Vec<PendingFailure> {
|
||||||
let mut failures: Vec<PendingFailure> = self.entries.lock().values().cloned().collect();
|
let mut failures: Vec<PendingFailure> = self.entries.lock().map.values().cloned().collect();
|
||||||
failures.sort_by_key(|failure| failure.sequence);
|
failures.sort_by_key(|failure| failure.sequence);
|
||||||
failures
|
failures
|
||||||
}
|
}
|
||||||
@@ -120,17 +125,21 @@ impl FailureTable {
|
|||||||
/// Return whether a guard failure was retired.
|
/// Return whether a guard failure was retired.
|
||||||
fn retire_guard(&self) -> bool {
|
fn retire_guard(&self) -> bool {
|
||||||
let mut entries = self.entries.lock();
|
let mut entries = self.entries.lock();
|
||||||
let before = entries.len();
|
let before = entries.map.len();
|
||||||
entries.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
|
entries
|
||||||
before != entries.len()
|
.map
|
||||||
|
.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
|
||||||
|
before != entries.map.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether any proxy failure was retired.
|
/// Return whether any proxy failure was retired.
|
||||||
fn retire_system_proxy(&self, asked: FailedOperation) -> bool {
|
fn retire_system_proxy(&self, asked: FailedOperation) -> bool {
|
||||||
let mut entries = self.entries.lock();
|
let mut entries = self.entries.lock();
|
||||||
let before = entries.len();
|
let before = entries.map.len();
|
||||||
entries.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
|
entries
|
||||||
before != entries.len()
|
.map
|
||||||
|
.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
|
||||||
|
before != entries.map.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +238,47 @@ pub fn what_was_asked() -> FailedOperation {
|
|||||||
.unwrap_or(FailedOperation::SystemProxyRestore)
|
.unwrap_or(FailedOperation::SystemProxyRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
|
static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::default);
|
||||||
|
|
||||||
|
/// A removal has a revision too, so a delayed read cannot restore a resolved failure.
|
||||||
|
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||||
|
pub struct SidecarFailureSnapshot {
|
||||||
|
pub revision: u64,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SidecarFailureSnapshot {
|
||||||
|
fn record(&mut self, detail: String) {
|
||||||
|
self.revision += 1;
|
||||||
|
self.detail = Some(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover(&mut self) -> bool {
|
||||||
|
if self.detail.take().is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.revision += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static SIDECAR_FAILURE: LazyLock<Mutex<SidecarFailureSnapshot>> = LazyLock::new(Mutex::default);
|
||||||
|
|
||||||
|
pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot {
|
||||||
|
SIDECAR_FAILURE.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_sidecar_failure(detail: String) {
|
||||||
|
SIDECAR_FAILURE.lock().record(detail);
|
||||||
|
notify_pending_failures_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retire_sidecar_failure() {
|
||||||
|
let changed = SIDECAR_FAILURE.lock().recover();
|
||||||
|
if changed {
|
||||||
|
notify_pending_failures_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
|
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
|
||||||
PENDING_FAILURES.record(operation, code, detail.into());
|
PENDING_FAILURES.record(operation, code, detail.into());
|
||||||
@@ -237,7 +286,7 @@ pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_pending_failure(code: &str) -> bool {
|
pub fn has_pending_failure(code: &str) -> bool {
|
||||||
PENDING_FAILURES.entries.lock().contains_key(code)
|
PENDING_FAILURES.entries.lock().map.contains_key(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return unresolved failures oldest first without clearing them.
|
/// Return unresolved failures oldest first without clearing them.
|
||||||
@@ -283,9 +332,10 @@ impl NotificationSystem {
|
|||||||
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
|
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
|
||||||
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
|
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
|
||||||
FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)),
|
FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)),
|
||||||
FrontendEvent::NoticeMessage { status, message } => {
|
FrontendEvent::NoticeMessage { status, message } => (
|
||||||
("verge://notice-message", serde_json::to_value((status, message)))
|
"verge://notice-message",
|
||||||
}
|
serde_json::to_value((status, message.as_ref())),
|
||||||
|
),
|
||||||
FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))),
|
FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))),
|
||||||
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
|
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
|
||||||
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
|
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
|
||||||
@@ -313,3 +363,106 @@ impl NotificationSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod wire_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_event_wire_contract() -> Result<(), serde_json::Error> {
|
||||||
|
let uid = String::from("profile");
|
||||||
|
for (event, name, payload) in super::sample_frontend_events(&uid) {
|
||||||
|
let (actual_name, actual_payload) = NotificationSystem::serialize_event(event);
|
||||||
|
assert_eq!(actual_name, name);
|
||||||
|
assert_eq!(actual_payload?, payload);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sample per `FrontendEvent` variant, paired with its wire name and payload.
|
||||||
|
/// Shared by the wire golden test and the contract export so the two cannot drift.
|
||||||
|
fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
FrontendEvent::RefreshClash,
|
||||||
|
"verge://refresh-clash-config",
|
||||||
|
json!("yes"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::RefreshVerge,
|
||||||
|
"verge://refresh-verge-config",
|
||||||
|
json!("yes"),
|
||||||
|
),
|
||||||
|
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
|
||||||
|
(
|
||||||
|
FrontendEvent::RefreshProxyConfig,
|
||||||
|
"verge://refresh-proxy-config",
|
||||||
|
json!(null),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::NoticeMessage {
|
||||||
|
status: NoticeStatus::Info,
|
||||||
|
message: Arc::from("ok"),
|
||||||
|
},
|
||||||
|
"verge://notice-message",
|
||||||
|
json!(["info", "ok"]),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::TimerUpdated { profile_index: uid },
|
||||||
|
"verge://timer-updated",
|
||||||
|
json!("profile"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::RunStateChanged {
|
||||||
|
state: json!({"running": true}),
|
||||||
|
},
|
||||||
|
"verge://run-state-changed",
|
||||||
|
json!({"running": true}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::PendingFailuresChanged,
|
||||||
|
"verge://pending-failures-changed",
|
||||||
|
json!(null),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::ProfileChanged {
|
||||||
|
current_profile_id: uid,
|
||||||
|
},
|
||||||
|
"profile-changed",
|
||||||
|
json!("profile"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::ProfileUpdateStarted { uid },
|
||||||
|
"profile-update-started",
|
||||||
|
json!({"uid": "profile"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FrontendEvent::ProfileUpdateCompleted { uid },
|
||||||
|
"profile-update-completed",
|
||||||
|
json!({"uid": "profile"}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable form of the frontend wire contract: every event name with a
|
||||||
|
/// serialized sample payload, plus every notice status string. The sample
|
||||||
|
/// payload's JSON shape (string / null / array / object) is what the frontend
|
||||||
|
/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in
|
||||||
|
/// linux-only window event, not part of this contract.
|
||||||
|
pub fn frontend_wire_contract() -> serde_json::Value {
|
||||||
|
let uid = String::from("sample");
|
||||||
|
let events: Vec<_> = sample_frontend_events(&uid)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(event, _, _)| {
|
||||||
|
let (name, payload) = NotificationSystem::serialize_event(event);
|
||||||
|
json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let statuses: Vec<_> = NoticeStatus::ALL
|
||||||
|
.iter()
|
||||||
|
// A unit variant with a string rename cannot fail to serialize.
|
||||||
|
.filter_map(|status| serde_json::to_value(status).ok())
|
||||||
|
.collect();
|
||||||
|
json!({ "version": 1, "events": events, "noticeStatuses": statuses })
|
||||||
|
}
|
||||||
@@ -63,8 +63,8 @@ pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::Fil
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result<std::fs::File> {
|
||||||
windows_owner::open_or_create_private_file(path)
|
windows_owner::open_or_create_lock_file(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repairs an application data root Windows no longer reports as owned by the current user.
|
/// Repairs an application data root Windows no longer reports as owned by the current user.
|
||||||
@@ -110,7 +110,7 @@ mod windows_owner {
|
|||||||
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
|
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
|
||||||
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
|
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK,
|
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK,
|
||||||
GetFileInformationByHandle, GetFileType, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
|
GetFileInformationByHandle, GetFileType, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
|
||||||
};
|
};
|
||||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||||
|
|
||||||
@@ -215,22 +215,49 @@ mod windows_owner {
|
|||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
|
pub(super) fn open_or_create_lock_file(path: &Path) -> Result<std::fs::File> {
|
||||||
let sid = current_sid()?;
|
let sid = current_sid()?;
|
||||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||||
match create_file(path, &descriptor) {
|
let attributes = SECURITY_ATTRIBUTES {
|
||||||
Ok(file) => Ok(file),
|
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
Err(error)
|
lpSecurityDescriptor: descriptor.0,
|
||||||
if error
|
bInheritHandle: 0,
|
||||||
.downcast_ref::<std::io::Error>()
|
};
|
||||||
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
|
let wide = wide_path(path)?;
|
||||||
{
|
let handle = unsafe {
|
||||||
let file = open_file(path)?;
|
CreateFileW(
|
||||||
validate_private_file(&file, descriptor.owner()?)?;
|
wide.as_ptr(),
|
||||||
Ok(file)
|
GENERIC_READ | READ_CONTROL | WRITE_DAC,
|
||||||
}
|
// Replacing a live lock would let a second instance lock a different file.
|
||||||
Err(error) => Err(error),
|
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||||
|
&attributes,
|
||||||
|
OPEN_ALWAYS,
|
||||||
|
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if handle == INVALID_HANDLE_VALUE {
|
||||||
|
return Err(std::io::Error::last_os_error()).context("failed to open current-user lock file");
|
||||||
}
|
}
|
||||||
|
let file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||||
|
let mut information = BY_HANDLE_FILE_INFORMATION::default();
|
||||||
|
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
|
||||||
|
return Err(std::io::Error::last_os_error()).context("failed to inspect current-user lock file");
|
||||||
|
}
|
||||||
|
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||||
|
|| unsafe { GetFileType(file.as_raw_handle()) } != FILE_TYPE_DISK
|
||||||
|
|| information.nNumberOfLinks != 1
|
||||||
|
{
|
||||||
|
bail!("current-user lock is not an ordinary, single-link file");
|
||||||
|
}
|
||||||
|
let expected_owner = descriptor.owner()?;
|
||||||
|
if !owner_matches(&file, expected_owner)? {
|
||||||
|
bail!("current-user lock belongs to a different Windows user");
|
||||||
|
}
|
||||||
|
// Lock contents are never trusted; records and tokens retain their separate validation.
|
||||||
|
descriptor.apply_dacl(file.as_raw_handle())?;
|
||||||
|
validate_private_file(&file, expected_owner)?;
|
||||||
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
|
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
|
||||||
@@ -442,7 +469,8 @@ mod windows_owner {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A DACL that refuses even the owner read settles it just as well.
|
// Failed inspection does not prove that a live lock belongs to another user.
|
||||||
|
Err(error) if name == crate::utils::server::INSTANCE_LOCK_FILE => return Err(error),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
};
|
};
|
||||||
if !ours {
|
if !ours {
|
||||||
@@ -712,7 +740,7 @@ mod windows_owner {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
bail!("failed to restrict owner token DACL: Windows error {status}");
|
bail!("failed to restrict current-user file DACL: Windows error {status}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,25 +522,34 @@ pub async fn clear() -> Result<()> {
|
|||||||
|
|
||||||
/// A failed system call is usually a transient RPC hiccup, so give it a few tries.
|
/// A failed system call is usually a transient RPC hiccup, so give it a few tries.
|
||||||
async fn clear_with_retry() -> Result<()> {
|
async fn clear_with_retry() -> Result<()> {
|
||||||
for attempt in 1..CLEAR_ATTEMPTS {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
match clear_inner().await {
|
retry(
|
||||||
Err(error)
|
RetryPolicy::fixed(
|
||||||
|
std::num::NonZeroUsize::MIN.saturating_add((CLEAR_ATTEMPTS - 1) as usize),
|
||||||
|
CLEAR_RETRY_DELAY,
|
||||||
|
),
|
||||||
|
|attempt| async move {
|
||||||
|
clear_inner().await.map_err(|error| {
|
||||||
if matches!(
|
if matches!(
|
||||||
SysproxyFailure::from_chain(&error),
|
SysproxyFailure::from_chain(&error),
|
||||||
Some(SysproxyFailure::SystemCallFailed)
|
Some(SysproxyFailure::SystemCallFailed)
|
||||||
) =>
|
) {
|
||||||
{
|
if attempt + 1 < CLEAR_ATTEMPTS as usize {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
"clearing the system proxy failed (attempt {attempt}/{CLEAR_ATTEMPTS}); retrying: {error:#}"
|
"clearing the system proxy failed (attempt {}/{CLEAR_ATTEMPTS}); retrying: {error:#}",
|
||||||
);
|
attempt + 1
|
||||||
tokio::time::sleep(CLEAR_RETRY_DELAY).await;
|
);
|
||||||
}
|
}
|
||||||
other => return other,
|
RetryError::Retry(error)
|
||||||
}
|
} else {
|
||||||
}
|
RetryError::Stop(error)
|
||||||
clear_inner().await
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
|
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
|
||||||
|
|||||||
+121
-309
@@ -1,16 +1,23 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri_plugin_mihomo::models::{
|
use tauri_plugin_mihomo::models::{
|
||||||
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
|
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ProxyViewInput {
|
pub struct ProxyViewInput {
|
||||||
pub runtime_group_order: Vec<String>,
|
pub runtime_group_order: Vec<String>,
|
||||||
|
pub group_scopes: BTreeMap<String, GroupScope>,
|
||||||
pub proxies: Proxies,
|
pub proxies: Proxies,
|
||||||
pub providers: Option<ProxyProviders>,
|
pub providers: Option<ProxyProviders>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The providers a group draws from (Mihomo order) and its `exclude-type` entries.
|
||||||
|
pub struct GroupScope {
|
||||||
|
pub providers: Vec<String>,
|
||||||
|
pub exclude_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProxyViewBuilder;
|
pub struct ProxyViewBuilder;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||||
@@ -68,6 +75,7 @@ pub struct ProxyGroupView {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub test_url: Option<String>,
|
pub test_url: Option<String>,
|
||||||
pub history: Vec<DelayHistory>,
|
pub history: Vec<DelayHistory>,
|
||||||
|
pub extra: HashMap<String, Extra>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub capabilities: ProxyCapabilities,
|
pub capabilities: ProxyCapabilities,
|
||||||
pub members: Vec<ProxyMemberRef>,
|
pub members: Vec<ProxyMemberRef>,
|
||||||
@@ -82,6 +90,7 @@ pub struct ProxyNodeView {
|
|||||||
pub proxy_type: ProxyType,
|
pub proxy_type: ProxyType,
|
||||||
pub alive: bool,
|
pub alive: bool,
|
||||||
pub history: Vec<DelayHistory>,
|
pub history: Vec<DelayHistory>,
|
||||||
|
pub extra: HashMap<String, Extra>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -167,15 +176,41 @@ pub struct ProxySubscriptionInfo {
|
|||||||
pub expire: i64,
|
pub expire: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proxy name -> `(provider name, record id)` in provider order.
|
||||||
|
type ProviderCandidates = BTreeMap<String, Vec<(String, String)>>;
|
||||||
|
|
||||||
struct MemberResolver<'a> {
|
struct MemberResolver<'a> {
|
||||||
group_names: BTreeSet<String>,
|
group_names: BTreeSet<String>,
|
||||||
|
group_scopes: &'a BTreeMap<String, GroupScope>,
|
||||||
|
records: &'a BTreeMap<String, ProxyNodeView>,
|
||||||
core_node_ids: &'a BTreeMap<String, String>,
|
core_node_ids: &'a BTreeMap<String, String>,
|
||||||
provider_candidates: &'a BTreeMap<String, Vec<String>>,
|
provider_candidates: &'a ProviderCandidates,
|
||||||
provider_available: bool,
|
provider_available: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemberResolver<'_> {
|
impl<'a> MemberResolver<'a> {
|
||||||
fn resolve(&self, name: String) -> ProxyMemberRef {
|
/// Same-name records in the order Mihomo lists them in `group`: `use` order, after `exclude-type`.
|
||||||
|
fn scoped_candidates(&self, group: &str, name: &str) -> Vec<&'a String> {
|
||||||
|
let (Some(scope), Some(candidates)) = (self.group_scopes.get(group), self.provider_candidates.get(name)) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
scope
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.flat_map(|provider| candidates.iter().filter(move |(owner, _)| owner == provider))
|
||||||
|
.map(|(_, record_id)| record_id)
|
||||||
|
.filter(|record_id| {
|
||||||
|
!self.records.get(*record_id).is_some_and(|node| {
|
||||||
|
scope
|
||||||
|
.exclude_types
|
||||||
|
.iter()
|
||||||
|
.any(|excluded| excluded.eq_ignore_ascii_case(node.proxy_type.as_str()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&self, name: String, scoped: Option<&String>) -> ProxyMemberRef {
|
||||||
if self.group_names.contains(&name) {
|
if self.group_names.contains(&name) {
|
||||||
ProxyMemberRef::Group { name }
|
ProxyMemberRef::Group { name }
|
||||||
} else if let Some(record_id) = self.core_node_ids.get(&name) {
|
} else if let Some(record_id) = self.core_node_ids.get(&name) {
|
||||||
@@ -189,16 +224,17 @@ impl MemberResolver<'_> {
|
|||||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match self.provider_candidates.get(&name).map(Vec::as_slice) {
|
let candidates = self.provider_candidates.get(&name).map_or(&[][..], Vec::as_slice);
|
||||||
Some([record_id]) => ProxyMemberRef::Node {
|
match (scoped, candidates) {
|
||||||
|
(Some(record_id), _) | (None, [(_, record_id)]) => ProxyMemberRef::Node {
|
||||||
name,
|
name,
|
||||||
record_id: record_id.clone(),
|
record_id: record_id.clone(),
|
||||||
},
|
},
|
||||||
None => ProxyMemberRef::Unresolved {
|
(None, []) => ProxyMemberRef::Unresolved {
|
||||||
name,
|
name,
|
||||||
reason: ProxyMemberUnresolvedReason::Missing,
|
reason: ProxyMemberUnresolvedReason::Missing,
|
||||||
},
|
},
|
||||||
Some(_) => ProxyMemberRef::Unresolved {
|
(None, _) => ProxyMemberRef::Unresolved {
|
||||||
name,
|
name,
|
||||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
||||||
},
|
},
|
||||||
@@ -211,6 +247,7 @@ impl ProxyViewBuilder {
|
|||||||
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
|
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
|
||||||
let ProxyViewInput {
|
let ProxyViewInput {
|
||||||
runtime_group_order,
|
runtime_group_order,
|
||||||
|
group_scopes,
|
||||||
proxies,
|
proxies,
|
||||||
providers,
|
providers,
|
||||||
} = input;
|
} = input;
|
||||||
@@ -219,11 +256,13 @@ impl ProxyViewBuilder {
|
|||||||
} else {
|
} else {
|
||||||
ProxyViewProviderState::Unavailable
|
ProxyViewProviderState::Unavailable
|
||||||
};
|
};
|
||||||
let (mut core_groups, core_nodes) = partition_core(proxies);
|
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
|
||||||
let (mut records, core_node_ids) = build_core_records(core_nodes);
|
let (mut records, core_node_ids) = build_core_records(core_nodes);
|
||||||
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
|
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
|
||||||
let resolver = MemberResolver {
|
let resolver = MemberResolver {
|
||||||
group_names: core_groups.keys().cloned().collect(),
|
group_names: core_groups.keys().cloned().collect(),
|
||||||
|
group_scopes: &group_scopes,
|
||||||
|
records: &records,
|
||||||
core_node_ids: &core_node_ids,
|
core_node_ids: &core_node_ids,
|
||||||
provider_candidates: &provider_candidates,
|
provider_candidates: &provider_candidates,
|
||||||
provider_available: provider_state == ProxyViewProviderState::Ready,
|
provider_available: provider_state == ProxyViewProviderState::Ready,
|
||||||
@@ -250,14 +289,14 @@ impl ProxyViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
|
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
|
||||||
let mut groups = BTreeMap::new();
|
let mut groups = BTreeMap::new();
|
||||||
let mut nodes = BTreeMap::new();
|
let mut nodes = BTreeMap::new();
|
||||||
|
|
||||||
for (name, proxy) in proxies.proxies {
|
for (name, proxy) in proxies.proxies {
|
||||||
if proxy.all.is_some() {
|
if proxy.all.is_some() {
|
||||||
groups.insert(name, proxy);
|
groups.insert(name, proxy);
|
||||||
} else {
|
} else if !providers_available || proxy.provider_name.is_empty() {
|
||||||
nodes.insert(name, proxy);
|
nodes.insert(name, proxy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +330,7 @@ fn build_core_records(
|
|||||||
fn build_provider_records(
|
fn build_provider_records(
|
||||||
providers: Option<ProxyProviders>,
|
providers: Option<ProxyProviders>,
|
||||||
records: &mut BTreeMap<String, ProxyNodeView>,
|
records: &mut BTreeMap<String, ProxyNodeView>,
|
||||||
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
|
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
|
||||||
let providers = providers
|
let providers = providers
|
||||||
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
|
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -322,7 +361,7 @@ fn build_provider_records(
|
|||||||
candidates
|
candidates
|
||||||
.entry(proxy_name.clone())
|
.entry(proxy_name.clone())
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
.push(record_id.clone());
|
.push((provider_name.clone(), record_id.clone()));
|
||||||
records.insert(
|
records.insert(
|
||||||
record_id.clone(),
|
record_id.clone(),
|
||||||
build_node(
|
build_node(
|
||||||
@@ -365,6 +404,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
|||||||
test_url,
|
test_url,
|
||||||
alive,
|
alive,
|
||||||
history,
|
history,
|
||||||
|
extra,
|
||||||
udp,
|
udp,
|
||||||
xudp,
|
xudp,
|
||||||
tfo,
|
tfo,
|
||||||
@@ -373,6 +413,20 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
|||||||
proxy_type,
|
proxy_type,
|
||||||
..
|
..
|
||||||
} = proxy;
|
} = proxy;
|
||||||
|
// The n-th occurrence of a name in `all` is the n-th scoped candidate.
|
||||||
|
let mut seen = BTreeMap::<String, (usize, Vec<&String>)>::new();
|
||||||
|
let members = all
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|member| {
|
||||||
|
let (occurrence, scoped) = seen
|
||||||
|
.entry(member.clone())
|
||||||
|
.or_insert_with(|| (0, resolver.scoped_candidates(&name, &member)));
|
||||||
|
let picked = scoped.get(*occurrence).or_else(|| scoped.first()).copied();
|
||||||
|
*occurrence += 1;
|
||||||
|
resolver.resolve(member, picked)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
ProxyGroupView {
|
ProxyGroupView {
|
||||||
name,
|
name,
|
||||||
@@ -384,6 +438,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
|||||||
icon,
|
icon,
|
||||||
test_url,
|
test_url,
|
||||||
history,
|
history,
|
||||||
|
extra,
|
||||||
capabilities: ProxyCapabilities {
|
capabilities: ProxyCapabilities {
|
||||||
udp,
|
udp,
|
||||||
xudp,
|
xudp,
|
||||||
@@ -391,11 +446,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
|||||||
mptcp,
|
mptcp,
|
||||||
smux,
|
smux,
|
||||||
},
|
},
|
||||||
members: all
|
members,
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|name| resolver.resolve(name))
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +458,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
|||||||
test_url,
|
test_url,
|
||||||
alive,
|
alive,
|
||||||
history,
|
history,
|
||||||
|
extra,
|
||||||
udp,
|
udp,
|
||||||
xudp,
|
xudp,
|
||||||
tfo,
|
tfo,
|
||||||
@@ -422,6 +474,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
|||||||
proxy_type,
|
proxy_type,
|
||||||
alive,
|
alive,
|
||||||
history,
|
history,
|
||||||
|
extra,
|
||||||
id,
|
id,
|
||||||
hidden,
|
hidden,
|
||||||
icon,
|
icon,
|
||||||
@@ -477,301 +530,60 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::expect_used)]
|
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use tauri_plugin_mihomo::models::{
|
#[test]
|
||||||
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
|
fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> {
|
||||||
};
|
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||||
|
runtime_group_order: vec!["A".into(), "B".into()],
|
||||||
use super::{
|
group_scopes: ["A", "B"]
|
||||||
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
|
.into_iter()
|
||||||
ProxyViewOrderSource, ProxyViewProviderState,
|
.map(|name| {
|
||||||
};
|
(
|
||||||
|
name.into(),
|
||||||
fn node(name: &str) -> Proxy {
|
GroupScope {
|
||||||
Proxy {
|
providers: vec![name.into()],
|
||||||
name: name.to_owned(),
|
exclude_types: vec![],
|
||||||
proxy_type: ProxyType::Shadowsocks,
|
},
|
||||||
alive: true,
|
)
|
||||||
udp: true,
|
})
|
||||||
..Proxy::default()
|
.collect(),
|
||||||
|
proxies: serde_json::from_value(json!({"proxies": {
|
||||||
|
"A": {"all": ["node"], "type": "Selector"},
|
||||||
|
"B": {"all": ["node"], "type": "Selector"},
|
||||||
|
"node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}
|
||||||
|
}}))?,
|
||||||
|
providers: Some(serde_json::from_value(json!({"providers": {
|
||||||
|
"A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]},
|
||||||
|
"B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]}
|
||||||
|
}}))?),
|
||||||
|
});
|
||||||
|
for (group, expected) in view.groups.iter().zip([32, 123]) {
|
||||||
|
let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else {
|
||||||
|
anyhow::bail!("Expected node")
|
||||||
|
};
|
||||||
|
let record = &view.records[record_id];
|
||||||
|
assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected));
|
||||||
|
assert!(
|
||||||
|
matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
fn group(name: &str, members: &[&str]) -> Proxy {
|
fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> {
|
||||||
Proxy {
|
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||||
name: format!("ignored-{name}"),
|
runtime_group_order: vec!["A".into()],
|
||||||
proxy_type: ProxyType::Selector,
|
group_scopes: BTreeMap::new(),
|
||||||
alive: true,
|
proxies: serde_json::from_value(json!({"proxies": {
|
||||||
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
|
"A": {"all": ["node"], "type": "Selector"},
|
||||||
..Proxy::default()
|
"node": {"name": "node", "provider-name": "provider"}
|
||||||
}
|
}}))?,
|
||||||
}
|
|
||||||
|
|
||||||
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
|
|
||||||
let entries = [
|
|
||||||
("Zulu", group("Zulu", &["node-b", "node-b"])),
|
|
||||||
("Alpha", group("Alpha", &["node-a"])),
|
|
||||||
("node-b", node("wrong-node-b")),
|
|
||||||
("node-a", node("wrong-node-a")),
|
|
||||||
("A-before-direct", node("wrong-before-direct")),
|
|
||||||
("DIRECT", node("wrong-direct")),
|
|
||||||
("REJECT", node("wrong-reject")),
|
|
||||||
];
|
|
||||||
let mut proxies = HashMap::new();
|
|
||||||
if reverse_insert {
|
|
||||||
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
|
|
||||||
} else {
|
|
||||||
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
|
|
||||||
}
|
|
||||||
|
|
||||||
ProxyViewInput {
|
|
||||||
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
|
|
||||||
proxies: Proxies { proxies },
|
|
||||||
providers: None,
|
providers: None,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
|
|
||||||
let providers = entries
|
|
||||||
.into_iter()
|
|
||||||
.map(|(provider_key, vehicle_type, names)| {
|
|
||||||
let provider = ProxyProvider {
|
|
||||||
name: format!("ignored-{provider_key}"),
|
|
||||||
vehicle_type,
|
|
||||||
proxies: names.iter().map(|name| node(name)).collect(),
|
|
||||||
..ProxyProvider::default()
|
|
||||||
};
|
|
||||||
(provider_key.to_owned(), provider)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
ProxyProviders { providers }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
|
|
||||||
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
|
|
||||||
ProxyViewInput {
|
|
||||||
runtime_group_order: vec![],
|
|
||||||
proxies: Proxies { proxies },
|
|
||||||
providers: Some(providers_fixture(vec![
|
|
||||||
("a", VehicleType::HTTP, &["duplicate"]),
|
|
||||||
("b", VehicleType::File, &["duplicate"]),
|
|
||||||
])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
|
|
||||||
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
|
|
||||||
ProxyViewInput {
|
|
||||||
runtime_group_order: vec![],
|
|
||||||
proxies: Proxies { proxies },
|
|
||||||
providers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_with_provider_duplicates() -> ProxyViewInput {
|
|
||||||
ProxyViewInput {
|
|
||||||
runtime_group_order: vec![],
|
|
||||||
proxies: Proxies::default(),
|
|
||||||
providers: Some(providers_fixture(vec![
|
|
||||||
("z-provider", VehicleType::HTTP, &["z-node"]),
|
|
||||||
("unsupported", VehicleType::Compatible, &["ignored"]),
|
|
||||||
("a-provider", VehicleType::File, &["same", "same"]),
|
|
||||||
])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serde_input_with_provider() -> ProxyViewInput {
|
|
||||||
let provider = ProxyProvider {
|
|
||||||
name: "ignored-object-name".to_owned(),
|
|
||||||
vehicle_type: VehicleType::HTTP,
|
|
||||||
updated_at: None,
|
|
||||||
subscription_info: Some(SubScriptionInfo {
|
|
||||||
upload: 1,
|
|
||||||
download: 2,
|
|
||||||
total: 3,
|
|
||||||
expire: 4,
|
|
||||||
}),
|
|
||||||
..ProxyProvider::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
ProxyViewInput {
|
|
||||||
runtime_group_order: vec![],
|
|
||||||
proxies: Proxies::default(),
|
|
||||||
providers: Some(ProxyProviders {
|
|
||||||
providers: HashMap::from([("provider-key".to_owned(), provider)]),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ordering_and_core_records_are_deterministic() {
|
|
||||||
let first = ProxyViewBuilder::build(ordered_input(false));
|
|
||||||
let second = ProxyViewBuilder::build(ordered_input(true));
|
|
||||||
|
|
||||||
assert_eq!(first, second);
|
|
||||||
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
|
|
||||||
assert_eq!(
|
|
||||||
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
|
||||||
["Zulu", "Alpha"]
|
|
||||||
);
|
|
||||||
assert_eq!(first.direct.as_deref(), Some("c:1"));
|
|
||||||
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
|
|
||||||
assert_eq!(first.records["c:0"].name, "A-before-direct");
|
|
||||||
assert_eq!(first.records["c:1"].name, "DIRECT");
|
|
||||||
assert_eq!(first.records["c:4"].name, "node-b");
|
|
||||||
assert_eq!(first.groups[0].members.len(), 2);
|
|
||||||
assert!(matches!(
|
|
||||||
&first.groups[0].members[0],
|
|
||||||
ProxyMemberRef::Node { name, .. } if name == "node-b"
|
|
||||||
));
|
|
||||||
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fallback_and_unmatched_groups_are_stable() {
|
|
||||||
let mut input = ordered_input(false);
|
|
||||||
input.runtime_group_order = vec![];
|
|
||||||
let view = ProxyViewBuilder::build(input);
|
|
||||||
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
|
|
||||||
assert_eq!(
|
|
||||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
|
||||||
["Alpha", "Zulu"]
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut input = ordered_input(false);
|
|
||||||
input.runtime_group_order = vec!["Missing".into()];
|
|
||||||
let view = ProxyViewBuilder::build(input);
|
|
||||||
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
|
|
||||||
assert_eq!(
|
|
||||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
|
||||||
["Alpha", "Zulu"]
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut input = ordered_input(false);
|
|
||||||
input.runtime_group_order = vec!["Zulu".into(), "Zulu".into()];
|
|
||||||
let view = ProxyViewBuilder::build(input);
|
|
||||||
assert_eq!(
|
|
||||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
|
||||||
["Zulu", "Alpha"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn global_and_member_resolution_follow_the_required_priority() {
|
|
||||||
let mut proxies = HashMap::new();
|
|
||||||
proxies.insert(
|
|
||||||
"GLOBAL".into(),
|
|
||||||
group("wrong", &["Nested", "core", "provider-only", "missing"]),
|
|
||||||
);
|
|
||||||
proxies.insert("Nested".into(), group("wrong", &[]));
|
|
||||||
proxies.insert("core".into(), node("wrong"));
|
|
||||||
let providers = providers_fixture(vec![
|
|
||||||
(
|
|
||||||
"a",
|
|
||||||
VehicleType::HTTP,
|
|
||||||
&["Nested", "core", "provider-only", "duplicate"],
|
|
||||||
),
|
|
||||||
("b", VehicleType::File, &["duplicate"]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
|
||||||
runtime_group_order: vec![],
|
|
||||||
proxies: Proxies { proxies },
|
|
||||||
providers: Some(providers),
|
|
||||||
});
|
});
|
||||||
let members = &view.global.as_ref().expect("GLOBAL").members;
|
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
|
||||||
|
Ok(())
|
||||||
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
|
|
||||||
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
|
|
||||||
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
|
|
||||||
assert!(matches!(
|
|
||||||
members[3],
|
|
||||||
ProxyMemberRef::Unresolved {
|
|
||||||
reason: ProxyMemberUnresolvedReason::Missing,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
|
|
||||||
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
|
|
||||||
assert!(matches!(
|
|
||||||
ready.groups[0].members[0],
|
|
||||||
ProxyMemberRef::Unresolved {
|
|
||||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
|
|
||||||
assert!(matches!(
|
|
||||||
unavailable.groups[0].members[0],
|
|
||||||
ProxyMemberRef::Unresolved {
|
|
||||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
|
|
||||||
assert!(unavailable.providers.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn providers_preserve_identity_duplicates_and_member_order() {
|
|
||||||
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
|
|
||||||
assert_eq!(
|
|
||||||
view.providers
|
|
||||||
.iter()
|
|
||||||
.map(|provider| provider.name.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
["a-provider", "z-provider"]
|
|
||||||
);
|
|
||||||
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
|
|
||||||
assert_eq!(view.records["p:0:0"].name, "same");
|
|
||||||
assert_eq!(view.records["p:0:1"].name, "same");
|
|
||||||
assert_eq!(
|
|
||||||
view.records["p:0:0"].source,
|
|
||||||
ProxyNodeSource::Provider {
|
|
||||||
provider_name: "a-provider".into(),
|
|
||||||
proxy_name: "same".into(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
|
|
||||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
|
||||||
runtime_group_order: vec![],
|
|
||||||
proxies: Proxies::default(),
|
|
||||||
providers: Some(providers_fixture(vec![(
|
|
||||||
"unsupported",
|
|
||||||
VehicleType::Compatible,
|
|
||||||
&["ignored"],
|
|
||||||
)])),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
|
|
||||||
assert!(view.providers.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn serde_matches_the_v1_wire_contract() {
|
|
||||||
let view = ProxyViewBuilder::build(serde_input_with_provider());
|
|
||||||
let json = serde_json::to_value(view).expect("serialize view");
|
|
||||||
|
|
||||||
assert_eq!(json["schemaVersion"], 1);
|
|
||||||
assert_eq!(json["orderSource"], "fallback");
|
|
||||||
assert_eq!(json["providerState"], "ready");
|
|
||||||
assert!(json["global"].is_null());
|
|
||||||
assert!(json["direct"].is_null());
|
|
||||||
assert!(json["providers"][0].get("vehicleType").is_some());
|
|
||||||
assert!(json["providers"][0].get("updatedAt").is_none());
|
|
||||||
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
|
|
||||||
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ impl RunStateEnv for RealEnv {
|
|||||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||||
// The IPC client would otherwise back off for about two minutes before failing.
|
// The IPC client would otherwise back off for about two minutes before failing.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if tokio::task::spawn_blocking(crate::core::service::service_stopped)
|
if let Some(reason) = tokio::task::spawn_blocking(crate::core::service::service_stop_reason)
|
||||||
.await
|
.await
|
||||||
.context("service status probe did not finish")??
|
.context("service status probe did not finish")??
|
||||||
{
|
{
|
||||||
anyhow::bail!("the Windows service is not running");
|
anyhow::bail!(reason);
|
||||||
}
|
}
|
||||||
let response = clash_verge_service_ipc::get_version().await?;
|
let response = clash_verge_service_ipc::get_version().await?;
|
||||||
let core = if response.code == 0
|
let core = if response.code == 0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ mod probe;
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc, LazyLock,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
@@ -19,7 +19,6 @@ use std::{
|
|||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ use health::StoredService;
|
|||||||
use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
|
use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
|
||||||
|
|
||||||
/// The process-wide Run State, observing the real machine.
|
/// The process-wide Run State, observing the real machine.
|
||||||
pub static RUN_STATE: Lazy<RunStateStore<RealEnv>> = Lazy::new(|| RunStateStore::new(RealEnv));
|
pub static RUN_STATE: LazyLock<RunStateStore<RealEnv>> = LazyLock::new(|| RunStateStore::new(RealEnv));
|
||||||
|
|
||||||
/// Distinguishes silence from a reply that updated Service health with a rejection.
|
/// Distinguishes silence from a reply that updated Service health with a rejection.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -130,20 +129,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
|||||||
|
|
||||||
/// Retries transport failures but stops at the first readable reply, including a rejection.
|
/// Retries transport failures but stops at the first readable reply, including a rejection.
|
||||||
pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result<RunState, ReadyWaitError> {
|
pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result<RunState, ReadyWaitError> {
|
||||||
let mut last_error = None;
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
for attempt in 0..attempts {
|
let attempts = std::num::NonZeroUsize::new(attempts).ok_or_else(|| {
|
||||||
|
ReadyWaitError::Unreachable(anyhow::anyhow!(
|
||||||
|
"service readiness wait was configured with no attempts"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
retry(RetryPolicy::fixed(attempts, interval), |_| async {
|
||||||
match self.env.probe_service_version().await {
|
match self.env.probe_service_version().await {
|
||||||
Ok(reply) => return self.record_reply(&reply).map_err(ReadyWaitError::Rejected),
|
Ok(reply) => self
|
||||||
Err(error) => last_error = Some(error),
|
.record_reply(&reply)
|
||||||
|
.map_err(|error| RetryError::Stop(ReadyWaitError::Rejected(error))),
|
||||||
|
Err(error) => Err(RetryError::Retry(ReadyWaitError::Unreachable(error))),
|
||||||
}
|
}
|
||||||
if attempt + 1 < attempts {
|
})
|
||||||
tokio::time::sleep(interval).await;
|
.await
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ReadyWaitError::Unreachable(last_error.unwrap_or_else(|| {
|
|
||||||
anyhow::anyhow!("service readiness wait was configured with no attempts")
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
|
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
|
||||||
|
|||||||
+157
-98
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
use crate::utils::dirs;
|
use crate::utils::dirs;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, runtime::IRuntime},
|
config::{Config, runtime::IRuntime},
|
||||||
@@ -25,7 +26,6 @@ use clash_verge_service_ipc::{
|
|||||||
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
|
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
|
||||||
StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||||
};
|
};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
@@ -38,7 +38,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None);
|
||||||
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||||
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||||
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||||
@@ -49,18 +49,29 @@ static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
|||||||
pub enum ServiceFallbackNotice {
|
pub enum ServiceFallbackNotice {
|
||||||
Unavailable,
|
Unavailable,
|
||||||
CoreRejected(String),
|
CoreRejected(String),
|
||||||
|
NotAutoStarted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The release installer registers AutoStart.
|
||||||
|
const SERVICE_NOT_AUTO_STARTED: &str = "the Windows service is stopped and no longer starts with Windows";
|
||||||
|
|
||||||
|
pub(crate) fn is_not_auto_started(reason: &str) -> bool {
|
||||||
|
// Detection prefixes its own context to the reason.
|
||||||
|
reason.contains(SERVICE_NOT_AUTO_STARTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn notify_service_fallback(reason: &str) {
|
pub(crate) fn notify_service_fallback(reason: &str) {
|
||||||
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
|
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
|
||||||
Handle::notice_message("service_core::sidecar_fallback", "");
|
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
|
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
|
||||||
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
|
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
|
||||||
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
|
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
|
||||||
ServiceFallbackNotice::CoreRejected(reason)
|
ServiceFallbackNotice::CoreRejected(reason)
|
||||||
|
} else if is_not_auto_started(&reason) {
|
||||||
|
ServiceFallbackNotice::NotAutoStarted
|
||||||
} else {
|
} else {
|
||||||
ServiceFallbackNotice::Unavailable
|
ServiceFallbackNotice::Unavailable
|
||||||
})
|
})
|
||||||
@@ -266,7 +277,7 @@ fn open_registered_service() -> Result<Option<windows_service::service::Service>
|
|||||||
let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||||
match manager.open_service(
|
match manager.open_service(
|
||||||
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
||||||
ServiceAccess::QUERY_STATUS,
|
ServiceAccess::QUERY_STATUS | ServiceAccess::QUERY_CONFIG,
|
||||||
) {
|
) {
|
||||||
Ok(service) => Ok(Some(service)),
|
Ok(service) => Ok(Some(service)),
|
||||||
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
||||||
@@ -281,21 +292,34 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
|||||||
Ok(open_registered_service()?.is_some())
|
Ok(open_registered_service()?.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether IPC cannot succeed until the service is started again. A service that is starting, or
|
/// Why IPC cannot succeed until the service is started again, if it cannot. A starting service,
|
||||||
/// that the SCM has not started yet this boot, is left to the IPC retries, which wait for it.
|
/// or an AutoStart one the SCM has yet to start this boot, is left to the IPC retries.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) fn service_stopped() -> Result<bool> {
|
pub(crate) fn service_stop_reason() -> Result<Option<&'static str>> {
|
||||||
use windows_service::service::{ServiceExitCode, ServiceState};
|
use windows_service::service::{ServiceExitCode, ServiceStartType, ServiceState};
|
||||||
|
|
||||||
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
||||||
|
const NOT_RUNNING: &str = "the Windows service is not running";
|
||||||
let Some(service) = open_registered_service()? else {
|
let Some(service) = open_registered_service()? else {
|
||||||
return Ok(true);
|
return Ok(Some(NOT_RUNNING));
|
||||||
};
|
};
|
||||||
let status = service
|
let status = service
|
||||||
.query_status()
|
.query_status()
|
||||||
.context("failed to query Windows service status")?;
|
.context("failed to query Windows service status")?;
|
||||||
Ok(status.current_state == ServiceState::Stopped
|
if status.current_state != ServiceState::Stopped {
|
||||||
&& status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED))
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// The development channel registers an on-demand start.
|
||||||
|
if !cfg!(feature = "verge-dev")
|
||||||
|
&& service
|
||||||
|
.query_config()
|
||||||
|
.context("failed to query Windows service configuration")?
|
||||||
|
.start_type
|
||||||
|
!= ServiceStartType::AutoStart
|
||||||
|
{
|
||||||
|
return Ok(Some(SERVICE_NOT_AUTO_STARTED));
|
||||||
|
}
|
||||||
|
Ok((status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED)).then_some(NOT_RUNNING))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -327,35 +351,40 @@ static SERVICE_CORE_STAGING_GENERATION: AtomicU64 = AtomicU64::new(0);
|
|||||||
|
|
||||||
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
|
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
|
||||||
fn create_service_core_staging_file(directory: &Path, core_name: &std::ffi::OsStr) -> Result<(PathBuf, std::fs::File)> {
|
fn create_service_core_staging_file(directory: &Path, core_name: &std::ffi::OsStr) -> Result<(PathBuf, std::fs::File)> {
|
||||||
for _ in 0..32 {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
|
||||||
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
retry_sync(
|
||||||
let temporary_name = format!(
|
RetryPolicy::fixed(
|
||||||
".{}.{}.{generation}.tmp",
|
std::num::NonZeroUsize::MIN.saturating_add(31),
|
||||||
core_name.to_string_lossy(),
|
std::time::Duration::ZERO,
|
||||||
std::process::id()
|
),
|
||||||
);
|
|_| {
|
||||||
let temporary_path = directory.join(temporary_name);
|
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||||
match std::fs::OpenOptions::new()
|
let temporary_name = format!(
|
||||||
.write(true)
|
".{}.{}.{generation}.tmp",
|
||||||
.create_new(true)
|
core_name.to_string_lossy(),
|
||||||
.open(&temporary_path)
|
std::process::id()
|
||||||
{
|
);
|
||||||
Ok(file) => return Ok((temporary_path, file)),
|
let temporary_path = directory.join(temporary_name);
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
match std::fs::OpenOptions::new()
|
||||||
Err(error) => {
|
.write(true)
|
||||||
return Err(error).with_context(|| {
|
.create_new(true)
|
||||||
|
.open(&temporary_path)
|
||||||
|
{
|
||||||
|
Ok(file) => Ok((temporary_path, file)),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
Err(RetryError::Retry(anyhow::anyhow!(
|
||||||
|
"failed to create a unique temporary development Service core in {}",
|
||||||
|
directory.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(error) => Err(RetryError::Stop(anyhow::Error::from(error).context({
|
||||||
format!(
|
format!(
|
||||||
"failed to create temporary development Service core {}",
|
"failed to create temporary development Service core {}",
|
||||||
temporary_path.display()
|
temporary_path.display()
|
||||||
)
|
)
|
||||||
});
|
}))),
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
bail!(
|
|
||||||
"failed to create a unique temporary development Service core in {}",
|
|
||||||
directory.display()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,11 +928,11 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
|
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
|
||||||
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
|
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
|
||||||
Handle::notice_message("service_core::repair_required", "");
|
Handle::notice(NoticeStatus::ServiceCoreRepairRequired, "");
|
||||||
}
|
}
|
||||||
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
|
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
|
||||||
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
|
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
|
||||||
Handle::notice_message("service_core::app_data_not_owned", "");
|
Handle::notice(NoticeStatus::ServiceCoreAppDataNotOwned, "");
|
||||||
}
|
}
|
||||||
start_owner_monitor();
|
start_owner_monitor();
|
||||||
return Err(record_service_start_refusal(
|
return Err(record_service_start_refusal(
|
||||||
@@ -1001,7 +1030,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
|
|||||||
}
|
}
|
||||||
let encoded = response.data.context("服务未返回核心日志快照")?;
|
let encoded = response.data.context("服务未返回核心日志快照")?;
|
||||||
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
|
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
|
||||||
Ok(String::from_utf8_lossy(&content).into_owned())
|
Ok(String::from_utf8_lossy_owned(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
|
fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
|
||||||
@@ -1015,7 +1044,8 @@ fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static PROVIDER_SYNC_QUEUED: AtomicBool = AtomicBool::new(false);
|
static PROVIDER_SYNC_QUEUED: AtomicBool = AtomicBool::new(false);
|
||||||
static PROVIDER_SYNC_SERIAL: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::new(()));
|
// Serializing the whole read-and-publish pass prevents an older cache from replacing a newer one.
|
||||||
|
static PROVIDER_SYNC_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
static SYNC_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static SYNC_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
const RUNTIME_PROVIDER_SYNC_ATTEMPTS: u32 = 4;
|
const RUNTIME_PROVIDER_SYNC_ATTEMPTS: u32 = 4;
|
||||||
const CONTENT_COMPARE_CHUNK: usize = 64 * 1024;
|
const CONTENT_COMPARE_CHUNK: usize = 64 * 1024;
|
||||||
@@ -1026,38 +1056,47 @@ pub(crate) fn request_runtime_provider_sync(delay: Duration) {
|
|||||||
}
|
}
|
||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
for attempt in 1..=RUNTIME_PROVIDER_SYNC_ATTEMPTS {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
let outcome = {
|
let _ = retry(
|
||||||
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
|
RetryPolicy::fixed(
|
||||||
if attempt == 1 {
|
std::num::NonZeroUsize::MIN.saturating_add((RUNTIME_PROVIDER_SYNC_ATTEMPTS - 1) as usize),
|
||||||
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
|
constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY,
|
||||||
|
),
|
||||||
|
|index| async move {
|
||||||
|
let attempt = index as u32 + 1;
|
||||||
|
let outcome = {
|
||||||
|
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
|
||||||
|
if attempt == 1 {
|
||||||
|
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
sync_runtime_providers_by_service().await
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
Ok(ProviderSync { pending: 0, .. }) => Ok(()),
|
||||||
|
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
"{pending} provider caches are not ready yet; retrying"
|
||||||
|
);
|
||||||
|
Err(RetryError::Retry(()))
|
||||||
|
}
|
||||||
|
Ok(ProviderSync { pending, .. }) => {
|
||||||
|
logging!(warn, Type::Service, "{pending} provider caches were not synced");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Service,
|
||||||
|
"failed to sync provider caches from the service: {error:#}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sync_runtime_providers_by_service().await
|
},
|
||||||
};
|
)
|
||||||
match outcome {
|
.await;
|
||||||
Ok(ProviderSync { pending: 0, .. }) => return,
|
|
||||||
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
|
|
||||||
logging!(
|
|
||||||
info,
|
|
||||||
Type::Service,
|
|
||||||
"{pending} provider caches are not ready yet; retrying"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(ProviderSync { pending, .. }) => {
|
|
||||||
logging!(warn, Type::Service, "{pending} provider caches were not synced");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Service,
|
|
||||||
"failed to sync provider caches from the service: {error:#}"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY).await;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,21 +1404,29 @@ async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy())
|
.map(|name| name.to_string_lossy())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
for _ in 0..8 {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
retry(
|
||||||
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
|
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(7), std::time::Duration::ZERO),
|
||||||
match tokio::fs::OpenOptions::new()
|
|_| async {
|
||||||
.write(true)
|
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||||
.create_new(true)
|
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
|
||||||
.open(&temp)
|
match tokio::fs::OpenOptions::new()
|
||||||
.await
|
.write(true)
|
||||||
{
|
.create_new(true)
|
||||||
Ok(file) => return Ok((temp, file)),
|
.open(&temp)
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
.await
|
||||||
Err(error) => return Err(error).with_context(|| format!("failed to create {}", temp.display())),
|
{
|
||||||
}
|
Ok(file) => Ok((temp, file)),
|
||||||
}
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
|
||||||
bail!("no free temporary name beside {}", target.display())
|
anyhow::anyhow!("no free temporary name beside {}", target.display()),
|
||||||
|
)),
|
||||||
|
Err(error) => Err(RetryError::Stop(
|
||||||
|
anyhow::anyhow!(error).context(format!("failed to create {}", temp.display())),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn same_contents(temp: &Path, target: &Path) -> bool {
|
async fn same_contents(temp: &Path, target: &Path) -> bool {
|
||||||
@@ -1623,7 +1670,7 @@ fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
|
|||||||
);
|
);
|
||||||
logging!(error, Type::Service, "service core stopped: {detail}");
|
logging!(error, Type::Service, "service core stopped: {detail}");
|
||||||
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
|
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
|
||||||
Handle::notice_message("core_start::error", "");
|
Handle::notice(NoticeStatus::CoreStartError, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Samples ownership, treating every unusable reply as unreadable.
|
/// Samples ownership, treating every unusable reply as unreadable.
|
||||||
@@ -1737,31 +1784,43 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
|||||||
);
|
);
|
||||||
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
||||||
proxy_control::stop_guard().await;
|
proxy_control::stop_guard().await;
|
||||||
|
let policy = owner_recovery_policy(reason, cfg!(target_os = "macos"));
|
||||||
// Clear while still in Service mode with the session: on macOS it routes through the helper.
|
// Clear while still in Service mode with the session: on macOS it routes through the helper.
|
||||||
if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
if policy.reset_system_proxy {
|
||||||
clear_proxy_after_owner_loss().await;
|
clear_proxy_after_owner_loss().await;
|
||||||
}
|
}
|
||||||
clear_active_service_session();
|
clear_active_service_session();
|
||||||
CoreManager::global().core_stopped();
|
CoreManager::global().core_stopped();
|
||||||
|
// A displaced Service may still run TUN for its new owner, and DNS settings are system-wide.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if policy.reset_system_proxy {
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn clear_proxy_after_owner_loss() {
|
async fn clear_proxy_after_owner_loss() {
|
||||||
let mut last_error = None;
|
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||||
for attempt in 1..=3 {
|
let result = retry(
|
||||||
match proxy_control::clear().await {
|
RetryPolicy::fixed(
|
||||||
Ok(()) => return,
|
std::num::NonZeroUsize::MIN.saturating_add(2),
|
||||||
Err(error) => {
|
Duration::from_millis(100),
|
||||||
|
),
|
||||||
|
|index| async move {
|
||||||
|
let attempt = index + 1;
|
||||||
|
proxy_control::clear().await.map_err(|error| {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
|
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
|
||||||
);
|
);
|
||||||
last_error = Some(error);
|
RetryError::Retry(error)
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
})
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
.await;
|
||||||
if let Some(error) = last_error {
|
if let Err(error) = result {
|
||||||
|
// The existing recovery path also waits after its final failed clear.
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
logging!(
|
logging!(
|
||||||
error,
|
error,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
|
|||||||
@@ -477,7 +477,9 @@ impl Timer {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
logging!(debug, Type::Timer, "timer task completed for uid: {}", uid);
|
logging!(debug, Type::Timer, "timer task completed for uid: {}", uid);
|
||||||
}
|
}
|
||||||
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid),
|
Err(e) => {
|
||||||
|
logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|| Self::emit_update_event(uid, false),
|
|| Self::emit_update_event(uid, false),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
use super::Tray;
|
||||||
|
use crate::{
|
||||||
|
Type, cmd,
|
||||||
|
config::Config,
|
||||||
|
core::{
|
||||||
|
handle,
|
||||||
|
notify::{Refresh, announce},
|
||||||
|
proxy_view::{ProxyMemberRef, ProxyNodeSource},
|
||||||
|
},
|
||||||
|
logging,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
|
||||||
|
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
|
||||||
|
|
||||||
|
fn is_latency_test_url(raw: &str) -> bool {
|
||||||
|
let raw = raw.trim();
|
||||||
|
raw.split_once("://")
|
||||||
|
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
|
||||||
|
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn test_url(
|
||||||
|
group: &crate::core::proxy_view::ProxyGroupView,
|
||||||
|
verge: &crate::config::IVerge,
|
||||||
|
profiles: &crate::config::IProfiles,
|
||||||
|
) -> String {
|
||||||
|
let custom_url = profiles
|
||||||
|
.current
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|uid| profiles.get_item(uid).ok())
|
||||||
|
.and_then(|profile| profile.latency_test_urls.as_ref())
|
||||||
|
.and_then(|urls| urls.get(group.name.as_str()))
|
||||||
|
.map(|url| url.as_str())
|
||||||
|
.filter(|url| !url.trim().is_empty());
|
||||||
|
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
|
||||||
|
let default_url = verge
|
||||||
|
.default_latency_test
|
||||||
|
.as_deref()
|
||||||
|
.filter(|url| !url.trim().is_empty());
|
||||||
|
custom_url
|
||||||
|
.or(group_url)
|
||||||
|
.or(default_url)
|
||||||
|
.filter(|&url| is_latency_test_url(url))
|
||||||
|
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
|
||||||
|
.trim()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
|
||||||
|
parking_lot::Mutex::new(std::collections::BTreeSet::new());
|
||||||
|
|
||||||
|
pub(super) fn is_running(group_name: &str) -> bool {
|
||||||
|
RUNNING.lock().contains(group_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn test_proxy_group_delay(group_name: &str) {
|
||||||
|
if !RUNNING.lock().insert(group_name.to_owned()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Tray::global().refresh_latency_item(group_name);
|
||||||
|
let _running = scopeguard::guard(group_name, |name| {
|
||||||
|
RUNNING.lock().remove(name);
|
||||||
|
Tray::global().refresh_latency_item(name);
|
||||||
|
});
|
||||||
|
use futures::{StreamExt as _, stream};
|
||||||
|
|
||||||
|
let view = match cmd::proxy::get_proxy_view().await {
|
||||||
|
Ok(view) => view,
|
||||||
|
Err(err) => {
|
||||||
|
logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let verge = Config::verge().await.latest_arc();
|
||||||
|
|
||||||
|
let group = view
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.chain(view.global.iter())
|
||||||
|
.find(|group| group.name.as_str() == group_name);
|
||||||
|
let Some(group) = group else {
|
||||||
|
logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let profiles = Config::profiles().await.latest_arc();
|
||||||
|
let url = test_url(group, &verge, &profiles);
|
||||||
|
let timeout = verge
|
||||||
|
.default_latency_timeout
|
||||||
|
.filter(|timeout| *timeout > 0)
|
||||||
|
.unwrap_or(10000) as u32;
|
||||||
|
|
||||||
|
let mut targets: Vec<(std::string::String, Option<std::string::String>)> = Vec::with_capacity(group.members.len());
|
||||||
|
for member in &group.members {
|
||||||
|
match member {
|
||||||
|
ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) {
|
||||||
|
Some(ProxyNodeSource::Provider {
|
||||||
|
provider_name,
|
||||||
|
proxy_name,
|
||||||
|
}) => targets.push((proxy_name.clone(), Some(provider_name.clone()))),
|
||||||
|
_ => targets.push((name.clone(), None)),
|
||||||
|
},
|
||||||
|
ProxyMemberRef::Group { name } => targets.push((name.clone(), None)),
|
||||||
|
ProxyMemberRef::Unresolved { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mihomo = handle::Handle::mihomo();
|
||||||
|
|
||||||
|
stream::iter(targets.into_iter().map(move |(member, provider)| {
|
||||||
|
let url = url.clone();
|
||||||
|
async move {
|
||||||
|
let result = match provider {
|
||||||
|
Some(provider_name) => {
|
||||||
|
mihomo
|
||||||
|
.healthcheck_node_in_provider(&provider_name, &member, &url, timeout)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => mihomo.delay_proxy_by_name(&member, &url, timeout).await,
|
||||||
|
};
|
||||||
|
if let Err(err) = result {
|
||||||
|
logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY)
|
||||||
|
.for_each(|()| std::future::ready(()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await {
|
||||||
|
logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
announce(Refresh::Proxies);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latency_urls_require_a_valid_http_host_and_port() {
|
||||||
|
for invalid in [
|
||||||
|
"http://?",
|
||||||
|
"http://host:invalid",
|
||||||
|
"http://bad host/",
|
||||||
|
"ftp://localhost",
|
||||||
|
"http:localhost",
|
||||||
|
] {
|
||||||
|
assert!(!is_latency_test_url(invalid), "{invalid}");
|
||||||
|
}
|
||||||
|
for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] {
|
||||||
|
assert!(is_latency_test_url(valid), "{valid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ define_menu! {
|
|||||||
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
|
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
|
||||||
profiles => PROFILES, "tray_profiles", "tray.profiles",
|
profiles => PROFILES, "tray_profiles", "tray.profiles",
|
||||||
proxies => PROXIES, "tray_proxies", "tray.proxies",
|
proxies => PROXIES, "tray_proxies", "tray.proxies",
|
||||||
|
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
|
||||||
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
|
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
|
||||||
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
|
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
|
||||||
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
|
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
|
||||||
|
|||||||
+360
-141
@@ -1,20 +1,20 @@
|
|||||||
use crate::config::{IProfilePreview, IVerge};
|
use crate::config::{IProfilePreview, IVerge};
|
||||||
|
use crate::core::lightweight;
|
||||||
|
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
|
||||||
use crate::core::tray::menu_def::TrayAction;
|
use crate::core::tray::menu_def::TrayAction;
|
||||||
use crate::module::lightweight;
|
|
||||||
use crate::process::AsyncHandler;
|
use crate::process::AsyncHandler;
|
||||||
use crate::singleton;
|
use crate::singleton;
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
Type, cmd,
|
Type, cmd,
|
||||||
config::Config,
|
config::Config,
|
||||||
|
core::lightweight::is_in_lightweight_mode,
|
||||||
feat, logging,
|
feat, logging,
|
||||||
module::lightweight::is_in_lightweight_mode,
|
|
||||||
utils::{dirs::find_target_icons, help},
|
utils::{dirs::find_target_icons, help},
|
||||||
};
|
};
|
||||||
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
||||||
use clash_verge_logging::logging_error;
|
use clash_verge_logging::logging_error;
|
||||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||||
use tauri_plugin_mihomo::models::Proxies;
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use super::handle;
|
use super::handle;
|
||||||
@@ -28,6 +28,7 @@ use tauri::{
|
|||||||
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod latency;
|
||||||
mod menu_def;
|
mod menu_def;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod speed_task;
|
mod speed_task;
|
||||||
@@ -55,8 +56,25 @@ enum IconKind {
|
|||||||
Tun,
|
Tun,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LatencyMenuItem {
|
||||||
|
item: MenuItem<Wry>,
|
||||||
|
running: bool,
|
||||||
|
proxies: Vec<ProxyLatencyItem>,
|
||||||
|
provider_state: ProxyViewProviderState,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProxyLatencyItem {
|
||||||
|
item: CheckMenuItem<Wry>,
|
||||||
|
name: std::string::String,
|
||||||
|
provider_name: Option<std::string::String>,
|
||||||
|
text: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Tray {
|
pub struct Tray {
|
||||||
limiter: SystemLimiter,
|
limiter: SystemLimiter,
|
||||||
|
proxy_hover_limiter: SystemLimiter,
|
||||||
|
menu_update: tokio::sync::Mutex<()>,
|
||||||
|
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
speed_controller: speed_task::TraySpeedController,
|
speed_controller: speed_task::TraySpeedController,
|
||||||
}
|
}
|
||||||
@@ -130,6 +148,9 @@ impl Default for Tray {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
|
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
|
||||||
|
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
|
||||||
|
menu_update: tokio::sync::Mutex::new(()),
|
||||||
|
latency_items: parking_lot::Mutex::new(HashMap::new()),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
speed_controller: speed_task::TraySpeedController::new(),
|
speed_controller: speed_task::TraySpeedController::new(),
|
||||||
}
|
}
|
||||||
@@ -195,6 +216,7 @@ impl Tray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
|
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
|
||||||
|
let _update = self.menu_update.lock().await;
|
||||||
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
||||||
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -219,29 +241,133 @@ impl Tray {
|
|||||||
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
||||||
let is_lightweight_mode = is_in_lightweight_mode();
|
let is_lightweight_mode = is_in_lightweight_mode();
|
||||||
|
|
||||||
logging_error!(
|
let (menu, latency_items) = create_tray_menu(
|
||||||
Type::Tray,
|
app_handle,
|
||||||
tray.set_menu(Some(
|
Some(mode.as_str()),
|
||||||
create_tray_menu(
|
*system_proxy,
|
||||||
app_handle,
|
*tun_mode,
|
||||||
Some(mode.as_str()),
|
tun_mode_available,
|
||||||
*system_proxy,
|
profiles_preview,
|
||||||
*tun_mode,
|
TrayMenuOptions {
|
||||||
tun_mode_available,
|
is_lightweight_mode,
|
||||||
profiles_preview,
|
include_proxy_groups,
|
||||||
TrayMenuOptions {
|
},
|
||||||
is_lightweight_mode,
|
)
|
||||||
include_proxy_groups,
|
.await?;
|
||||||
},
|
tray.set_menu(Some(menu))?;
|
||||||
)
|
let groups: Vec<_> = latency_items.keys().cloned().collect();
|
||||||
.await?,
|
*self.latency_items.lock() = latency_items;
|
||||||
))
|
for group in groups {
|
||||||
);
|
self.refresh_latency_item(&group);
|
||||||
|
}
|
||||||
|
|
||||||
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn refresh_proxy_items(&'static self) {
|
||||||
|
let Ok(update) = self.menu_update.try_lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(Ok(view)) = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self
|
||||||
|
.latency_items
|
||||||
|
.lock()
|
||||||
|
.values()
|
||||||
|
.any(|entry| entry.provider_state != view.provider_state)
|
||||||
|
{
|
||||||
|
// Provider fallback changes member identities; rebuild through the existing queue.
|
||||||
|
drop(update);
|
||||||
|
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let verge = Config::verge().await.latest_arc();
|
||||||
|
let profiles = Config::profiles().await.latest_arc();
|
||||||
|
logging_error!(
|
||||||
|
Type::Tray,
|
||||||
|
handle::Handle::app_handle().run_on_main_thread(move || {
|
||||||
|
// Keep rebuilding serialized until these handles have received their snapshot.
|
||||||
|
let _update = update;
|
||||||
|
let mut items = self.latency_items.lock();
|
||||||
|
for (group_name, entry) in items.iter_mut() {
|
||||||
|
let Some(group) = find_group(&view, group_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let url = latency::test_url(group, &verge, &profiles);
|
||||||
|
let mut members = HashMap::with_capacity(group.members.len());
|
||||||
|
for member in &group.members {
|
||||||
|
members
|
||||||
|
.entry((member_name(member), member_provider(&view, member)))
|
||||||
|
.or_insert(member);
|
||||||
|
}
|
||||||
|
for proxy in &mut entry.proxies {
|
||||||
|
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let text = proxy_item_text(&view, member, &url, &verge);
|
||||||
|
let checked = group.now.as_ref() == Some(&proxy.name);
|
||||||
|
if text != proxy.text {
|
||||||
|
match proxy.item.set_text(&text) {
|
||||||
|
Ok(()) => proxy.text = text,
|
||||||
|
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match proxy.item.is_checked() {
|
||||||
|
Ok(current) if current != checked => {
|
||||||
|
logging_error!(Type::Tray, proxy.item.set_checked(checked))
|
||||||
|
}
|
||||||
|
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_latency_item(&self, group_name: &str) {
|
||||||
|
let group_name = group_name.to_owned();
|
||||||
|
logging_error!(
|
||||||
|
Type::Tray,
|
||||||
|
handle::Handle::app_handle().run_on_main_thread(move || {
|
||||||
|
Self::global().refresh_latency_item_on_main_thread(&group_name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
|
||||||
|
let running = latency::is_running(group_name);
|
||||||
|
let item = {
|
||||||
|
let mut items = self.latency_items.lock();
|
||||||
|
items.get_mut(group_name).and_then(|entry| {
|
||||||
|
if entry.running == running {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry.running = running;
|
||||||
|
Some(entry.item.clone())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(item) = item {
|
||||||
|
let label = if running {
|
||||||
|
clash_verge_i18n::t!("tray.testingLatency")
|
||||||
|
} else {
|
||||||
|
clash_verge_i18n::t!("tray.testLatency")
|
||||||
|
};
|
||||||
|
logging_error!(Type::Tray, item.set_text(label));
|
||||||
|
logging_error!(Type::Tray, item.set_enabled(!running));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
|
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
|
||||||
if handle::Handle::global().is_exiting() {
|
if handle::Handle::global().is_exiting() {
|
||||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
|
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
|
||||||
@@ -457,96 +583,175 @@ fn create_profile_menu_item(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNTESTED_DELAY_TEXT: &str = "-ms";
|
||||||
|
const TIMEOUT_DELAY_TEXT: &str = "T/O";
|
||||||
|
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
|
||||||
|
|
||||||
|
const fn member_name(member: &ProxyMemberRef) -> &str {
|
||||||
|
match member {
|
||||||
|
ProxyMemberRef::Node { name, .. }
|
||||||
|
| ProxyMemberRef::Group { name }
|
||||||
|
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
|
||||||
|
view.groups
|
||||||
|
.iter()
|
||||||
|
.chain(view.global.iter())
|
||||||
|
.find(|group| group.name.as_str() == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
|
||||||
|
/// node the core tested on its own still shows a value.
|
||||||
|
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
|
||||||
|
view.records
|
||||||
|
.get(record_id)
|
||||||
|
.map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history))
|
||||||
|
.and_then(|history| history.last())
|
||||||
|
.map(|history| history.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group row shows the delay of the node the group currently selects. The core never writes a
|
||||||
|
/// history for the group itself, so `now` is followed until the chain reaches a node.
|
||||||
|
fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option<u16> {
|
||||||
|
let mut current = group_name;
|
||||||
|
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
|
||||||
|
let Some(group) = find_group(view, current) else { break };
|
||||||
|
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match member {
|
||||||
|
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
|
||||||
|
ProxyMemberRef::Group { name } => current = name.as_str(),
|
||||||
|
ProxyMemberRef::Unresolved { .. } => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
find_group(view, group_name)
|
||||||
|
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
|
||||||
|
.and_then(|history| history.last())
|
||||||
|
.map(|history| history.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
|
||||||
|
let ProxyMemberRef::Node { record_id, .. } = member else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
match &view.records.get(record_id)?.source {
|
||||||
|
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
|
||||||
|
ProxyNodeSource::Core { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
|
||||||
|
let timeout = verge
|
||||||
|
.default_latency_timeout
|
||||||
|
.filter(|timeout| *timeout > 0)
|
||||||
|
.unwrap_or(10000);
|
||||||
|
let delay = match member {
|
||||||
|
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
|
||||||
|
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
|
||||||
|
ProxyMemberRef::Unresolved { .. } => None,
|
||||||
|
};
|
||||||
|
let delay_text = delay.map_or_else(
|
||||||
|
|| UNTESTED_DELAY_TEXT.to_owned(),
|
||||||
|
|delay| {
|
||||||
|
if delay == 0 || u32::from(delay) >= timeout as u32 {
|
||||||
|
TIMEOUT_DELAY_TEXT.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{delay}ms")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
format!("{} | {delay_text}", member_name(member))
|
||||||
|
}
|
||||||
|
|
||||||
fn create_subcreate_proxy_menu_item(
|
fn create_subcreate_proxy_menu_item(
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
proxy_mode: &str,
|
proxy_mode: &str,
|
||||||
proxy_group_order_map: Option<HashMap<String, usize>>,
|
view: Option<ProxyViewV1>,
|
||||||
proxy_nodes_data: Option<Proxies>,
|
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
|
||||||
|
texts: &MenuTexts,
|
||||||
|
verge: &IVerge,
|
||||||
|
profiles: &crate::config::IProfiles,
|
||||||
) -> Vec<Submenu<Wry>> {
|
) -> Vec<Submenu<Wry>> {
|
||||||
let proxy_submenus: Vec<Submenu<Wry>> = {
|
let Some(view) = view else { return Vec::new() };
|
||||||
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new();
|
view.groups
|
||||||
|
.iter()
|
||||||
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
|
.chain(view.global.iter())
|
||||||
if let Some(proxy_nodes_data) = proxy_nodes_data {
|
.filter_map(|group| {
|
||||||
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
|
if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
|
||||||
let should_show = match proxy_mode {
|
return None;
|
||||||
"global" => group_name == "GLOBAL",
|
|
||||||
_ => group_name != "GLOBAL",
|
|
||||||
} && !group_data.hidden.unwrap_or_default();
|
|
||||||
|
|
||||||
if !should_show {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(all_proxies) = group_data.all.as_ref() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let now_proxy = group_data.now.as_deref().unwrap_or_default();
|
|
||||||
|
|
||||||
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
|
|
||||||
.iter()
|
|
||||||
.filter_map(|proxy_str| {
|
|
||||||
let is_selected = *proxy_str == now_proxy;
|
|
||||||
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
|
|
||||||
|
|
||||||
let delay_text = proxy_nodes_data
|
|
||||||
.proxies
|
|
||||||
.get(proxy_str)
|
|
||||||
.and_then(|h| h.history.last())
|
|
||||||
.map(|h| match h.delay {
|
|
||||||
0 => "-ms".into(),
|
|
||||||
delay if delay >= 10000 => "-ms".into(),
|
|
||||||
_ => format!("{}ms", h.delay),
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "-ms".into());
|
|
||||||
|
|
||||||
let display_text = format!("{} | {}", proxy_str, delay_text);
|
|
||||||
|
|
||||||
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
|
|
||||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if group_items.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let group_display_name = group_name.to_string();
|
|
||||||
|
|
||||||
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
|
|
||||||
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
|
|
||||||
|
|
||||||
if let Ok(submenu) = Submenu::with_id_and_items(
|
|
||||||
app_handle,
|
|
||||||
format!("proxy_group_{}", group_name),
|
|
||||||
group_display_name,
|
|
||||||
true,
|
|
||||||
&group_items_refs,
|
|
||||||
) {
|
|
||||||
let insertion_index = submenus.len();
|
|
||||||
submenus.push((group_name.into(), insertion_index, submenu));
|
|
||||||
} else {
|
|
||||||
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
let url = latency::test_url(group, verge, profiles);
|
||||||
|
let proxies: Vec<ProxyLatencyItem> = group
|
||||||
if let Some(order_map) = proxy_group_order_map.as_ref() {
|
.members
|
||||||
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| {
|
.iter()
|
||||||
match (order_map.get(name_a), order_map.get(name_b)) {
|
.filter_map(|member| {
|
||||||
(Some(index_a), Some(index_b)) => index_a.cmp(index_b),
|
let name = member_name(member);
|
||||||
(Some(_), None) => std::cmp::Ordering::Less,
|
let text = proxy_item_text(&view, member, &url, verge);
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
let checked = group.now.as_deref() == Some(name);
|
||||||
(None, None) => original_index_a.cmp(original_index_b),
|
CheckMenuItem::with_id(
|
||||||
}
|
app_handle,
|
||||||
});
|
format!("proxy_{}_{}", group.name, name),
|
||||||
}
|
&text,
|
||||||
|
true,
|
||||||
submenus.into_iter().map(|(_, _, submenu)| submenu).collect()
|
checked,
|
||||||
};
|
None::<&str>,
|
||||||
proxy_submenus
|
)
|
||||||
|
.map(|item| ProxyLatencyItem {
|
||||||
|
item,
|
||||||
|
name: name.to_owned(),
|
||||||
|
provider_name: member_provider(&view, member).map(str::to_owned),
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
|
||||||
|
if group_items.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let running = latency::is_running(&group.name);
|
||||||
|
let test_item = MenuItem::with_id(
|
||||||
|
app_handle,
|
||||||
|
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
|
||||||
|
if running {
|
||||||
|
clash_verge_i18n::t!("tray.testingLatency")
|
||||||
|
} else {
|
||||||
|
texts.test_latency.clone()
|
||||||
|
},
|
||||||
|
!running,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
latency_items.insert(
|
||||||
|
group.name.clone(),
|
||||||
|
LatencyMenuItem {
|
||||||
|
item: test_item.clone(),
|
||||||
|
running,
|
||||||
|
proxies,
|
||||||
|
provider_state: view.provider_state,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
|
||||||
|
let mut items: Vec<&dyn IsMenuItem<Wry>> = vec![&test_item, &separator];
|
||||||
|
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
|
||||||
|
Submenu::with_id_and_items(
|
||||||
|
app_handle,
|
||||||
|
format!("proxy_group_{}", group.name),
|
||||||
|
&group.name,
|
||||||
|
true,
|
||||||
|
&items,
|
||||||
|
)
|
||||||
|
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_proxy_menu_item(
|
fn create_proxy_menu_item(
|
||||||
@@ -593,54 +798,38 @@ async fn create_tray_menu(
|
|||||||
tun_mode_available: bool,
|
tun_mode_available: bool,
|
||||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
profiles_preview: Vec<IProfilePreview<'_>>,
|
||||||
options: TrayMenuOptions,
|
options: TrayMenuOptions,
|
||||||
) -> Result<tauri::menu::Menu<Wry>> {
|
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
|
||||||
let current_proxy_mode = mode.unwrap_or("");
|
let current_proxy_mode = mode.unwrap_or("");
|
||||||
|
|
||||||
let mut verge_settings = Config::verge().await.latest_arc();
|
let mut verge_settings = Config::verge().await.latest_arc();
|
||||||
let fetch_proxy_groups =
|
let fetch_proxy_groups =
|
||||||
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
|
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
|
||||||
|
|
||||||
// TODO: should update tray menu again when it was timeout error
|
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
|
||||||
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
|
let proxy_view = if fetch_proxy_groups {
|
||||||
let proxy_nodes_data =
|
tokio::time::timeout(
|
||||||
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
|
Duration::from_millis(1000),
|
||||||
.await
|
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
|
||||||
.map_or(None, |res| res.ok());
|
)
|
||||||
|
.await
|
||||||
let runtime = Config::runtime().await.latest_arc();
|
.ok()
|
||||||
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
|
.and_then(Result::ok)
|
||||||
config
|
|
||||||
.get("proxy-groups")
|
|
||||||
.and_then(|groups| groups.as_sequence())
|
|
||||||
.map(|groups| {
|
|
||||||
groups
|
|
||||||
.iter()
|
|
||||||
.filter_map(|group| group.get("name"))
|
|
||||||
.filter_map(|name| name.as_str())
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, name)| (name.into(), index))
|
|
||||||
.collect::<HashMap<String, usize>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
});
|
|
||||||
|
|
||||||
(proxy_nodes_data, runtime_proxy_groups_order)
|
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if fetch_proxy_groups {
|
if fetch_proxy_groups {
|
||||||
verge_settings = Config::verge().await.latest_arc();
|
verge_settings = Config::verge().await.latest_arc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let profiles_config = Config::profiles().await.latest_arc();
|
||||||
|
|
||||||
let tray_proxy_groups_display_mode = verge_settings
|
let tray_proxy_groups_display_mode = verge_settings
|
||||||
.tray_proxy_groups_display_mode
|
.tray_proxy_groups_display_mode
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("default");
|
.unwrap_or("default");
|
||||||
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
|
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
|
||||||
|
|
||||||
let proxy_group_order_map = runtime_proxy_groups_order;
|
|
||||||
|
|
||||||
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
|
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
|
||||||
|
|
||||||
let version = env!("CARGO_PKG_VERSION");
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
@@ -720,9 +909,17 @@ async fn create_tray_menu(
|
|||||||
&profile_menu_items_refs,
|
&profile_menu_items_refs,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let mut latency_items = HashMap::new();
|
||||||
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
|
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
|
||||||
let proxy_sub_menus =
|
let proxy_sub_menus = create_subcreate_proxy_menu_item(
|
||||||
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
|
app_handle,
|
||||||
|
current_proxy_mode,
|
||||||
|
proxy_view,
|
||||||
|
&mut latency_items,
|
||||||
|
&texts,
|
||||||
|
&verge_settings,
|
||||||
|
&profiles_config,
|
||||||
|
);
|
||||||
|
|
||||||
match tray_proxy_groups_display_mode {
|
match tray_proxy_groups_display_mode {
|
||||||
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
|
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
|
||||||
@@ -866,10 +1063,25 @@ async fn create_tray_menu(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
|
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
|
||||||
Ok(menu)
|
Ok((menu, latency_items))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
|
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
|
||||||
|
let refresh_proxies = match tray_event {
|
||||||
|
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
|
||||||
|
TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left | MouseButton::Right,
|
||||||
|
button_state: MouseButtonState::Down,
|
||||||
|
..
|
||||||
|
} => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if refresh_proxies {
|
||||||
|
AsyncHandler::spawn(|| async {
|
||||||
|
Tray::global().refresh_proxy_items().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if matches!(
|
if matches!(
|
||||||
tray_event,
|
tray_event,
|
||||||
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
|
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
|
||||||
@@ -926,9 +1138,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
|||||||
AsyncHandler::spawn(|| async move {
|
AsyncHandler::spawn(|| async move {
|
||||||
match event.id.as_ref() {
|
match event.id.as_ref() {
|
||||||
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
|
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
|
||||||
if let Some(stripped) = mode.strip_prefix("tray_")
|
if let Some(final_mode) = mode.strip_circumfix("tray_", "_mode") {
|
||||||
&& let Some(final_mode) = stripped.strip_suffix("_mode")
|
|
||||||
{
|
|
||||||
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
|
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
|
||||||
let _ = feat::change_clash_mode(final_mode.into()).await;
|
let _ = feat::change_clash_mode(final_mode.into()).await;
|
||||||
}
|
}
|
||||||
@@ -985,6 +1195,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
|||||||
};
|
};
|
||||||
feat::toggle_proxy_profile(profile_index.into()).await;
|
feat::toggle_proxy_profile(profile_index.into()).await;
|
||||||
}
|
}
|
||||||
|
id if id.starts_with(MenuIds::TEST_LATENCY) => {
|
||||||
|
let group_name = id
|
||||||
|
.strip_prefix(MenuIds::TEST_LATENCY)
|
||||||
|
.and_then(|rest| rest.strip_prefix('_'));
|
||||||
|
if let Some(group_name) = group_name {
|
||||||
|
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
|
||||||
|
latency::test_proxy_group_delay(group_name).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
id if id.starts_with("proxy_") => {
|
id if id.starts_with("proxy_") => {
|
||||||
let rest = match id.strip_prefix("proxy_") {
|
let rest = match id.strip_prefix("proxy_") {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
|
|||||||
+310
-116
@@ -2,19 +2,20 @@ use crate::{config::Config, singleton, utils::dirs};
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::Mutex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tauri_plugin_updater::{Update, UpdaterExt as _};
|
use tauri_plugin_updater::{Update, UpdaterExt as _};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
pub struct SilentUpdater {
|
pub struct SilentUpdater {
|
||||||
update_ready: AtomicBool,
|
update_ready: AtomicBool,
|
||||||
pending_bytes: RwLock<Option<Vec<u8>>>,
|
manual_cancel: Mutex<Option<CancellationToken>>,
|
||||||
pending_update: RwLock<Option<Update>>,
|
background_cancel: Mutex<Option<CancellationToken>>,
|
||||||
pending_version: RwLock<Option<String>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
singleton!(SilentUpdater, SILENT_UPDATER);
|
singleton!(SilentUpdater, SILENT_UPDATER);
|
||||||
@@ -23,9 +24,8 @@ impl SilentUpdater {
|
|||||||
const fn new() -> Self {
|
const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
update_ready: AtomicBool::new(false),
|
update_ready: AtomicBool::new(false),
|
||||||
pending_bytes: RwLock::new(None),
|
manual_cancel: Mutex::new(None),
|
||||||
pending_update: RwLock::new(None),
|
background_cancel: Mutex::new(None),
|
||||||
pending_version: RwLock::new(None),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +99,72 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the cached installer for `update`, deleting a cache that does not match it.
|
||||||
|
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> {
|
||||||
|
let meta = Self::read_cache_meta().ok()?;
|
||||||
|
let bytes = if meta.version == update.version {
|
||||||
|
Self::read_cache_bytes().and_then(|bytes| {
|
||||||
|
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
|
||||||
|
Ok(bytes)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("server offers v{}", update.version))
|
||||||
|
};
|
||||||
|
match bytes {
|
||||||
|
Ok(bytes) => Some(bytes),
|
||||||
|
Err(e) => {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::System,
|
||||||
|
"Update cache v{} is unusable: {e:#}, cleaning up",
|
||||||
|
meta.version
|
||||||
|
);
|
||||||
|
Self::delete_cache();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
|
||||||
|
/// with the same rules `Update::download` applies.
|
||||||
|
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
let decode = |value: &str| -> Result<String> {
|
||||||
|
Ok(String::from_utf8(
|
||||||
|
base64::engine::general_purpose::STANDARD.decode(value)?,
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
let config: tauri_plugin_updater::Config = serde_json::from_value(
|
||||||
|
app_handle
|
||||||
|
.config()
|
||||||
|
.plugins
|
||||||
|
.0
|
||||||
|
.get("updater")
|
||||||
|
.cloned()
|
||||||
|
.context("updater is not configured")?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
|
||||||
|
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
|
||||||
|
public_key
|
||||||
|
.verify(bytes, &signature, true)
|
||||||
|
.context("signature mismatch")?;
|
||||||
|
|
||||||
|
match signature
|
||||||
|
.trusted_comment()
|
||||||
|
.split('\t')
|
||||||
|
.find_map(|field| field.strip_prefix("version:"))
|
||||||
|
{
|
||||||
|
Some(signed) => anyhow::ensure!(
|
||||||
|
signed.trim_start_matches('v') == version,
|
||||||
|
"signed for v{signed}, not v{version}"
|
||||||
|
),
|
||||||
|
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
||||||
@@ -147,9 +213,128 @@ fn nsis_language_id(app_language: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same wire shape as the updater plugin's JS `DownloadEvent`.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(tag = "event", content = "data")]
|
||||||
|
pub enum DownloadEvent {
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Started {
|
||||||
|
content_length: Option<u64>,
|
||||||
|
},
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Progress {
|
||||||
|
chunk_length: usize,
|
||||||
|
},
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIRRORS: [&str; 2] = ["https://update.hwdns.net/", "https://gh-proxy.org/"];
|
||||||
|
/// A source delivering less than this per window is dropped for the next one.
|
||||||
|
const MIN_PROGRESS: u64 = 1024 * 1024;
|
||||||
|
const PROGRESS_WINDOW: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// The manifest's URL, then the other mirror, then GitHub itself.
|
||||||
|
fn download_sources(url: &tauri::Url) -> Vec<tauri::Url> {
|
||||||
|
let origin = MIRRORS
|
||||||
|
.iter()
|
||||||
|
.find_map(|mirror| url.as_str().strip_prefix(mirror))
|
||||||
|
.unwrap_or(url.as_str());
|
||||||
|
let mut sources = vec![url.clone()];
|
||||||
|
if origin.starts_with("https://github.com/") {
|
||||||
|
let candidates = MIRRORS.iter().map(|mirror| format!("{mirror}{origin}"));
|
||||||
|
for candidate in candidates.chain([origin.to_owned()]) {
|
||||||
|
if let Ok(candidate) = tauri::Url::parse(&candidate)
|
||||||
|
&& !sources.contains(&candidate)
|
||||||
|
{
|
||||||
|
sources.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries every source with a throughput floor, then again stopping only on a stall, so a link
|
||||||
|
/// that is slow everywhere still finishes.
|
||||||
|
async fn download(update: &Update, on_event: impl Fn(DownloadEvent) + Sync) -> Result<Vec<u8>> {
|
||||||
|
let sources = download_sources(&update.download_url);
|
||||||
|
let mut last_error = None;
|
||||||
|
for floor in [MIN_PROGRESS, 1] {
|
||||||
|
for url in &sources {
|
||||||
|
logging!(info, Type::System, "Downloading update v{} from {url}", update.version);
|
||||||
|
match download_from(update, url, floor, &on_event).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
on_event(DownloadEvent::Finished);
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(warn, Type::System, "Update download from {url} failed: {e:#}");
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no update download source")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_from(
|
||||||
|
update: &Update,
|
||||||
|
url: &tauri::Url,
|
||||||
|
floor: u64,
|
||||||
|
on_event: &(impl Fn(DownloadEvent) + Sync),
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let mut source = update.clone();
|
||||||
|
source.download_url = url.clone();
|
||||||
|
let received = AtomicU64::new(0);
|
||||||
|
let mut started = false;
|
||||||
|
let fetch = source.download(
|
||||||
|
|chunk_length, content_length| {
|
||||||
|
if !std::mem::replace(&mut started, true) {
|
||||||
|
on_event(DownloadEvent::Started { content_length });
|
||||||
|
}
|
||||||
|
received.fetch_add(chunk_length as u64, Ordering::Relaxed);
|
||||||
|
on_event(DownloadEvent::Progress { chunk_length });
|
||||||
|
},
|
||||||
|
|| {},
|
||||||
|
);
|
||||||
|
tokio::pin!(fetch);
|
||||||
|
let mut seen = 0;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut fetch => return Ok(result?),
|
||||||
|
() = tokio::time::sleep(PROGRESS_WINDOW) => {
|
||||||
|
let total = received.load(Ordering::Relaxed);
|
||||||
|
anyhow::ensure!(total - seen >= floor, "only {} bytes in {PROGRESS_WINDOW:?}", total - seen);
|
||||||
|
seen = total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SilentUpdater {
|
impl SilentUpdater {
|
||||||
|
/// Checks the server; Windows receives `/LANG` to suppress the NSIS language dialog,
|
||||||
|
/// see `packages/windows/installer.nsi`.
|
||||||
|
async fn check(app_handle: &tauri::AppHandle) -> Result<Option<Update>> {
|
||||||
|
let updater_builder = app_handle.updater_builder();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let updater_builder = {
|
||||||
|
let verge_lang = Config::verge().await.latest_arc().language.clone();
|
||||||
|
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
|
||||||
|
updater_builder.installer_arg(format!("/LANG={lang_id}"))
|
||||||
|
};
|
||||||
|
Ok(updater_builder.build()?.check().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
|
||||||
|
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
|
||||||
|
}
|
||||||
|
|
||||||
/// Installs a newer cached update before normal startup, if the user confirms.
|
/// Installs a newer cached update before normal startup, if the user confirms.
|
||||||
pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool {
|
pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool {
|
||||||
|
if !Config::verge().await.latest_arc().auto_check_update.unwrap_or(true) {
|
||||||
|
Self::delete_cache();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION");
|
let current_version = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
let meta = match Self::read_cache_meta() {
|
let meta = match Self::read_cache_meta() {
|
||||||
@@ -185,71 +370,30 @@ impl SilentUpdater {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = match Self::read_cache_bytes() {
|
let update = match Self::check(app_handle).await {
|
||||||
Ok(b) => b,
|
Ok(Some(u)) => u,
|
||||||
Err(e) => {
|
Ok(None) => {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
info,
|
||||||
Type::System,
|
Type::System,
|
||||||
"Failed to read cached update bytes: {e:#}, cleaning up"
|
"No update available from server, cache may be stale, cleaning up"
|
||||||
);
|
);
|
||||||
Self::delete_cache();
|
Self::delete_cache();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the
|
|
||||||
// NSIS language dialog; see `packages/windows/installer.nsi`.
|
|
||||||
let updater_builder = app_handle.updater_builder();
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let updater_builder = {
|
|
||||||
let verge_lang = Config::verge().await.latest_arc().language.clone();
|
|
||||||
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
|
|
||||||
updater_builder.installer_arg(format!("/LANG={lang_id}"))
|
|
||||||
};
|
|
||||||
let update = match updater_builder.build() {
|
|
||||||
Ok(updater) => match updater.check().await {
|
|
||||||
Ok(Some(u)) => u,
|
|
||||||
Ok(None) => {
|
|
||||||
logging!(
|
|
||||||
info,
|
|
||||||
Type::System,
|
|
||||||
"No update available from server, cache may be stale, cleaning up"
|
|
||||||
);
|
|
||||||
Self::delete_cache();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::System,
|
|
||||||
"Failed to check for update at startup: {e}, will retry next launch"
|
|
||||||
);
|
|
||||||
return false; // Keep cache for next attempt
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::System,
|
Type::System,
|
||||||
"Failed to create updater: {e}, will retry next launch"
|
"Failed to check for update at startup: {e:#}, will retry next launch"
|
||||||
);
|
);
|
||||||
return false;
|
return false; // Keep cache for next attempt
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// A changed server version invalidates the cached bytes.
|
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
|
||||||
if update.version != *cached_version {
|
|
||||||
logging!(
|
|
||||||
info,
|
|
||||||
Type::System,
|
|
||||||
"Server version ({}) != cached version ({}), cache is stale, cleaning up",
|
|
||||||
update.version,
|
|
||||||
cached_version
|
|
||||||
);
|
|
||||||
Self::delete_cache();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
let version = update.version.clone();
|
let version = update.version.clone();
|
||||||
logging!(info, Type::System, "Installing cached update v{version} at startup...");
|
logging!(info, Type::System, "Installing cached update v{version} at startup...");
|
||||||
@@ -257,49 +401,87 @@ impl SilentUpdater {
|
|||||||
Self::show_update_splash(app_handle, &version);
|
Self::show_update_splash(app_handle, &version);
|
||||||
|
|
||||||
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
|
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
|
||||||
let install_result = tokio::task::spawn_blocking({
|
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
|
||||||
let bytes = bytes.clone();
|
match install
|
||||||
let update = update.clone();
|
.await
|
||||||
move || update.install(&bytes)
|
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
|
||||||
});
|
{
|
||||||
|
Ok(()) => {
|
||||||
let success = match tokio::time::timeout(std::time::Duration::from_secs(30), install_result).await {
|
|
||||||
Ok(Ok(Ok(()))) => {
|
|
||||||
logging!(info, Type::System, "Update v{version} install triggered at startup");
|
logging!(info, Type::System, "Update v{version} install triggered at startup");
|
||||||
Self::delete_cache();
|
Self::delete_cache();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Ok(Ok(Err(e))) => {
|
Err(e) => {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::System,
|
Type::System,
|
||||||
"Startup install failed: {e}, will retry next launch"
|
"Startup install failed: {e:#}, will retry next launch"
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::System,
|
|
||||||
"Startup install task panicked: {e}, will retry next launch"
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::System,
|
|
||||||
"Startup install timed out (30s), will retry next launch"
|
|
||||||
);
|
);
|
||||||
|
Self::close_update_splash(app_handle);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
|
||||||
|
/// `false` when cancelled before the installer starts.
|
||||||
|
pub async fn install_update(
|
||||||
|
&self,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
version: &str,
|
||||||
|
on_event: impl Fn(DownloadEvent) + Sync,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
*self.manual_cancel.lock() = Some(cancel.clone());
|
||||||
|
let prepared = tokio::select! {
|
||||||
|
prepared = Self::prepare_install(app_handle, version, on_event) => Some(prepared),
|
||||||
|
() = cancel.cancelled() => None,
|
||||||
|
};
|
||||||
|
// `cancel_download` cancels under the lock, so once the token is taken back its state is
|
||||||
|
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
|
||||||
|
self.manual_cancel.lock().take();
|
||||||
|
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
|
||||||
|
logging!(info, Type::System, "Update v{version} cancelled");
|
||||||
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if !success {
|
logging!(info, Type::System, "Installing update v{version}...");
|
||||||
Self::close_update_splash(app_handle);
|
Self::install(update, bytes).await?;
|
||||||
}
|
Self::delete_cache();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
success
|
async fn prepare_install(
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
version: &str,
|
||||||
|
on_event: impl Fn(DownloadEvent) + Sync,
|
||||||
|
) -> Result<(Update, Vec<u8>)> {
|
||||||
|
let update = Self::check(app_handle)
|
||||||
|
.await?
|
||||||
|
.filter(|update| update.version == version)
|
||||||
|
.with_context(|| format!("v{version} is no longer offered"))?;
|
||||||
|
let bytes = match Self::verified_cache(app_handle, &update) {
|
||||||
|
Some(bytes) => bytes,
|
||||||
|
None => download(&update, on_event).await?,
|
||||||
|
};
|
||||||
|
Ok((update, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_download(&self) {
|
||||||
|
let mut slot = self.manual_cancel.lock();
|
||||||
|
if let Some(cancel) = slot.take() {
|
||||||
|
cancel.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the downloaded update and aborts a background download in flight.
|
||||||
|
pub fn discard_pending(&self) {
|
||||||
|
let cancel = self.background_cancel.lock().take();
|
||||||
|
if let Some(cancel) = cancel {
|
||||||
|
cancel.cancel();
|
||||||
|
}
|
||||||
|
self.update_ready.store(false, Ordering::Release);
|
||||||
|
Self::delete_cache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,12 +570,14 @@ impl SilentUpdater {
|
|||||||
|
|
||||||
// The new webview may not accept evaluation immediately.
|
// The new webview may not accept evaluation immediately.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for i in 0..10 {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100 * (i + 1)));
|
let _ = retry_sync(
|
||||||
if window.eval(&js).is_ok() {
|
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(9), std::time::Duration::ZERO),
|
||||||
return;
|
|index| {
|
||||||
}
|
std::thread::sleep(std::time::Duration::from_millis(100 * (index as u64 + 1)));
|
||||||
}
|
window.eval(&js).map_err(RetryError::Retry)
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
logging!(info, Type::System, "Update splash window shown");
|
logging!(info, Type::System, "Update splash window shown");
|
||||||
@@ -410,6 +594,9 @@ impl SilentUpdater {
|
|||||||
|
|
||||||
impl SilentUpdater {
|
impl SilentUpdater {
|
||||||
async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> {
|
async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> {
|
||||||
|
// Registered before reading the switch, so `discard_pending` either cancels it or ran first.
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
*self.background_cancel.lock() = Some(cancel.clone());
|
||||||
let auto_check = Config::verge().await.latest_arc().auto_check_update.unwrap_or(true);
|
let auto_check = Config::verge().await.latest_arc().auto_check_update.unwrap_or(true);
|
||||||
if !auto_check {
|
if !auto_check {
|
||||||
logging!(debug, Type::System, "Silent update skipped: auto_check_update is false");
|
logging!(debug, Type::System, "Silent update skipped: auto_check_update is false");
|
||||||
@@ -447,36 +634,42 @@ impl SilentUpdater {
|
|||||||
Type::System,
|
Type::System,
|
||||||
"Silent updater: breaking change detected in v{version}, notifying frontend"
|
"Silent updater: breaking change detected in v{version}, notifying frontend"
|
||||||
);
|
);
|
||||||
super::handle::Handle::notice_message(
|
super::handle::Handle::notice(
|
||||||
"info",
|
super::notify::NoticeStatus::Info,
|
||||||
format!("New version v{version} contains breaking changes. Please update manually."),
|
format!("New version v{version} contains breaking changes. Please update manually."),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
if Self::verified_cache(app_handle, &update).is_some() {
|
||||||
let bytes = update
|
logging!(info, Type::System, "Silent updater: v{version} already cached");
|
||||||
.download(
|
} else {
|
||||||
|chunk_len, content_len| {
|
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
||||||
logging!(
|
let downloaded = tokio::select! {
|
||||||
debug,
|
bytes = download(&update, |_| {}) => Some(bytes),
|
||||||
Type::System,
|
() = cancel.cancelled() => None,
|
||||||
"Silent updater download progress: chunk={chunk_len}, total={content_len:?}"
|
};
|
||||||
);
|
let Some(bytes) = downloaded else {
|
||||||
},
|
logging!(info, Type::System, "Silent updater: download cancelled");
|
||||||
|| {
|
return Ok(());
|
||||||
logging!(info, Type::System, "Silent updater: download complete");
|
};
|
||||||
},
|
let bytes = bytes?;
|
||||||
)
|
logging!(info, Type::System, "Silent updater: download complete");
|
||||||
.await?;
|
Self::write_cache(&bytes, &version)?;
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = Self::write_cache(&bytes, &version) {
|
// Serialized with `discard_pending` in the verge patch, which holds the same lock.
|
||||||
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
|
let _config_write = Config::lock_config_write().await;
|
||||||
|
if cancel.is_cancelled() || !Config::verge().await.latest_arc().auto_check_update.unwrap_or(true) {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::System,
|
||||||
|
"Silent updater: auto check was disabled, discarding v{version}"
|
||||||
|
);
|
||||||
|
Self::delete_cache();
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.pending_bytes.write() = Some(bytes);
|
|
||||||
*self.pending_update.write() = Some(update);
|
|
||||||
*self.pending_version.write() = Some(version.clone());
|
|
||||||
self.update_ready.store(true, Ordering::Release);
|
self.update_ready.store(true, Ordering::Release);
|
||||||
|
|
||||||
logging!(
|
logging!(
|
||||||
@@ -488,6 +681,7 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_background_check(&self, app_handle: tauri::AppHandle) {
|
pub async fn start_background_check(&self, app_handle: tauri::AppHandle) {
|
||||||
|
// Permanent watcher: daily update cycles for the lifetime of the app.
|
||||||
logging!(info, Type::System, "Silent updater: background task started");
|
logging!(info, Type::System, "Silent updater: background task started");
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
mod chain;
|
mod chain;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
mod merge;
|
mod merge;
|
||||||
@@ -436,7 +437,7 @@ fn notify_discarded_keys(keys: Vec<String>) {
|
|||||||
drop(notice);
|
drop(notice);
|
||||||
|
|
||||||
if should_notify {
|
if should_notify {
|
||||||
Handle::notice_message("enhance::discarded_keys", "");
|
Handle::notice(NoticeStatus::EnhanceDiscardedKeys, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,10 +507,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
|||||||
|
|
||||||
fn is_loopback_bind_address(addr: &str) -> bool {
|
fn is_loopback_bind_address(addr: &str) -> bool {
|
||||||
let addr = addr.trim();
|
let addr = addr.trim();
|
||||||
let addr = addr
|
let addr = addr.strip_circumfix('[', ']').unwrap_or(addr);
|
||||||
.strip_prefix('[')
|
|
||||||
.and_then(|addr| addr.strip_suffix(']'))
|
|
||||||
.unwrap_or(addr);
|
|
||||||
|
|
||||||
addr.eq_ignore_ascii_case("localhost")
|
addr.eq_ignore_ascii_case("localhost")
|
||||||
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
|
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use crate::process::AsyncHandler;
|
|
||||||
|
|
||||||
macro_rules! revise {
|
macro_rules! revise {
|
||||||
($map: expr, $key: expr, $val: expr) => {
|
($map: expr, $key: expr, $val: expr) => {
|
||||||
let ret_key = Value::String($key.into());
|
let ret_key = Value::String($key.into());
|
||||||
@@ -46,22 +43,9 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
|||||||
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
|
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
|
||||||
revise!(dns_val, "fake-ip-range6", "2001:2::0/64");
|
revise!(dns_val, "fake-ip-range6", "2001:2::0/64");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
AsyncHandler::spawn(move || async move {
|
|
||||||
crate::utils::resolve::dns::restore_public_dns().await;
|
|
||||||
crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revise!(config, "dns", dns_val);
|
revise!(config, "dns", dns_val);
|
||||||
} else {
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
AsyncHandler::spawn(move || async move {
|
|
||||||
crate::utils::resolve::dns::restore_public_dns().await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revise!(tun_val, "enable", enable);
|
revise!(tun_val, "enable", enable);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, MixedPort},
|
config::{Config, MixedPort},
|
||||||
core::{CoreManager, handle, tray},
|
core::{CoreManager, handle, tray},
|
||||||
@@ -7,13 +9,12 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
|
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
|
||||||
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
@@ -26,11 +27,10 @@ static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
|
|||||||
pub async fn restart_clash_core() {
|
pub async fn restart_clash_core() {
|
||||||
match CoreManager::global().restart_core().await {
|
match CoreManager::global().restart_core().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
handle::Handle::notice_message("set_config::ok", "ok");
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
handle::Handle::notice_message("set_config::error", format!("{err:#}"));
|
handle::Handle::notice(NoticeStatus::SetConfigError, format!("{err:#}"));
|
||||||
logging!(error, Type::Core, "restart core failed: {err:#}");
|
logging!(error, Type::Core, "restart core failed: {err:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,21 @@ pub async fn restart_app() {
|
|||||||
|
|
||||||
if !cleanup_result.core_stopped {
|
if !cleanup_result.core_stopped {
|
||||||
handle::Handle::global().clear_is_exiting();
|
handle::Handle::global().clear_is_exiting();
|
||||||
handle::Handle::notice_message(
|
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
|
||||||
"app_restart::core_stop_failed",
|
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let manager = CoreManager::global();
|
||||||
|
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||||
|
if matches!(
|
||||||
|
*manager.get_running_mode(),
|
||||||
|
crate::core::manager::RunningMode::NotRunning
|
||||||
|
) {
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle::Handle::notice(
|
||||||
|
NoticeStatus::AppRestartCoreStopFailed,
|
||||||
cleanup_result.stop_error.unwrap_or_default(),
|
cleanup_result.stop_error.unwrap_or_default(),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -95,7 +108,7 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
|
|||||||
|
|
||||||
let clash_data = clash.data_arc();
|
let clash_data = clash.data_arc();
|
||||||
if clash_data.save_config().await.is_ok() {
|
if clash_data.save_config().await.is_ok() {
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
tray::Tray::global().update_menu_and_icon().await;
|
tray::Tray::global().update_menu_and_icon().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-295
@@ -1,279 +1,17 @@
|
|||||||
use crate::{
|
use crate::config::{Config, IVerge};
|
||||||
config::{Config, IVerge},
|
|
||||||
core::{CoreManager, autostart, handle, hotkey, logger, proxy_control, tray},
|
|
||||||
module::{auto_backup::AutoBackupManager, lightweight},
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bitflags::bitflags;
|
use clash_verge_draft::SharedDraft;
|
||||||
use clash_verge_draft::{DraftTransaction, SharedDraft};
|
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use tokio::sync::MutexGuard;
|
use tokio::sync::MutexGuard;
|
||||||
|
|
||||||
/// Patch Clash configuration
|
|
||||||
pub async fn patch_clash(patch: &Mapping) -> Result<()> {
|
pub async fn patch_clash(patch: &Mapping) -> Result<()> {
|
||||||
Config::clash().await.edit_draft(|d| d.patch_config(patch));
|
let config_write = Config::try_lock_config_write()?;
|
||||||
|
super::executor::apply(
|
||||||
let res = {
|
&config_write,
|
||||||
// 激活订阅
|
super::executor::Patch::Clash(patch),
|
||||||
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
|
super::effects::clash_effects(patch),
|
||||||
Config::generate().await?;
|
)
|
||||||
CoreManager::global().restart_core().await?;
|
.await
|
||||||
} else if patch.get("allow-lan").is_some() {
|
|
||||||
CoreManager::global().update_config_checked().await?;
|
|
||||||
} else {
|
|
||||||
if patch.get("mode").is_some() {
|
|
||||||
tray::Tray::global().update_menu_and_icon().await;
|
|
||||||
}
|
|
||||||
CoreManager::global().update_config_checked().await?;
|
|
||||||
}
|
|
||||||
handle::Handle::refresh_clash();
|
|
||||||
<Result<()>>::Ok(())
|
|
||||||
};
|
|
||||||
match res {
|
|
||||||
Ok(()) => {
|
|
||||||
Config::clash().await.apply();
|
|
||||||
// 分离数据获取和异步调用
|
|
||||||
let clash_data = Config::clash().await.data_arc();
|
|
||||||
clash_data.save_config().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
Config::clash().await.discard();
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define update flags as bitflags for better performance
|
|
||||||
bitflags! {
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
||||||
struct UpdateFlags: u16 {
|
|
||||||
const RESTART_CORE = 1 << 0;
|
|
||||||
const CLASH_CONFIG = 1 << 1;
|
|
||||||
const VERGE_CONFIG = 1 << 2;
|
|
||||||
const LAUNCH = 1 << 3;
|
|
||||||
const SYS_PROXY = 1 << 4;
|
|
||||||
const SYSTRAY_ICON = 1 << 5;
|
|
||||||
const HOTKEY = 1 << 6;
|
|
||||||
const SYSTRAY_MENU = 1 << 7;
|
|
||||||
const SYSTRAY_TOOLTIP = 1 << 8;
|
|
||||||
const SYSTRAY_CLICK_BEHAVIOR = 1 << 9;
|
|
||||||
const LIGHT_WEIGHT = 1 << 10;
|
|
||||||
const LANGUAGE = 1 << 11;
|
|
||||||
const LOG_LEVEL = 1 << 12;
|
|
||||||
const LOG_FILE = 1 << 13;
|
|
||||||
|
|
||||||
const GROUP_SYS_TRAY = Self::SYSTRAY_MENU.bits()
|
|
||||||
| Self::SYSTRAY_TOOLTIP.bits()
|
|
||||||
| Self::SYSTRAY_ICON.bits();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn determine_update_flags(patch: &IVerge) -> UpdateFlags {
|
|
||||||
let tun_mode = patch.enable_tun_mode;
|
|
||||||
let auto_launch = patch.enable_auto_launch;
|
|
||||||
let system_proxy = patch.enable_system_proxy;
|
|
||||||
let pac = patch.proxy_auto_config;
|
|
||||||
let pac_content = &patch.pac_file_content;
|
|
||||||
let proxy_bypass = &patch.system_proxy_bypass;
|
|
||||||
let language = &patch.language;
|
|
||||||
let mixed_port = patch.verge_mixed_port;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let tray_icon = &patch.tray_icon;
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let tray_icon: Option<String> = None;
|
|
||||||
let common_tray_icon = patch.common_tray_icon;
|
|
||||||
let sysproxy_tray_icon = patch.sysproxy_tray_icon;
|
|
||||||
let tun_tray_icon = patch.tun_tray_icon;
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let redir_enabled = patch.verge_redir_enabled;
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let redir_port = patch.verge_redir_port;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let tproxy_enabled = patch.verge_tproxy_enabled;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let tproxy_port = patch.verge_tproxy_port;
|
|
||||||
let socks_enabled = patch.verge_socks_enabled;
|
|
||||||
let socks_port = patch.verge_socks_port;
|
|
||||||
let http_enabled = patch.verge_http_enabled;
|
|
||||||
let http_port = patch.verge_port;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let enable_tray_speed = patch.enable_tray_speed;
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let enable_tray_speed: Option<bool> = None;
|
|
||||||
// let enable_tray_icon = patch.enable_tray_icon;
|
|
||||||
let enable_global_hotkey = patch.enable_global_hotkey;
|
|
||||||
let tray_event = &patch.tray_event;
|
|
||||||
let home_cards = patch.home_cards.as_ref();
|
|
||||||
let enable_auto_light_weight = patch.enable_auto_light_weight_mode;
|
|
||||||
let enable_external_controller = patch.enable_external_controller;
|
|
||||||
let tray_proxy_groups_display_mode = &patch.tray_proxy_groups_display_mode;
|
|
||||||
let tray_inline_outbound_modes = patch.tray_inline_outbound_modes;
|
|
||||||
let enable_proxy_guard = patch.enable_proxy_guard;
|
|
||||||
let proxy_guard_duration = patch.proxy_guard_duration;
|
|
||||||
let log_level = &patch.app_log_level;
|
|
||||||
let log_max_size = patch.app_log_max_size;
|
|
||||||
let log_max_count = patch.app_log_max_count;
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let restart_core_needed = socks_enabled.is_some()
|
|
||||||
|| http_enabled.is_some()
|
|
||||||
|| socks_port.is_some()
|
|
||||||
|| http_port.is_some()
|
|
||||||
|| mixed_port.is_some()
|
|
||||||
|| enable_external_controller.is_some();
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let mut restart_core_needed = socks_enabled.is_some()
|
|
||||||
|| http_enabled.is_some()
|
|
||||||
|| socks_port.is_some()
|
|
||||||
|| http_port.is_some()
|
|
||||||
|| mixed_port.is_some()
|
|
||||||
|| enable_external_controller.is_some();
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
restart_core_needed |= redir_enabled.is_some() || redir_port.is_some();
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
restart_core_needed |= tproxy_enabled.is_some() || tproxy_port.is_some();
|
|
||||||
restart_core_needed |= tun_mode == Some(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut update_flags = UpdateFlags::empty();
|
|
||||||
if restart_core_needed {
|
|
||||||
update_flags.insert(UpdateFlags::RESTART_CORE);
|
|
||||||
}
|
|
||||||
if tun_mode.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::CLASH_CONFIG | UpdateFlags::GROUP_SYS_TRAY);
|
|
||||||
}
|
|
||||||
if enable_global_hotkey.is_some() || home_cards.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::VERGE_CONFIG);
|
|
||||||
}
|
|
||||||
if auto_launch.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::LAUNCH);
|
|
||||||
}
|
|
||||||
if system_proxy.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::SYS_PROXY | UpdateFlags::GROUP_SYS_TRAY);
|
|
||||||
}
|
|
||||||
if proxy_bypass.is_some()
|
|
||||||
|| pac_content.is_some()
|
|
||||||
|| pac.is_some()
|
|
||||||
|| enable_proxy_guard.is_some()
|
|
||||||
|| proxy_guard_duration.is_some()
|
|
||||||
{
|
|
||||||
update_flags.insert(UpdateFlags::SYS_PROXY);
|
|
||||||
}
|
|
||||||
if language.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::LANGUAGE | UpdateFlags::SYSTRAY_MENU | UpdateFlags::SYSTRAY_TOOLTIP);
|
|
||||||
}
|
|
||||||
if common_tray_icon.is_some()
|
|
||||||
|| sysproxy_tray_icon.is_some()
|
|
||||||
|| tun_tray_icon.is_some()
|
|
||||||
|| tray_icon.is_some()
|
|
||||||
|| enable_tray_speed.is_some()
|
|
||||||
{
|
|
||||||
update_flags.insert(UpdateFlags::SYSTRAY_ICON);
|
|
||||||
}
|
|
||||||
if patch.hotkeys.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::HOTKEY | UpdateFlags::SYSTRAY_MENU);
|
|
||||||
}
|
|
||||||
if tray_event.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR);
|
|
||||||
}
|
|
||||||
if enable_auto_light_weight.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::LIGHT_WEIGHT);
|
|
||||||
}
|
|
||||||
if tray_proxy_groups_display_mode.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
|
|
||||||
}
|
|
||||||
if log_level.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::LOG_LEVEL);
|
|
||||||
}
|
|
||||||
if log_max_size.is_some() || log_max_count.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::LOG_FILE);
|
|
||||||
}
|
|
||||||
if tray_inline_outbound_modes.is_some() {
|
|
||||||
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
|
|
||||||
}
|
|
||||||
|
|
||||||
update_flags
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::cognitive_complexity)]
|
|
||||||
async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> Result<()> {
|
|
||||||
// Process updates based on flags
|
|
||||||
if update_flags.contains(UpdateFlags::RESTART_CORE) {
|
|
||||||
Config::generate().await?;
|
|
||||||
CoreManager::global().restart_core().await?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::CLASH_CONFIG) {
|
|
||||||
CoreManager::global().update_config_checked().await?;
|
|
||||||
handle::Handle::refresh_clash();
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::LAUNCH) {
|
|
||||||
autostart::update_launch().await?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::LANGUAGE)
|
|
||||||
&& let Some(language) = &patch.language
|
|
||||||
{
|
|
||||||
clash_verge_i18n::set_locale(language.as_str());
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::SYS_PROXY) {
|
|
||||||
let manager = CoreManager::global();
|
|
||||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
|
||||||
// Turning it off only writes OS state, so it must stay available while the Core is down.
|
|
||||||
if Config::verge()
|
|
||||||
.await
|
|
||||||
.latest_arc()
|
|
||||||
.enable_system_proxy
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
manager.apply_proxy_after_start().await?;
|
|
||||||
} else {
|
|
||||||
proxy_control::apply().await?;
|
|
||||||
proxy_control::refresh_guard().await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::HOTKEY)
|
|
||||||
&& let Some(hotkeys) = &patch.hotkeys
|
|
||||||
{
|
|
||||||
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::SYSTRAY_MENU) {
|
|
||||||
tray::Tray::global().update_menu().await?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::SYSTRAY_ICON) {
|
|
||||||
tray::Tray::global()
|
|
||||||
.update_icon(&Config::verge().await.latest_arc())
|
|
||||||
.await?;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if patch.enable_tray_speed.is_some() {
|
|
||||||
tray::Tray::global().update_speed_task(patch.enable_tray_speed.unwrap_or(false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) {
|
|
||||||
tray::Tray::global().update_tooltip().await?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR) {
|
|
||||||
tray::Tray::global().update_click_behavior().await?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::LIGHT_WEIGHT) {
|
|
||||||
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
|
|
||||||
lightweight::enable_auto_light_weight_mode().await;
|
|
||||||
} else {
|
|
||||||
lightweight::disable_auto_light_weight_mode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::LOG_LEVEL) {
|
|
||||||
logger::Logger::global().update_log_level(patch.get_log_level())?;
|
|
||||||
}
|
|
||||||
if update_flags.contains(UpdateFlags::LOG_FILE) {
|
|
||||||
let log_max_size = patch.app_log_max_size.unwrap_or(128);
|
|
||||||
let log_max_count = patch.app_log_max_count.unwrap_or(8);
|
|
||||||
logger::update_log_config(log_max_size, log_max_count).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a patch, then reconcile TUN when its setting changes.
|
/// Apply a patch, then reconcile TUN when its setting changes.
|
||||||
@@ -289,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
|||||||
|
|
||||||
/// Apply a patch without post-update reconciliation.
|
/// Apply a patch without post-update reconciliation.
|
||||||
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
||||||
let config_write = Config::lock_config_write().await;
|
let config_write = Config::try_lock_config_write()?;
|
||||||
apply_verge_patch_locked(&config_write, patch, not_save_file).await
|
apply_verge_patch_locked(&config_write, patch, not_save_file).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,29 +38,15 @@ pub(super) async fn apply_verge_patch_locked(
|
|||||||
patch: &IVerge,
|
patch: &IVerge,
|
||||||
not_save_file: bool,
|
not_save_file: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let verge = Config::verge().await;
|
super::executor::apply(
|
||||||
// Hold the claim across side effects so concurrent transactions cannot share this draft.
|
_config_write,
|
||||||
let transaction = DraftTransaction::begin(vec![&verge])?;
|
super::executor::Patch::Verge {
|
||||||
verge.edit_draft(|d| d.patch_config(patch));
|
patch,
|
||||||
|
persist: !not_save_file,
|
||||||
let update_flags = determine_update_flags(patch);
|
},
|
||||||
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
|
super::effects::verge_effects(patch),
|
||||||
// A failed patch rolls back to what the user already had; it never invents a value for them.
|
)
|
||||||
process_terminated_flags(update_flags, patch).await?;
|
.await
|
||||||
transaction.commit();
|
|
||||||
announce_verge_change();
|
|
||||||
|
|
||||||
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
|
|
||||||
if !not_save_file {
|
|
||||||
// 分离数据获取和异步调用
|
|
||||||
let verge_data = verge.data_arc();
|
|
||||||
verge_data.save_file().await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn announce_verge_change() {
|
|
||||||
handle::Handle::refresh_verge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
|
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
|
||||||
|
|||||||
@@ -359,24 +359,31 @@ fn stage_core(target: &Path, package: &[u8], version: &str) -> Result<StagedCore
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_staging_file(directory: &Path, core_name: &OsStr) -> Result<(PathBuf, File)> {
|
fn create_staging_file(directory: &Path, core_name: &OsStr) -> Result<(PathBuf, File)> {
|
||||||
for _ in 0..32 {
|
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
|
||||||
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
retry_sync(
|
||||||
let path = directory.join(format!(
|
RetryPolicy::fixed(
|
||||||
".{}.{}.{generation}.tmp",
|
std::num::NonZeroUsize::MIN.saturating_add(31),
|
||||||
core_name.to_string_lossy(),
|
std::time::Duration::ZERO,
|
||||||
std::process::id()
|
),
|
||||||
));
|
|_| {
|
||||||
|
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = directory.join(format!(
|
||||||
|
".{}.{}.{generation}.tmp",
|
||||||
|
core_name.to_string_lossy(),
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
|
||||||
match File::options().write(true).create_new(true).open(&path) {
|
match File::options().write(true).create_new(true).open(&path) {
|
||||||
Ok(file) => return Ok((path, file)),
|
Ok(file) => Ok((path, file)),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
|
||||||
Err(error) => {
|
anyhow::anyhow!("failed to create a unique staging file in {}", directory.display()),
|
||||||
return Err(error).with_context(|| format!("failed to create {}", path.display()));
|
)),
|
||||||
|
Err(error) => Err(RetryError::Stop(
|
||||||
|
anyhow::Error::from(error).context(format!("failed to create {}", path.display())),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
|
|
||||||
bail!("failed to create a unique staging file in {}", directory.display())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{Result, ensure};
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -10,7 +11,7 @@ use crate::{
|
|||||||
profiles::PROFILE_WRITE_LOCK,
|
profiles::PROFILE_WRITE_LOCK,
|
||||||
},
|
},
|
||||||
constants::files::DNS_CONFIG,
|
constants::files::DNS_CONFIG,
|
||||||
core::{CoreManager, handle::Handle},
|
core::CoreManager,
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,8 +75,10 @@ pub async fn set_dns_override(
|
|||||||
}
|
}
|
||||||
|
|
||||||
transaction.commit();
|
transaction.commit();
|
||||||
Handle::refresh_verge();
|
// Core and memory state remain applied even when persistence fails.
|
||||||
Handle::refresh_clash();
|
let result = verge.data_arc().save_file().await;
|
||||||
verge.data_arc().save_file().await?;
|
announce(Refresh::Verge);
|
||||||
|
announce(Refresh::Clash);
|
||||||
|
result?;
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
use crate::config::IVerge;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// Variant order defines execution dependency order.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub(super) enum Effect {
|
||||||
|
RestartCore,
|
||||||
|
ClashConfig,
|
||||||
|
Autostart,
|
||||||
|
Language,
|
||||||
|
SystemProxy,
|
||||||
|
Hotkey,
|
||||||
|
TrayMenu,
|
||||||
|
TrayIcon,
|
||||||
|
TrayTooltip,
|
||||||
|
TrayClick,
|
||||||
|
Lightweight,
|
||||||
|
LogLevel,
|
||||||
|
LogFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) type Effects = BTreeSet<Effect>;
|
||||||
|
|
||||||
|
macro_rules! select_field {
|
||||||
|
($effects:ident, $field:ident, NoEffect) => { let _ = $field; };
|
||||||
|
($effects:ident, $field:ident, [$($effect:ident),*]) => {
|
||||||
|
if $field.is_some() { $effects.extend([$(Effect::$effect),*]); }
|
||||||
|
};
|
||||||
|
($effects:ident, $field:ident, Tun) => {
|
||||||
|
select_field!($effects, $field, [ClashConfig, TrayMenu, TrayTooltip, TrayIcon]);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if *$field == Some(true) { $effects.insert(Effect::RestartCore); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! verge_registry {
|
||||||
|
($( $(#[$cfg:meta])* $field:ident => $rule:tt; )*) => {
|
||||||
|
pub(super) fn verge_effects(patch: &IVerge) -> Effects {
|
||||||
|
// No rest pattern: even fields omitted by serde must be classified at compile time.
|
||||||
|
let IVerge { $( $(#[$cfg])* $field, )* } = patch;
|
||||||
|
let mut effects = Effects::new();
|
||||||
|
$( $(#[$cfg])* { select_field!(effects, $field, $rule); } )*
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
verge_registry! {
|
||||||
|
app_log_level => [LogLevel];
|
||||||
|
app_log_max_size => [LogFile];
|
||||||
|
app_log_max_count => [LogFile];
|
||||||
|
language => [Language, TrayMenu, TrayTooltip];
|
||||||
|
theme_mode => NoEffect;
|
||||||
|
tray_event => [TrayClick];
|
||||||
|
env_type => NoEffect;
|
||||||
|
start_page => NoEffect;
|
||||||
|
startup_script => NoEffect;
|
||||||
|
traffic_graph => NoEffect;
|
||||||
|
enable_memory_usage => NoEffect;
|
||||||
|
enable_group_icon => NoEffect;
|
||||||
|
pause_render_traffic_stats_on_blur => NoEffect;
|
||||||
|
common_tray_icon => [TrayIcon];
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
tray_icon => [TrayIcon];
|
||||||
|
menu_icon => NoEffect;
|
||||||
|
menu_order => NoEffect;
|
||||||
|
proxy_group_tools_position => NoEffect;
|
||||||
|
notice_position => NoEffect;
|
||||||
|
collapse_navbar => NoEffect;
|
||||||
|
sysproxy_tray_icon => [TrayIcon];
|
||||||
|
tun_tray_icon => [TrayIcon];
|
||||||
|
enable_tun_mode => Tun;
|
||||||
|
enable_auto_launch => [Autostart];
|
||||||
|
enable_silent_start => NoEffect;
|
||||||
|
enable_system_proxy => [SystemProxy, TrayMenu, TrayTooltip, TrayIcon];
|
||||||
|
enable_proxy_guard => [SystemProxy];
|
||||||
|
enable_bypass_check => NoEffect;
|
||||||
|
enable_dns_settings => NoEffect;
|
||||||
|
profile_dns_settings => NoEffect;
|
||||||
|
use_default_bypass => [SystemProxy];
|
||||||
|
system_proxy_bypass => [SystemProxy];
|
||||||
|
proxy_guard_duration => [SystemProxy];
|
||||||
|
proxy_auto_config => [SystemProxy];
|
||||||
|
pac_file_content => [SystemProxy];
|
||||||
|
proxy_host => [SystemProxy];
|
||||||
|
theme_setting => NoEffect;
|
||||||
|
web_ui_list => NoEffect;
|
||||||
|
clash_core => NoEffect;
|
||||||
|
hotkeys => [Hotkey, TrayMenu];
|
||||||
|
enable_global_hotkey => NoEffect;
|
||||||
|
home_cards => NoEffect;
|
||||||
|
auto_close_connection => NoEffect;
|
||||||
|
auto_check_update => NoEffect;
|
||||||
|
default_latency_test => NoEffect;
|
||||||
|
default_latency_timeout => NoEffect;
|
||||||
|
enable_auto_delay_detection => NoEffect;
|
||||||
|
auto_delay_detection_interval_minutes => NoEffect;
|
||||||
|
enable_builtin_enhanced => NoEffect;
|
||||||
|
proxy_layout_column => NoEffect;
|
||||||
|
test_list => NoEffect;
|
||||||
|
auto_log_clean => NoEffect;
|
||||||
|
enable_auto_backup_schedule => NoEffect;
|
||||||
|
auto_backup_interval_hours => NoEffect;
|
||||||
|
auto_backup_on_change => NoEffect;
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
verge_redir_port => [RestartCore];
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
verge_redir_enabled => [RestartCore];
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
verge_tproxy_port => [RestartCore];
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
verge_tproxy_enabled => [RestartCore];
|
||||||
|
verge_mixed_port => [RestartCore];
|
||||||
|
verge_socks_port => [RestartCore];
|
||||||
|
verge_socks_enabled => [RestartCore];
|
||||||
|
verge_port => [RestartCore];
|
||||||
|
verge_http_enabled => [RestartCore];
|
||||||
|
webdav_url => NoEffect;
|
||||||
|
webdav_username => NoEffect;
|
||||||
|
webdav_password => NoEffect;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
enable_tray_speed => [TrayIcon];
|
||||||
|
tray_proxy_groups_display_mode => [TrayMenu];
|
||||||
|
tray_inline_outbound_modes => [TrayMenu];
|
||||||
|
enable_auto_light_weight_mode => [Lightweight];
|
||||||
|
auto_light_weight_minutes => NoEffect;
|
||||||
|
enable_hover_jump_navigator => NoEffect;
|
||||||
|
hover_jump_navigator_delay => NoEffect;
|
||||||
|
enable_external_controller => [RestartCore];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn clash_effects(patch: &serde_yaml_ng::Mapping) -> Effects {
|
||||||
|
use clash_verge_logging::{Type, logging};
|
||||||
|
let mut effects = Effects::new();
|
||||||
|
for key in patch.keys() {
|
||||||
|
effects.extend(match key.as_str() {
|
||||||
|
Some("secret" | "external-controller") => &[Effect::RestartCore][..],
|
||||||
|
Some("allow-lan") => &[Effect::ClashConfig][..],
|
||||||
|
Some("mode") => &[Effect::ClashConfig, Effect::TrayMenu, Effect::TrayIcon][..],
|
||||||
|
_ => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Config,
|
||||||
|
"Unregistered Clash configuration key {key:?}; reloading configuration"
|
||||||
|
);
|
||||||
|
&[Effect::ClashConfig][..]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// These branches were exclusive: restart supersedes reload, and allow-lan precedes mode.
|
||||||
|
if effects.contains(&Effect::RestartCore) {
|
||||||
|
effects.retain(|effect| *effect == Effect::RestartCore);
|
||||||
|
} else if patch.contains_key("allow-lan") {
|
||||||
|
effects.retain(|effect| *effect == Effect::ClashConfig);
|
||||||
|
}
|
||||||
|
if patch.is_empty() {
|
||||||
|
effects.insert(Effect::ClashConfig);
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn verge_patch(mutate: impl FnOnce(&mut IVerge)) -> IVerge {
|
||||||
|
let mut patch = IVerge::default();
|
||||||
|
mutate(&mut patch);
|
||||||
|
patch
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn representative_verge_patches_map_to_frozen_effects() {
|
||||||
|
assert_eq!(verge_effects(&IVerge::default()), Effects::new());
|
||||||
|
|
||||||
|
let cases: [(IVerge, &[Effect]); 9] = [
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.verge_mixed_port = Some(27899)),
|
||||||
|
&[Effect::RestartCore],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.language = Some("zh".into())),
|
||||||
|
&[Effect::TrayMenu, Effect::TrayTooltip, Effect::Language],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.hotkeys = Some(vec!["CmdOrCtrl+K".into()])),
|
||||||
|
&[Effect::Hotkey, Effect::TrayMenu],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.enable_system_proxy = Some(true)),
|
||||||
|
&[
|
||||||
|
Effect::SystemProxy,
|
||||||
|
Effect::TrayIcon,
|
||||||
|
Effect::TrayMenu,
|
||||||
|
Effect::TrayTooltip,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.system_proxy_bypass = Some("localhost".into())),
|
||||||
|
&[Effect::SystemProxy],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.use_default_bypass = Some(false)),
|
||||||
|
&[Effect::SystemProxy],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.proxy_host = Some("localhost".into())),
|
||||||
|
&[Effect::SystemProxy],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
verge_patch(|p| p.tray_event = Some("show_main_window".into())),
|
||||||
|
&[Effect::TrayClick],
|
||||||
|
),
|
||||||
|
(verge_patch(|p| p.theme_mode = Some("dark".into())), &[]),
|
||||||
|
];
|
||||||
|
for (patch, expected) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
verge_effects(&patch),
|
||||||
|
expected.iter().copied().collect::<Effects>(),
|
||||||
|
"patch mapping drifted for {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tun = verge_patch(|p| p.enable_tun_mode = Some(true));
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
assert_eq!(
|
||||||
|
verge_effects(&tun),
|
||||||
|
[
|
||||||
|
Effect::RestartCore,
|
||||||
|
Effect::ClashConfig,
|
||||||
|
Effect::TrayIcon,
|
||||||
|
Effect::TrayMenu,
|
||||||
|
Effect::TrayTooltip,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Effects>()
|
||||||
|
);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
assert_eq!(
|
||||||
|
verge_effects(&tun),
|
||||||
|
[
|
||||||
|
Effect::ClashConfig,
|
||||||
|
Effect::TrayIcon,
|
||||||
|
Effect::TrayMenu,
|
||||||
|
Effect::TrayTooltip,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Effects>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clash_mapping(pairs: &[(&str, &str)]) -> serde_yaml_ng::Mapping {
|
||||||
|
let mut mapping = serde_yaml_ng::Mapping::new();
|
||||||
|
for (key, value) in pairs {
|
||||||
|
mapping.insert((*key).into(), (*value).into());
|
||||||
|
}
|
||||||
|
mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clash_keys_map_to_frozen_effects() {
|
||||||
|
let cases: [(serde_yaml_ng::Mapping, &[Effect]); 7] = [
|
||||||
|
(clash_mapping(&[]), &[Effect::ClashConfig]),
|
||||||
|
(clash_mapping(&[("secret", "s")]), &[Effect::RestartCore]),
|
||||||
|
(
|
||||||
|
clash_mapping(&[("mode", "rule")]),
|
||||||
|
&[Effect::ClashConfig, Effect::TrayIcon, Effect::TrayMenu],
|
||||||
|
),
|
||||||
|
(clash_mapping(&[("allow-lan", "true")]), &[Effect::ClashConfig]),
|
||||||
|
(clash_mapping(&[("not-a-known-key", "1")]), &[Effect::ClashConfig]),
|
||||||
|
// Restart supersedes reload; allow-lan precedes mode.
|
||||||
|
(
|
||||||
|
clash_mapping(&[("secret", "s"), ("mode", "rule")]),
|
||||||
|
&[Effect::RestartCore],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
clash_mapping(&[("allow-lan", "true"), ("mode", "rule")]),
|
||||||
|
&[Effect::ClashConfig],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (patch, expected) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
clash_effects(&patch),
|
||||||
|
expected.iter().copied().collect::<Effects>(),
|
||||||
|
"clash mapping drifted for {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
use super::effects::{Effect, Effects};
|
||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
|
use crate::{
|
||||||
|
config::{
|
||||||
|
Config, IVerge,
|
||||||
|
snapshot::{capture_config_files, restore_files},
|
||||||
|
},
|
||||||
|
core::auto_backup::AutoBackupManager,
|
||||||
|
core::{
|
||||||
|
CoreManager, SilentUpdater, autostart, hotkey, lightweight, logger, manager::ConfigUpdateGuard, proxy_control,
|
||||||
|
tray,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::Result;
|
||||||
|
use clash_verge_draft::DraftTransaction;
|
||||||
|
use clash_verge_logging::{Type, logging_error};
|
||||||
|
use serde_yaml_ng::Mapping;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::MutexGuard;
|
||||||
|
|
||||||
|
pub(super) enum Patch<'a> {
|
||||||
|
Verge { patch: &'a IVerge, persist: bool },
|
||||||
|
Clash(&'a Mapping),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_restart(manager: &CoreManager) -> Result<()> {
|
||||||
|
Config::generate().await?;
|
||||||
|
let previous = manager.current_core_readiness_generation();
|
||||||
|
let result = manager.restart_core_during_config_update().await;
|
||||||
|
if result.is_ok()
|
||||||
|
|| manager
|
||||||
|
.current_core_readiness_generation()
|
||||||
|
.is_some_and(|current| Some(current) != previous)
|
||||||
|
{
|
||||||
|
Config::runtime().await.apply();
|
||||||
|
}
|
||||||
|
result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_effect(
|
||||||
|
effect: Effect,
|
||||||
|
patch: &IVerge,
|
||||||
|
manager: &CoreManager,
|
||||||
|
update: &ConfigUpdateGuard<'_>,
|
||||||
|
) -> Result<()> {
|
||||||
|
match effect {
|
||||||
|
Effect::RestartCore => {
|
||||||
|
ensure_restart(manager).await?;
|
||||||
|
}
|
||||||
|
Effect::ClashConfig => {
|
||||||
|
manager.update_config_in_patch(update).await?;
|
||||||
|
}
|
||||||
|
Effect::Autostart => autostart::update_launch().await?,
|
||||||
|
Effect::Language => {
|
||||||
|
if let Some(language) = &patch.language {
|
||||||
|
clash_verge_i18n::set_locale(language.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::SystemProxy => {
|
||||||
|
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||||
|
// Disabling only writes OS state and must remain available when the Core is stopped.
|
||||||
|
if Config::verge()
|
||||||
|
.await
|
||||||
|
.latest_arc()
|
||||||
|
.enable_system_proxy
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
manager.apply_proxy_after_start().await?;
|
||||||
|
} else {
|
||||||
|
proxy_control::apply().await?;
|
||||||
|
proxy_control::refresh_guard().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::Hotkey => {
|
||||||
|
if let Some(hotkeys) = &patch.hotkeys {
|
||||||
|
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::TrayMenu => tray::Tray::global().update_menu().await?,
|
||||||
|
Effect::TrayIcon => {
|
||||||
|
tray::Tray::global()
|
||||||
|
.update_icon(&Config::verge().await.latest_arc())
|
||||||
|
.await?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some(enabled) = patch.enable_tray_speed {
|
||||||
|
tray::Tray::global().update_speed_task(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::TrayTooltip => tray::Tray::global().update_tooltip().await?,
|
||||||
|
Effect::TrayClick => tray::Tray::global().update_click_behavior().await?,
|
||||||
|
Effect::Lightweight => {
|
||||||
|
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
|
||||||
|
lightweight::enable_auto_light_weight_mode().await;
|
||||||
|
} else {
|
||||||
|
lightweight::disable_auto_light_weight_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::LogLevel => logger::Logger::global().update_log_level(patch.get_log_level())?,
|
||||||
|
Effect::LogFile => {
|
||||||
|
logger::update_log_config(
|
||||||
|
patch.app_log_max_size.unwrap_or(128),
|
||||||
|
patch.app_log_max_count.unwrap_or(8),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> {
|
||||||
|
let manager = CoreManager::global();
|
||||||
|
let update = manager.claim_config_update(config_write)?;
|
||||||
|
let clash = Config::clash().await;
|
||||||
|
let verge = Config::verge().await;
|
||||||
|
let runtime = Config::runtime().await;
|
||||||
|
let transaction = match patch {
|
||||||
|
Patch::Verge { .. } => DraftTransaction::begin(vec![&verge, &runtime])?,
|
||||||
|
Patch::Clash(_) => DraftTransaction::begin(vec![&clash, &runtime])?,
|
||||||
|
};
|
||||||
|
let original_runtime = runtime.data_arc();
|
||||||
|
let mut snapshots = capture_config_files().await?;
|
||||||
|
let empty = IVerge::default();
|
||||||
|
let verge_patch = match &patch {
|
||||||
|
Patch::Verge { patch, .. } => patch,
|
||||||
|
Patch::Clash(_) => &empty,
|
||||||
|
};
|
||||||
|
match &patch {
|
||||||
|
Patch::Verge { patch, .. } => verge.edit_draft(|draft| draft.patch_config(patch)),
|
||||||
|
Patch::Clash(patch) => clash.edit_draft(|draft| draft.patch_config(patch)),
|
||||||
|
}
|
||||||
|
let result: Result<()> = async {
|
||||||
|
for effect in effects.iter().copied() {
|
||||||
|
let result = Box::pin(ensure_effect(effect, verge_patch, manager, &update)).await;
|
||||||
|
if matches!(patch, Patch::Clash(_)) && matches!(effect, Effect::TrayMenu | Effect::TrayIcon) {
|
||||||
|
// Tray failures must not reject an applied Clash mode change.
|
||||||
|
logging_error!(Type::Tray, result);
|
||||||
|
} else {
|
||||||
|
result?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match patch {
|
||||||
|
Patch::Verge { persist: true, .. } => verge.latest_arc().save_file().await?,
|
||||||
|
Patch::Verge { persist: false, .. } => {}
|
||||||
|
Patch::Clash(_) => clash.latest_arc().save_config().await?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
transaction.rollback();
|
||||||
|
if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) {
|
||||||
|
announce(Refresh::Clash);
|
||||||
|
// The Core already loaded this runtime; restoring its file would only hide that state.
|
||||||
|
let runtime_path = crate::utils::dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG);
|
||||||
|
snapshots.retain(|snapshot| snapshot.path != runtime_path);
|
||||||
|
}
|
||||||
|
return match restore_files(&snapshots).await {
|
||||||
|
Ok(()) => Err(error),
|
||||||
|
Err(rollback_error) => Err(proxy_control::rollback_failure(error, rollback_error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
transaction.commit();
|
||||||
|
match patch {
|
||||||
|
Patch::Verge { patch, .. } => {
|
||||||
|
if patch.auto_check_update == Some(false) {
|
||||||
|
SilentUpdater::global().discard_pending();
|
||||||
|
}
|
||||||
|
if effects.contains(&Effect::ClashConfig) {
|
||||||
|
announce(Refresh::Clash);
|
||||||
|
}
|
||||||
|
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
|
||||||
|
announce(Refresh::Verge);
|
||||||
|
}
|
||||||
|
Patch::Clash(_) => announce(Refresh::Clash),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn config_claim_is_exclusive_and_released_on_drop() -> Result<()> {
|
||||||
|
let write_lock = tokio::sync::Mutex::new(());
|
||||||
|
let write = write_lock.lock().await;
|
||||||
|
let manager = CoreManager::default();
|
||||||
|
let first = manager.claim_config_update(&write)?;
|
||||||
|
assert!(manager.claim_config_update(&write).is_err());
|
||||||
|
drop(first);
|
||||||
|
assert!(manager.claim_config_update(&write).is_ok());
|
||||||
|
drop(write);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{
|
config::{
|
||||||
Config, MixedPort,
|
Config, MixedPort,
|
||||||
@@ -5,7 +6,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
core::{
|
core::{
|
||||||
CoreManager,
|
CoreManager,
|
||||||
handle::Handle,
|
|
||||||
listener::{
|
listener::{
|
||||||
ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome,
|
ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome,
|
||||||
probe_listener as probe_listener_sync, probe_proxy_port_change,
|
probe_listener as probe_listener_sync, probe_proxy_port_change,
|
||||||
@@ -126,8 +126,8 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
|||||||
transaction.commit();
|
transaction.commit();
|
||||||
// The save landed, so the port the app had borrowed is now irrelevant.
|
// The save landed, so the port the app had borrowed is now irrelevant.
|
||||||
let _ = ScopeGuard::into_inner(borrowed_port);
|
let _ = ScopeGuard::into_inner(borrowed_port);
|
||||||
Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
Handle::refresh_verge();
|
announce(Refresh::Verge);
|
||||||
logging!(info, Type::Config, "Proxy port configuration applied and persisted");
|
logging!(info, Type::Config, "Proxy port configuration applied and persisted");
|
||||||
Ok(SaveProxyPortsOutcome::Saved)
|
Ok(SaveProxyPortsOutcome::Saved)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ mod clash;
|
|||||||
mod config;
|
mod config;
|
||||||
mod core_upgrade;
|
mod core_upgrade;
|
||||||
mod dns;
|
mod dns;
|
||||||
|
mod effects;
|
||||||
|
mod executor;
|
||||||
mod icon;
|
mod icon;
|
||||||
mod listener;
|
mod listener;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::core::notify::NoticeStatus;
|
||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
use crate::{
|
use crate::{
|
||||||
cmd,
|
cmd,
|
||||||
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
|
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
|
||||||
@@ -43,7 +45,7 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
|
|||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
record_switched_node(group_name, proxy_name).await;
|
record_switched_node(group_name, proxy_name).await;
|
||||||
handle::Handle::refresh_proxy_config();
|
announce(Refresh::Proxies);
|
||||||
let _ = tray::Tray::global().update_menu().await;
|
let _ = tray::Tray::global().update_menu().await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,7 +102,7 @@ async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(uid = %uid, strategy = tracing::field::Empty))]
|
#[tracing::instrument(skip_all, level = "info", fields(uid = %uid, strategy = tracing::field::Empty, configured_elapsed = tracing::field::Empty, clash_elapsed = tracing::field::Empty, system_elapsed = tracing::field::Empty))]
|
||||||
async fn perform_profile_update(
|
async fn perform_profile_update(
|
||||||
uid: &String,
|
uid: &String,
|
||||||
url: &String,
|
url: &String,
|
||||||
@@ -108,7 +110,7 @@ async fn perform_profile_update(
|
|||||||
option: Option<&PrfOption>,
|
option: Option<&PrfOption>,
|
||||||
is_mannual_trigger: bool,
|
is_mannual_trigger: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut merged_opt = PrfOption::merge(opt, option);
|
let merged_opt = PrfOption::merge(opt, option);
|
||||||
let profiles = Config::profiles().await;
|
let profiles = Config::profiles().await;
|
||||||
let profiles_arc = profiles.latest_arc();
|
let profiles_arc = profiles.latest_arc();
|
||||||
let profile_name = profiles_arc
|
let profile_name = profiles_arc
|
||||||
@@ -117,66 +119,82 @@ async fn perform_profile_update(
|
|||||||
.and_then(|item| item.name.clone())
|
.and_then(|item| item.name.clone())
|
||||||
.unwrap_or_else(|| String::from("UnKnown Profile"));
|
.unwrap_or_else(|| String::from("UnKnown Profile"));
|
||||||
|
|
||||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await {
|
#[derive(Clone, Copy)]
|
||||||
Ok(mut item) => {
|
enum Strategy {
|
||||||
logging!(info, Type::Config, "[订阅更新] 更新订阅配置成功");
|
Configured,
|
||||||
profiles_update_item_safe(uid, &mut item).await?;
|
Clash,
|
||||||
return Ok(());
|
System,
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Config,
|
|
||||||
"[订阅更新] 直接更新失败: {},尝试使用Clash代理更新",
|
|
||||||
mask_err(&format!("{err:#}"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
merged_opt.get_or_insert_with(PrfOption::default).self_proxy = Some(true);
|
let result =
|
||||||
merged_opt.get_or_insert_with(PrfOption::default).with_proxy = Some(false);
|
crate::utils::retry::try_strategies(Strategy::Configured, [Strategy::Clash, Strategy::System], |strategy| {
|
||||||
|
let mut options = merged_opt.clone();
|
||||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await {
|
async move {
|
||||||
Ok(mut item) => {
|
let (name, success, failure) = match strategy {
|
||||||
logging!(info, Type::Config, "[订阅更新] 使用 Clash代理 更新订阅配置成功");
|
Strategy::Configured => (
|
||||||
|
"configured",
|
||||||
|
"[订阅更新] 更新订阅配置成功",
|
||||||
|
"[订阅更新] 直接更新失败: {},尝试使用Clash代理更新",
|
||||||
|
),
|
||||||
|
Strategy::Clash => (
|
||||||
|
"clash",
|
||||||
|
"[订阅更新] 使用 Clash代理 更新订阅配置成功",
|
||||||
|
"[订阅更新] Clash代理更新失败: {},尝试使用系统代理更新",
|
||||||
|
),
|
||||||
|
Strategy::System => (
|
||||||
|
"system",
|
||||||
|
"[订阅更新] 使用 系统代理 更新订阅配置成功",
|
||||||
|
"[订阅更新] 系统代理更新失败: {},所有重试均已失败",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
match strategy {
|
||||||
|
Strategy::Configured => {}
|
||||||
|
Strategy::Clash | Strategy::System => {
|
||||||
|
let option = options.get_or_insert_with(PrfOption::default);
|
||||||
|
option.self_proxy = Some(matches!(strategy, Strategy::Clash));
|
||||||
|
option.with_proxy = Some(matches!(strategy, Strategy::System));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::Span::current().record("strategy", name);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = PrfItem::from_url(url, None, None, options.as_ref()).await;
|
||||||
|
let elapsed_field = match strategy {
|
||||||
|
Strategy::Configured => "configured_elapsed",
|
||||||
|
Strategy::Clash => "clash_elapsed",
|
||||||
|
Strategy::System => "system_elapsed",
|
||||||
|
};
|
||||||
|
tracing::Span::current().record(elapsed_field, tracing::field::debug(started.elapsed()));
|
||||||
|
match &result {
|
||||||
|
Ok(_) => logging!(info, Type::Config, "{success}"),
|
||||||
|
Err(error) => logging!(
|
||||||
|
warn,
|
||||||
|
Type::Config,
|
||||||
|
"{}",
|
||||||
|
failure.replace("{}", &mask_err(&format!("{error:#}")))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let last_err = match result {
|
||||||
|
Ok((strategy, mut item)) => {
|
||||||
|
// A persistence error must not replay a successful download with another transport.
|
||||||
profiles_update_item_safe(uid, &mut item).await?;
|
profiles_update_item_safe(uid, &mut item).await?;
|
||||||
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
|
if !matches!(strategy, Strategy::Configured) {
|
||||||
|
handle::Handle::notice(NoticeStatus::UpdateWithClashProxy, profile_name.as_str());
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(error) => error,
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Config,
|
|
||||||
"[订阅更新] Clash代理更新失败: {},尝试使用系统代理更新",
|
|
||||||
mask_err(&format!("{err:#}"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
merged_opt.get_or_insert_with(PrfOption::default).self_proxy = Some(false);
|
|
||||||
merged_opt.get_or_insert_with(PrfOption::default).with_proxy = Some(true);
|
|
||||||
|
|
||||||
let last_err = match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await {
|
|
||||||
Ok(mut item) => {
|
|
||||||
logging!(info, Type::Config, "[订阅更新] 使用 系统代理 更新订阅配置成功");
|
|
||||||
profiles_update_item_safe(uid, &mut item).await?;
|
|
||||||
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Config,
|
|
||||||
"[订阅更新] 系统代理更新失败: {},所有重试均已失败",
|
|
||||||
mask_err(&format!("{err:#}"))
|
|
||||||
);
|
|
||||||
err
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let last_err = mask_err(&last_err.to_string());
|
let last_err = mask_err(&last_err.to_string());
|
||||||
if is_mannual_trigger {
|
if is_mannual_trigger {
|
||||||
handle::Handle::notice_message("update_failed_even_with_clash", format!("{profile_name} - {last_err}"));
|
handle::Handle::notice(
|
||||||
|
NoticeStatus::UpdateFailedEvenWithClash,
|
||||||
|
format!("{profile_name} - {last_err}"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
bail!(last_err)
|
bail!(last_err)
|
||||||
}
|
}
|
||||||
@@ -201,7 +219,7 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
|
|||||||
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
|
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
|
||||||
Ok(outcome) if outcome.is_valid() => {
|
Ok(outcome) if outcome.is_valid() => {
|
||||||
logging_error!(Type::Config, Config::sync_dns_override().await);
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
handle::Handle::refresh_clash();
|
announce(Refresh::Clash);
|
||||||
}
|
}
|
||||||
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
|
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
|
||||||
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
|
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
|
||||||
@@ -218,7 +236,7 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
|
|||||||
message
|
message
|
||||||
};
|
};
|
||||||
logging!(error, Type::Config, "[订阅更新] 更新失败: {}", message);
|
logging!(error, Type::Config, "[订阅更新] 更新失败: {}", message);
|
||||||
handle::Handle::notice_message("update_failed", &message);
|
handle::Handle::notice(NoticeStatus::UpdateFailed, message.as_str());
|
||||||
bail!(message);
|
bail!(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::core::notify::{Refresh, announce};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IVerge, MixedPort},
|
config::{Config, IVerge, MixedPort},
|
||||||
core::{
|
core::{
|
||||||
@@ -83,7 +84,7 @@ pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
handle::Handle::refresh_verge();
|
announce(Refresh::Verge);
|
||||||
// Reconciliation may immediately disable unavailable TUN; report the resulting state.
|
// Reconciliation may immediately disable unavailable TUN; report the resulting state.
|
||||||
Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
|
Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user