Compare commits

..
Author SHA1 Message Date
github-actions[bot] 84d8f702af Pre-Release: v2.5.9-rc 2026-11-02 08:52:52 +00:00
Tunglies 067a9f7cad fix(webdav): allow clearing saved configuration
Close #8495
2026-11-02 03:26:36 +08:00
109 changed files with 2409 additions and 1508 deletions
+53 -8
View File
@@ -157,12 +157,15 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -185,6 +188,14 @@ jobs:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -193,6 +204,12 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v1
env:
@@ -243,12 +260,15 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -265,6 +285,14 @@ jobs:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -317,6 +345,12 @@ jobs:
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
@@ -370,18 +404,15 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -398,6 +429,14 @@ jobs:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -413,6 +452,12 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows
id: build
uses: tauri-apps/tauri-action@v1
+10 -2
View File
@@ -77,12 +77,11 @@ jobs:
uses: dtolnay/rust-toolchain@1.99.1
- name: Rust Cache
if: github.event.inputs[matrix.input] == 'true'
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -107,6 +106,15 @@ jobs:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
lookup-only: true
- name: Pnpm install and check
if: github.event.inputs[matrix.input] == 'true'
run: |
+8
View File
@@ -39,6 +39,14 @@ jobs:
node-version: '26.13.0'
cache: 'pnpm'
- name: Restore pnpm cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
restore-keys: |
pnpm-shared-stable-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- name: Run Prettier check
+45 -21
View File
@@ -206,12 +206,15 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -223,22 +226,27 @@ jobs:
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
pnpm run prebuild ${{ matrix.target }}
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
uses: tauri-apps/tauri-action@v1.0.0
env:
@@ -311,27 +319,29 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Pnpm install and check
run: |
@@ -382,6 +392,12 @@ jobs:
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
@@ -442,27 +458,29 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Pnpm install and check
run: |
@@ -476,6 +494,12 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
uses: tauri-apps/tauri-action@v1.0.0
env:
Generated
+4 -3
View File
@@ -1081,7 +1081,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.6.0"
version = "2.5.9-rc"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1111,6 +1111,7 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify",
"nanoid",
"network-interface",
"objc2",
@@ -1228,8 +1229,8 @@ dependencies = [
[[package]]
name = "clash_verge_service_ipc"
version = "2.7.7"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.7#3ed29700b093190b5e7addcaa892e77f172af544"
version = "2.7.6"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6"
dependencies = [
"anyhow",
"kode-bridge",
+28 -23
View File
@@ -1,41 +1,46 @@
<!-- Release-notes skeleton. Entries accumulate in ./Changelog.md as PRs land;
before publishing a release, distill them:
- user-visible changes only — no dev tooling, CI fixes, or refactors whose
effect is already covered by another entry
- describe outcomes, not internals: no internal module names, IPC or
protocol details, or implementation mechanics — write what the user
observes, not how the code achieves it
- one line per theme: merge entries that share a root cause
- each entry fits a single readable line (about 60 CJK characters); if it
overflows, it is packing multiple themes or detail that belongs in the
linked issue/PR — split it or drop the detail
- line grammar: open with a verb (修复/新增/优化), with the platform
first (Windows/macOS/Linux) when the change is platform-specific;
merge sub-symptoms as 主题:症状、症状; full-width punctuation,
「」 for UI labels, backticks for config keys and commands
- platform entries sit under their platform heading (bold, emoji
prefixed, ordered Windows/macOS/Linux, combined for pairs like
macOS/Linux) at the end of their section;
scripts-workflow/group_platforms.sh regroups, --check verifies
HTML comments are stripped by the update dialog's sanitizer and hidden on
GitHub, so they never reach users. -->
## v2.6.0
## v2.5.9
- **Mihomo(Meta) 内核升级至 v1.19.33**
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复检测更新后又发布新版时,应用内更新失败
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
- 修复 重启应用后定时自动备份不执行的问题
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
- 修复 WebDAV 设置清空后无法保存
**🍎 macOS**
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
-4
View File
@@ -102,10 +102,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
To run the development server, execute the following commands after all prerequisites for **Tauri** are installed:
`pnpm dev` also requires a matching `clash-verge-service-ipc` source checkout at
`../clash-verge-service-ipc`; `pnpm prebuild` downloads binaries but does not
provide this checkout. See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup details.
```shell
pnpm i
pnpm run prebuild
-77
View File
@@ -1,80 +1,3 @@
## v2.5.10
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复全局模式缺少 `GLOBAL` 策略组,无法单独选择全局出口
- 修复服务模式下,SSH 节点的 `private-key` 使用私钥文件时内核无法启动
- 修复服务模式下,内核更新的 GEO 数据库不会同步回应用目录,导致使用新分类的规则无法通过校验
- 修复订阅的 DNS 配置变化后,「DNS 覆写」实际已关闭但开关仍显示开启且没有提示
**🖥️ Windows**
- Windows:修复安装程序重新安装或覆盖时未提示退出运行中应用,导致卸载报错或覆盖失败
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
</details>
## v2.5.9
- **Mihomo(Meta) 内核升级至 v1.19.33**
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
- 修复 重启应用后定时自动备份不执行的问题
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
- 修复 WebDAV 设置清空后无法保存
**🍎 macOS**
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
## v2.5.8
<details>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.6.0",
"version": "2.5.9-rc",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
+89 -89
View File
@@ -334,10 +334,10 @@ importers:
version: 13.15.10
'@vitejs/plugin-legacy':
specifier: ^8.2.3
version: 8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 6.1.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@wdio/tauri-service':
specifier: 1.5.0
version: 1.5.0(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))
@@ -424,13 +424,13 @@ importers:
version: 8.70.0(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
vite:
specifier: ^8.3.0
version: 8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
version: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite-plugin-svgr:
specifier: ^5.2.0
version: 5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
vitest:
specifier: ^5.0.1
version: 5.0.1(@types/node@26.6.5)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 5.0.1(@types/node@26.6.5)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
webdriverio:
specifier: 10.1.0
version: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
@@ -1720,12 +1720,12 @@ packages:
cpu: [x64]
os: [win32]
'@oxc-project/types@0.150.0':
resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==}
'@oxc-project/types@0.155.0':
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
'@oxc-project/types@0.156.0':
resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
cpu: [arm]
@@ -1936,98 +1936,98 @@ packages:
'@remix-run/route-pattern@0.22.1':
resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==}
'@rolldown/binding-android-arm-eabi@1.2.16':
resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==}
'@rolldown/binding-android-arm-eabi@1.2.9':
resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/binding-android-arm64@1.2.16':
resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==}
'@rolldown/binding-android-arm64@1.2.9':
resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.2.16':
resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==}
'@rolldown/binding-darwin-arm64@1.2.9':
resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.2.16':
resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==}
'@rolldown/binding-darwin-x64@1.2.9':
resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.2.16':
resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==}
'@rolldown/binding-freebsd-x64@1.2.9':
resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.2.16':
resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==}
'@rolldown/binding-linux-arm-gnueabihf@1.2.9':
resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.2.16':
resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==}
'@rolldown/binding-linux-arm64-gnu@1.2.9':
resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.2.16':
resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
'@rolldown/binding-linux-arm64-musl@1.2.9':
resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.2.16':
resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
'@rolldown/binding-linux-ppc64-gnu@1.2.9':
resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.2.16':
resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
'@rolldown/binding-linux-s390x-gnu@1.2.9':
resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.2.16':
resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
'@rolldown/binding-linux-x64-gnu@1.2.9':
resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.2.16':
resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
'@rolldown/binding-linux-x64-musl@1.2.9':
resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.2.16':
resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
'@rolldown/binding-openharmony-arm64@1.2.9':
resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-win32-arm64-msvc@1.2.16':
resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==}
'@rolldown/binding-win32-arm64-msvc@1.2.9':
resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.2.16':
resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==}
'@rolldown/binding-win32-x64-msvc@1.2.9':
resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -4527,8 +4527,8 @@ packages:
rgb2hex@0.2.5:
resolution: {integrity: sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==}
rolldown@1.2.16:
resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==}
rolldown@1.2.9:
resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -4919,8 +4919,8 @@ packages:
peerDependencies:
vite: '>=3.0.0'
vite@8.3.3:
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
vite@8.3.0:
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -6550,9 +6550,9 @@ snapshots:
'@oxc-parser/binding-win32-x64-msvc@0.155.0':
optional: true
'@oxc-project/types@0.155.0': {}
'@oxc-project/types@0.150.0': {}
'@oxc-project/types@0.156.0': {}
'@oxc-project/types@0.155.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
optional: true
@@ -6689,49 +6689,49 @@ snapshots:
'@remix-run/route-pattern@0.22.1': {}
'@rolldown/binding-android-arm-eabi@1.2.16':
'@rolldown/binding-android-arm-eabi@1.2.9':
optional: true
'@rolldown/binding-android-arm64@1.2.16':
'@rolldown/binding-android-arm64@1.2.9':
optional: true
'@rolldown/binding-darwin-arm64@1.2.16':
'@rolldown/binding-darwin-arm64@1.2.9':
optional: true
'@rolldown/binding-darwin-x64@1.2.16':
'@rolldown/binding-darwin-x64@1.2.9':
optional: true
'@rolldown/binding-freebsd-x64@1.2.16':
'@rolldown/binding-freebsd-x64@1.2.9':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.2.16':
'@rolldown/binding-linux-arm-gnueabihf@1.2.9':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.2.16':
'@rolldown/binding-linux-arm64-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-arm64-musl@1.2.16':
'@rolldown/binding-linux-arm64-musl@1.2.9':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.2.16':
'@rolldown/binding-linux-ppc64-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.2.16':
'@rolldown/binding-linux-s390x-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-x64-gnu@1.2.16':
'@rolldown/binding-linux-x64-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-x64-musl@1.2.16':
'@rolldown/binding-linux-x64-musl@1.2.9':
optional: true
'@rolldown/binding-openharmony-arm64@1.2.16':
'@rolldown/binding-openharmony-arm64@1.2.9':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.2.16':
'@rolldown/binding-win32-arm64-msvc@1.2.9':
optional: true
'@rolldown/binding-win32-x64-msvc@1.2.16':
'@rolldown/binding-win32-x64-msvc@1.2.9':
optional: true
'@rolldown/pluginutils@1.0.1': {}
@@ -7186,7 +7186,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitejs/plugin-legacy@8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitejs/plugin-legacy@8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
@@ -7201,23 +7201,23 @@ snapshots:
regenerator-runtime: 0.14.1
systemjs: 6.15.1
terser: 5.51.2
vite: 8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@6.1.1(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
'@vitest/mocker@5.0.1(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.4.1
optionalDependencies:
vite: 8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -9524,26 +9524,26 @@ snapshots:
rgb2hex@0.2.5: {}
rolldown@1.2.16:
rolldown@1.2.9:
dependencies:
'@oxc-project/types': 0.156.0
'@oxc-project/types': 0.150.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.16
'@rolldown/binding-android-arm64': 1.2.16
'@rolldown/binding-darwin-arm64': 1.2.16
'@rolldown/binding-darwin-x64': 1.2.16
'@rolldown/binding-freebsd-x64': 1.2.16
'@rolldown/binding-linux-arm-gnueabihf': 1.2.16
'@rolldown/binding-linux-arm64-gnu': 1.2.16
'@rolldown/binding-linux-arm64-musl': 1.2.16
'@rolldown/binding-linux-ppc64-gnu': 1.2.16
'@rolldown/binding-linux-s390x-gnu': 1.2.16
'@rolldown/binding-linux-x64-gnu': 1.2.16
'@rolldown/binding-linux-x64-musl': 1.2.16
'@rolldown/binding-openharmony-arm64': 1.2.16
'@rolldown/binding-win32-arm64-msvc': 1.2.16
'@rolldown/binding-win32-x64-msvc': 1.2.16
'@rolldown/binding-android-arm-eabi': 1.2.9
'@rolldown/binding-android-arm64': 1.2.9
'@rolldown/binding-darwin-arm64': 1.2.9
'@rolldown/binding-darwin-x64': 1.2.9
'@rolldown/binding-freebsd-x64': 1.2.9
'@rolldown/binding-linux-arm-gnueabihf': 1.2.9
'@rolldown/binding-linux-arm64-gnu': 1.2.9
'@rolldown/binding-linux-arm64-musl': 1.2.9
'@rolldown/binding-linux-ppc64-gnu': 1.2.9
'@rolldown/binding-linux-s390x-gnu': 1.2.9
'@rolldown/binding-linux-x64-gnu': 1.2.9
'@rolldown/binding-linux-x64-musl': 1.2.9
'@rolldown/binding-openharmony-arm64': 1.2.9
'@rolldown/binding-win32-arm64-msvc': 1.2.9
'@rolldown/binding-win32-x64-msvc': 1.2.9
safaridriver@3.0.0: {}
@@ -9933,23 +9933,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-svgr@5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
vite-plugin-svgr@5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
'@rollup/pluginutils': 5.4.0
'@svgr/core': 8.1.0(supports-color@7.2.0)(typescript@6.0.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)
vite: 8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1):
vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.16
rolldown: 1.2.9
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.5
@@ -9960,10 +9960,10 @@ snapshots:
terser: 5.51.2
yaml: 2.9.1
vitest@5.0.1(@types/node@26.6.5)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
vitest@5.0.1(@types/node@26.6.5)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -9974,7 +9974,7 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
+1 -4
View File
@@ -41,10 +41,7 @@ fi
tmpfile=$(mktemp)
{
node -e '
const fs = require("node:fs");
process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(/<!--[\s\S]*?-->/g, "").trimStart());
' "$CHANGELOG"
cat "$CHANGELOG"
echo
echo ""
if [ -f "$HISTORY" ]; then
+27 -69
View File
@@ -134,31 +134,6 @@ function fetchBranches() {
)
}
function discardClosedCandidate(branch, base, prefix) {
const candidates = list(
`pulls?state=all&base=${base}&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
const latest = candidates[0]
if (!latest || candidates.some((pr) => pr.state === 'open'))
throw new Error(
`Branch ${branch} is reserved by an active or unknown candidate.`,
)
const pr = get(`pulls/${latest.number}`)
requireBotPr(pr, base, prefix)
if (pr.state !== 'closed' || pr.merged)
throw new Error(
`Close the unmerged candidate before retrying: ${pr.html_url}`,
)
updateRefs(pr.base.repo.node_id, [
{
name: `refs/heads/${branch}`,
beforeOid: pr.head.sha,
afterOid: '0'.repeat(40),
},
])
summary(`Released the unused branch from closed PR: ${pr.html_url}`)
}
function prepare() {
let refresh
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
@@ -236,15 +211,8 @@ function prepare() {
: last
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
: `${base}-rc`)
if (tags.includes(tag)) throw new Error(`Tag ${tag} already exists.`)
for (const [prefix, target] of [
['release/', 'dev'],
['publish/', 'main'],
]) {
const branch = `${prefix}${tag}`
if (refs.includes(`refs/heads/${branch}`) && branch !== refresh?.head.ref)
discardClosedCandidate(branch, target, prefix)
}
if (occupied.includes(tag))
throw new Error(`Tag ${tag} already exists or is reserved.`)
const sha = git('rev-parse', 'HEAD')
const body = releaseBody(tag, sha, tags, false)
summary(body)
@@ -345,8 +313,6 @@ function releaseBody(tag, sha, tags, publishing) {
.join('\n\n') || 'No new commits.'
return `## ${titleFor(tag)}
@clash-verge-rev/release
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
${
publishing
@@ -487,7 +453,7 @@ function publishPr() {
validateVersionCommit(tag, sha)
const branch = `publish/${tag}`
const existing = list(
`pulls?state=open&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`,
`pulls?state=all&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
if (existing.length) {
const publication = existing[0]
@@ -601,31 +567,28 @@ function publish() {
requireApproval(pr, sha)
const main = git('rev-parse', 'origin/main')
git('merge-base', '--is-ancestor', main, sha)
git(
'-c',
'user.name=github-actions[bot]',
'-c',
'user.email=41898282+github-actions[bot]@users.noreply.github.com',
'tag',
'--annotate',
'--no-sign',
'--message',
marker,
const object = api('git/tags', {
tag,
sha,
)
// GraphQL updateRefs rejects annotated tag objects; Git preserves atomicity.
git(
'push',
'--atomic',
`--force-with-lease=refs/heads/main:${main}`,
`--force-with-lease=refs/heads/${pr.head.ref}:${sha}`,
`--force-with-lease=refs/tags/${tag}:`,
'origin',
`${sha}:refs/heads/main`,
`${sha}:refs/heads/${pr.head.ref}`,
`refs/tags/${tag}:refs/tags/${tag}`,
)
message: marker,
object: sha,
type: 'commit',
})
// The tag records run ownership in the same transaction as the fast-forward.
updateRefs(pr.base.repo.node_id, [
{ name: 'refs/heads/main', beforeOid: main, afterOid: sha, force: false },
{
name: `refs/heads/${pr.head.ref}`,
beforeOid: sha,
afterOid: sha,
force: false,
},
{
name: `refs/tags/${tag}`,
beforeOid: '0'.repeat(40),
afterOid: object.sha,
force: false,
},
])
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
}
releaseOutput(tag, sha)
@@ -654,14 +617,9 @@ function validateTag() {
function prepareDraft() {
const tag = env.RELEASE_TAG
verifyTarget()
const result = run('gh', [
'api',
'--paginate',
endpoint('releases?per_page=100'),
'--jq',
`.[] | select(.tag_name == ${JSON.stringify(tag)}) | {id, draft}`,
])
const release = result ? JSON.parse(result) : null
const release = list('releases?per_page=100').find(
(release) => release.tag_name === tag,
)
if (release && !release.draft)
throw new Error('This release is already public.')
const draft =
+7 -6
View File
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
}
if (!META_MAP[`${platform}-${arch}`]) {
throw new Error(`mihomo unsupported platform "${platform}-${arch}"`)
throw new Error(`clash meta unsupported platform "${platform}-${arch}"`)
}
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`)
throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`)
}
function mihomoAlpha() {
function clashMetaAlpha() {
const name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -285,7 +285,7 @@ function mihomoAlpha() {
}
}
function mihomo() {
function clashMeta() {
const name = META_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -733,12 +733,13 @@ const tasks = [
{
name: 'verge-mihomo-alpha',
func: () =>
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
retry: 5,
},
{
name: 'verge-mihomo',
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
func: () =>
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
retry: 5,
},
// After both sidecar tasks: it hashes what they downloaded.
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.6.0"
version = "2.5.9-rc"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -84,6 +84,7 @@ flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11.1"
getrandom = "0.4.3"
futures = "0.3.34"
@@ -97,7 +98,7 @@ tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", 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",
] }
arc-swap = "1.9.2"
+3 -8
View File
@@ -21,7 +21,6 @@ ManifestDPIAwareness PerMonitorV2
!include "FileAssociation.nsh"
!include "Win\COM.nsh"
!include "Win\Propkey.nsh"
!include "Win\RestartManager.nsh"
!include "WinVer.nsh"
!include "LogicLib.nsh"
!include "StrFunc.nsh"
@@ -333,8 +332,6 @@ Function PageLeaveReinstall
${EndIf}
reinst_uninstall:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
HideWindow
ClearErrors
@@ -371,10 +368,7 @@ Function PageLeaveReinstall
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
Abort
${EndIf}
Return
reinst_done:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
FunctionEnd
; 5. Choose install directory page
@@ -907,7 +901,8 @@ Section Install
nsExec::Exec 'netsh int tcp res'
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
; Ensure startup folders exist
@@ -1106,7 +1101,7 @@ Section Uninstall
!insertmacro NSIS_HOOK_PREUNINSTALL
!endif
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
!insertmacro RemoveVergeService
+3 -5
View File
@@ -1,10 +1,7 @@
use super::CmdResult;
use crate::{
cmd::StringifyErr as _,
core::{
SilentUpdater,
updater::{DownloadEvent, InstallUpdateOutcome},
},
core::{SilentUpdater, updater::DownloadEvent},
feat,
utils::dirs,
};
@@ -64,7 +61,8 @@ pub async fn install_update(
app_handle: AppHandle,
version: std::string::String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<InstallUpdateOutcome> {
) -> CmdResult<bool> {
let version: String = version.into();
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
+1
View File
@@ -205,6 +205,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
logging!(info, Type::Config, "Config regenerated successfully");
}
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash();
Ok(())
}
+5
View File
@@ -191,6 +191,10 @@ pub async fn delete_profile(index: std::string::String) -> CmdResult {
drop(config_update_guard);
drop(profile_write_guard);
if should_update {
logging_error!(Type::Config, Config::sync_dns_override().await);
}
if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
}
@@ -260,6 +264,7 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationO
handle::Handle::notify_profile_changed(current);
}
logging_error!(Type::Config, Config::sync_dns_override().await);
Ok(ValidationOutcome::Valid)
}
+2 -1
View File
@@ -10,7 +10,7 @@ use crate::{
},
utils::dirs,
};
use clash_verge_logging::{Type, logging};
use clash_verge_logging::{Type, logging, logging_error};
use smartstring::alias::String;
use tokio::fs;
@@ -168,6 +168,7 @@ async fn handle_saved_profile_file(
);
match CoreManager::global().update_config_forced().await {
Ok(outcome) if outcome.is_valid() => {
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash();
Ok(ValidationOutcome::Valid)
}
+56 -13
View File
@@ -184,41 +184,84 @@ impl IClashTemp {
_ => None,
})
}
fn guard_port_value(config: &Mapping, key: &str, default: u16) -> u16 {
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
let mut port = config
.get(key)
.get("redir-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(default);
.unwrap_or(7895);
if port == 0 {
port = default;
port = 7895;
}
port
}
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "redir-port", network::ports::DEFAULT_REDIR)
}
#[cfg(target_os = "linux")]
fn guard_tproxy_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "tproxy-port", network::ports::DEFAULT_TPROXY)
let mut port = config
.get("tproxy-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_TPROXY);
if port == 0 {
port = network::ports::DEFAULT_TPROXY;
}
port
}
fn guard_mixed_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "mixed-port", network::ports::DEFAULT_MIXED)
let raw_value = config.get("mixed-port");
let mut port = raw_value
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_MIXED);
if port == 0 {
port = network::ports::DEFAULT_MIXED;
}
port
}
fn guard_socks_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "socks-port", network::ports::DEFAULT_SOCKS)
let mut port = config
.get("socks-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7898);
if port == 0 {
port = 7898;
}
port
}
fn guard_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "port", network::ports::DEFAULT_HTTP)
let mut port = config
.get("port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7899);
if port == 0 {
port = 7899;
}
port
}
pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
-9
View File
@@ -14,15 +14,6 @@ mod verge;
pub(crate) use self::config::Config;
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
macro_rules! patch_opt_field {
($lv:expr, $rv:expr, $key:tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.clone();
}
};
}
pub(crate) use patch_opt_field;
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}
+108 -71
View File
@@ -1,5 +1,3 @@
use super::patch_opt_field;
use crate::{
config::profiles,
utils::{
@@ -133,18 +131,19 @@ impl PrfOption {
match (one, other) {
(Some(a_ref), Some(b_ref)) => {
let mut result = a_ref.clone();
patch_opt_field!(result, b_ref, user_agent);
patch_opt_field!(result, b_ref, with_proxy);
patch_opt_field!(result, b_ref, self_proxy);
patch_opt_field!(result, b_ref, danger_accept_invalid_certs);
patch_opt_field!(result, b_ref, allow_auto_update);
patch_opt_field!(result, b_ref, update_interval);
patch_opt_field!(result, b_ref, merge);
patch_opt_field!(result, b_ref, script);
patch_opt_field!(result, b_ref, rules);
patch_opt_field!(result, b_ref, proxies);
patch_opt_field!(result, b_ref, groups);
patch_opt_field!(result, b_ref, timeout_seconds);
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
result.danger_accept_invalid_certs =
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
result.update_interval = b_ref.update_interval.or(result.update_interval);
result.merge = b_ref.merge.clone().or(result.merge);
result.script = b_ref.script.clone().or(result.script);
result.rules = b_ref.rules.clone().or(result.rules);
result.proxies = b_ref.proxies.clone().or(result.proxies);
result.groups = b_ref.groups.clone().or(result.groups);
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
Some(result)
}
(Some(a_ref), None) => Some(a_ref.clone()),
@@ -182,38 +181,6 @@ impl PrfItem {
}
}
async fn append_companion_item(item: &mut Self) -> Result<String> {
profiles::profiles_append_item_safe(item).await?;
item.uid
.clone()
.ok_or_else(|| anyhow::anyhow!("appended companion item has no uid"))
}
async fn ensure_companion_items(
merge: &mut Option<String>,
script: &mut Option<String>,
rules: &mut Option<String>,
proxies: &mut Option<String>,
groups: &mut Option<String>,
) -> Result<()> {
if merge.is_none() {
*merge = Some(Self::append_companion_item(&mut Self::from_merge(None)).await?);
}
if script.is_none() {
*script = Some(Self::append_companion_item(&mut Self::from_script(None)).await?);
}
if rules.is_none() {
*rules = Some(Self::append_companion_item(&mut Self::from_rules()).await?);
}
if proxies.is_none() {
*proxies = Some(Self::append_companion_item(&mut Self::from_proxies()).await?);
}
if groups.is_none() {
*groups = Some(Self::append_companion_item(&mut Self::from_groups()).await?);
}
Ok(())
}
async fn from_local(
name: String,
desc: String,
@@ -230,7 +197,31 @@ impl PrfItem {
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
let mut groups = opt_ref.and_then(|o| o.groups.clone());
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
if merge.is_none() {
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Ok(Self {
uid: Some(uid),
itype: Some("local".into()),
@@ -384,7 +375,31 @@ impl PrfItem {
bail!("profile does not contain `proxies` or `proxy-providers`");
}
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
if merge.is_none() {
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Ok(Self {
uid: Some(uid),
@@ -446,30 +461,46 @@ impl PrfItem {
}
}
fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self {
let uid = help::get_uid(prefix).into();
fn from_rules() -> Self {
let uid = help::get_uid("r").into();
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some(itype.into()),
itype: Some("rules".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(template.into()),
file_data: Some(tmpl::ITEM_RULES.into()),
..Default::default()
}
}
fn from_rules() -> Self {
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
}
fn from_proxies() -> Self {
Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES)
let uid = help::get_uid("p").into();
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("proxies".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_PROXIES.into()),
..Default::default()
}
}
fn from_groups() -> Self {
Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS)
let uid = help::get_uid("g").into();
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("groups".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_GROUPS.into()),
..Default::default()
}
}
pub(crate) async fn read_file(&self) -> Result<String> {
@@ -485,20 +516,26 @@ impl PrfItem {
}
}
macro_rules! current_field {
($name:ident, $field:ident) => {
pub(crate) fn $name(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.$field.as_ref())
}
};
}
impl PrfItem {
current_field!(current_merge, merge);
current_field!(current_script, script);
current_field!(current_rules, rules);
current_field!(current_proxies, proxies);
current_field!(current_groups, groups);
pub(crate) fn current_merge(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.merge.as_ref())
}
pub(crate) fn current_script(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.script.as_ref())
}
pub(crate) fn current_rules(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.rules.as_ref())
}
pub(crate) fn current_proxies(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.proxies.as_ref())
}
pub(crate) fn current_groups(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.groups.as_ref())
}
}
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
+62 -40
View File
@@ -1,5 +1,5 @@
use super::{
PrfOption, patch_opt_field,
PrfOption,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
};
use crate::core::notify::{Refresh, announce};
@@ -86,6 +86,14 @@ impl ProfileDeletePlan {
}
}
macro_rules! patch {
($lv: expr, $rv: expr, $key: tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.to_owned();
}
};
}
impl IProfiles {
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
@@ -236,15 +244,15 @@ impl IProfiles {
for each in items.iter_mut() {
if each.uid.as_deref() == Some(uid) {
patch_opt_field!(each, item, itype);
patch_opt_field!(each, item, name);
patch_opt_field!(each, item, desc);
patch_opt_field!(each, item, file);
patch_opt_field!(each, item, url);
patch_opt_field!(each, item, selected);
patch_opt_field!(each, item, extra);
patch_opt_field!(each, item, updated);
patch_opt_field!(each, item, option);
patch!(each, item, itype);
patch!(each, item, name);
patch!(each, item, desc);
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
self.items = Some(items);
return self.save_file().await;
@@ -412,21 +420,6 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file
profiles_append_item_safe(item).await
}
/// Applies `edit` to the committed profiles; commits only when the edit succeeds.
async fn with_profiles_edit<F, Fut>(edit: F) -> Result<()>
where
F: FnOnce(IProfiles) -> Fut + Send,
Fut: std::future::Future<Output = Result<IProfiles>> + Send,
{
Config::profiles()
.await
.with_data_modify(|profiles| async move {
let profiles = edit(profiles).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
let profiles = Config::profiles().await;
profiles_append_item_to_safe(&profiles, item).await
@@ -442,19 +435,43 @@ pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, it
}
pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> {
with_profiles_edit(|mut profiles| async move { profiles.patch_item(index, item).await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.patch_item(index, item).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> {
with_profiles_edit(|mut profiles| async move { profiles.reorder(active_id, over_id).await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.reorder(active_id, over_id).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_save_file_safe() -> Result<()> {
with_profiles_edit(|profiles| async move { profiles.save_file().await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|profiles| async move {
profiles.save_file().await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> {
with_profiles_edit(|mut profiles| async move { profiles.update_item(index, item).await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.update_item(index, item).await?;
Ok((profiles, ()))
})
.await
}
#[derive(Debug, PartialEq, Eq)]
@@ -696,21 +713,19 @@ where
Ok(())
}
fn current_item_mut(profiles: &mut IProfiles) -> Option<&mut PrfItem> {
let current = profiles.current.clone()?;
profiles
.items
.as_mut()?
.iter_mut()
.find(|item| item.uid.as_ref() == Some(&current))
}
/// 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<()> {
let group_name = String::from(group_name);
let node = String::from(node);
persist_selected_update(true, move |mut profiles| async move {
let Some(item) = current_item_mut(&mut profiles) else {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
@@ -745,7 +760,14 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
persist_selected_update(true, move |mut profiles| async move {
let Some(item) = current_item_mut(&mut profiles) else {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
+84 -76
View File
@@ -1,6 +1,6 @@
use crate::config::Config;
use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted},
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
constants::network,
utils::{dirs, help},
};
@@ -39,7 +39,7 @@ pub struct IVerge {
pub traffic_graph: Option<bool>,
/// show memory info (only for Mihomo)
/// show memory info (only for Clash Meta)
pub enable_memory_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -432,90 +432,98 @@ impl IVerge {
#[allow(clippy::cognitive_complexity)]
pub fn patch_config(&mut self, patch: &Self) {
patch_opt_field!(self, patch, app_log_level);
patch_opt_field!(self, patch, app_log_max_size);
patch_opt_field!(self, patch, app_log_max_count);
macro_rules! patch {
($key: tt) => {
if patch.$key.is_some() {
self.$key = patch.$key.clone();
}
};
}
patch_opt_field!(self, patch, language);
patch_opt_field!(self, patch, theme_mode);
patch_opt_field!(self, patch, tray_event);
patch_opt_field!(self, patch, env_type);
patch_opt_field!(self, patch, start_page);
patch_opt_field!(self, patch, startup_script);
patch_opt_field!(self, patch, traffic_graph);
patch_opt_field!(self, patch, enable_memory_usage);
patch_opt_field!(self, patch, enable_group_icon);
patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur);
patch!(app_log_level);
patch!(app_log_max_size);
patch!(app_log_max_count);
patch!(language);
patch!(theme_mode);
patch!(tray_event);
patch!(env_type);
patch!(start_page);
patch!(startup_script);
patch!(traffic_graph);
patch!(enable_memory_usage);
patch!(enable_group_icon);
patch!(pause_render_traffic_stats_on_blur);
#[cfg(target_os = "macos")]
patch_opt_field!(self, patch, tray_icon);
patch_opt_field!(self, patch, menu_icon);
patch_opt_field!(self, patch, menu_order);
patch_opt_field!(self, patch, proxy_group_tools_position);
patch_opt_field!(self, patch, notice_position);
patch_opt_field!(self, patch, collapse_navbar);
patch_opt_field!(self, patch, common_tray_icon);
patch_opt_field!(self, patch, sysproxy_tray_icon);
patch_opt_field!(self, patch, tun_tray_icon);
patch!(tray_icon);
patch!(menu_icon);
patch!(menu_order);
patch!(proxy_group_tools_position);
patch!(notice_position);
patch!(collapse_navbar);
patch!(common_tray_icon);
patch!(sysproxy_tray_icon);
patch!(tun_tray_icon);
patch_opt_field!(self, patch, enable_tun_mode);
patch_opt_field!(self, patch, enable_auto_launch);
patch_opt_field!(self, patch, enable_silent_start);
patch_opt_field!(self, patch, enable_hover_jump_navigator);
patch_opt_field!(self, patch, hover_jump_navigator_delay);
patch!(enable_tun_mode);
patch!(enable_auto_launch);
patch!(enable_silent_start);
patch!(enable_hover_jump_navigator);
patch!(hover_jump_navigator_delay);
#[cfg(not(target_os = "windows"))]
patch_opt_field!(self, patch, verge_redir_port);
patch!(verge_redir_port);
#[cfg(not(target_os = "windows"))]
patch_opt_field!(self, patch, verge_redir_enabled);
patch!(verge_redir_enabled);
#[cfg(target_os = "linux")]
patch_opt_field!(self, patch, verge_tproxy_port);
patch!(verge_tproxy_port);
#[cfg(target_os = "linux")]
patch_opt_field!(self, patch, verge_tproxy_enabled);
patch_opt_field!(self, patch, verge_mixed_port);
patch_opt_field!(self, patch, verge_socks_port);
patch_opt_field!(self, patch, verge_socks_enabled);
patch_opt_field!(self, patch, verge_port);
patch_opt_field!(self, patch, verge_http_enabled);
patch_opt_field!(self, patch, enable_system_proxy);
patch_opt_field!(self, patch, enable_proxy_guard);
patch_opt_field!(self, patch, enable_bypass_check);
patch_opt_field!(self, patch, use_default_bypass);
patch_opt_field!(self, patch, system_proxy_bypass);
patch_opt_field!(self, patch, proxy_guard_duration);
patch_opt_field!(self, patch, proxy_auto_config);
patch_opt_field!(self, patch, pac_file_content);
patch_opt_field!(self, patch, proxy_host);
patch_opt_field!(self, patch, theme_setting);
patch_opt_field!(self, patch, web_ui_list);
patch_opt_field!(self, patch, clash_core);
patch_opt_field!(self, patch, hotkeys);
patch_opt_field!(self, patch, enable_global_hotkey);
patch!(verge_tproxy_enabled);
patch!(verge_mixed_port);
patch!(verge_socks_port);
patch!(verge_socks_enabled);
patch!(verge_port);
patch!(verge_http_enabled);
patch!(enable_system_proxy);
patch!(enable_proxy_guard);
patch!(enable_bypass_check);
patch!(use_default_bypass);
patch!(system_proxy_bypass);
patch!(proxy_guard_duration);
patch!(proxy_auto_config);
patch!(pac_file_content);
patch!(proxy_host);
patch!(theme_setting);
patch!(web_ui_list);
patch!(clash_core);
patch!(hotkeys);
patch!(enable_global_hotkey);
patch_opt_field!(self, patch, auto_close_connection);
patch_opt_field!(self, patch, auto_check_update);
patch_opt_field!(self, patch, default_latency_test);
patch_opt_field!(self, patch, default_latency_timeout);
patch_opt_field!(self, patch, enable_auto_delay_detection);
patch_opt_field!(self, patch, auto_delay_detection_interval_minutes);
patch_opt_field!(self, patch, enable_builtin_enhanced);
patch_opt_field!(self, patch, proxy_layout_column);
patch_opt_field!(self, patch, test_list);
patch_opt_field!(self, patch, auto_log_clean);
patch_opt_field!(self, patch, enable_auto_backup_schedule);
patch_opt_field!(self, patch, auto_backup_interval_hours);
patch_opt_field!(self, patch, auto_backup_on_change);
patch!(auto_close_connection);
patch!(auto_check_update);
patch!(default_latency_test);
patch!(default_latency_timeout);
patch!(enable_auto_delay_detection);
patch!(auto_delay_detection_interval_minutes);
patch!(enable_builtin_enhanced);
patch!(proxy_layout_column);
patch!(test_list);
patch!(auto_log_clean);
patch!(enable_auto_backup_schedule);
patch!(auto_backup_interval_hours);
patch!(auto_backup_on_change);
patch_opt_field!(self, patch, webdav_url);
patch_opt_field!(self, patch, webdav_username);
patch_opt_field!(self, patch, webdav_password);
patch!(webdav_url);
patch!(webdav_username);
patch!(webdav_password);
#[cfg(target_os = "macos")]
patch_opt_field!(self, patch, enable_tray_speed);
patch_opt_field!(self, patch, tray_proxy_groups_display_mode);
patch_opt_field!(self, patch, tray_inline_outbound_modes);
patch_opt_field!(self, patch, enable_auto_light_weight_mode);
patch_opt_field!(self, patch, auto_light_weight_minutes);
patch_opt_field!(self, patch, enable_dns_settings);
patch_opt_field!(self, patch, home_cards);
patch_opt_field!(self, patch, enable_external_controller);
patch!(enable_tray_speed);
patch!(tray_proxy_groups_display_mode);
patch!(tray_inline_outbound_modes);
patch!(enable_auto_light_weight_mode);
patch!(auto_light_weight_minutes);
patch!(enable_dns_settings);
patch!(home_cards);
patch!(enable_external_controller);
}
pub fn get_log_level(&self) -> LevelFilter {
-7
View File
@@ -14,13 +14,6 @@ pub mod network {
}
}
pub mod sysproxy {
/// Single source of the macOS default bypass list; both proxy backends compose from it.
#[cfg(target_os = "macos")]
pub const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
}
pub mod timing {
use super::Duration;
+13 -7
View File
@@ -7,10 +7,11 @@ use crate::{
use anyhow::Result;
use chrono::Local;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use std::{
path::PathBuf,
sync::{
OnceLock,
Arc, OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering},
},
time::{Duration, UNIX_EPOCH},
@@ -81,9 +82,8 @@ impl Default for AutoBackupSettings {
}
pub struct AutoBackupManager {
settings: Arc<RwLock<AutoBackupSettings>>,
settings_tx: watch::Sender<AutoBackupSettings>,
// The watch channel is the single settings store; no duplicate copies.
settings_rx: watch::Receiver<AutoBackupSettings>,
runner_started: AtomicBool,
exec_lock: Mutex<()>,
last_backup: AtomicI64,
@@ -93,10 +93,10 @@ impl AutoBackupManager {
pub fn global() -> &'static Self {
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| {
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
Self {
settings_tx,
settings_rx,
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
settings_tx: tx,
runner_started: AtomicBool::new(false),
exec_lock: Mutex::new(()),
last_backup: AtomicI64::new(0),
@@ -106,6 +106,9 @@ impl AutoBackupManager {
pub async fn init(&self) -> Result<()> {
let settings = Self::load_settings().await;
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send_replace(settings);
self.maybe_start_runner(settings);
Ok(())
@@ -113,6 +116,9 @@ impl AutoBackupManager {
pub async fn refresh_settings(&self) -> Result<()> {
let settings = Self::load_settings().await;
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send_replace(settings);
self.maybe_start_runner(settings);
Ok(())
@@ -187,7 +193,7 @@ impl AutoBackupManager {
}
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
let snapshot = *self.settings_rx.borrow();
let snapshot = *self.settings.read();
if trigger.is_schedule() && !snapshot.schedule_enabled {
return Ok(());
+9
View File
@@ -466,3 +466,12 @@ impl Hotkey {
(del_list, add_list)
}
}
impl Drop for Hotkey {
fn drop(&mut self) {
let app_handle = handle::Handle::app_handle();
if let Err(e) = app_handle.global_shortcut().unregister_all() {
logging!(error, Type::Hotkey, "Error unregistering all hotkeys: {:?}", e);
}
}
}
+42 -53
View File
@@ -40,47 +40,8 @@ impl LightweightState {
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
struct WindowListener {
handler_id: AtomicU32,
event: &'static str,
label: &'static str,
}
impl WindowListener {
fn setup(&self, on_event: impl Fn() + Send + 'static) {
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = self.handler_id.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的{}监听", self.label);
}
let handler_id = window.listen(self.event, move |_event| on_event());
self.handler_id.store(handler_id, Ordering::Release);
}
}
fn cancel(&self) {
let id = self.handler_id.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了{}监听", self.label);
}
}
}
static WINDOW_CLOSE_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://close-requested",
label: "窗口关闭",
};
static WEBVIEW_FOCUS_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://focus",
label: "窗口焦点",
};
static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
@@ -193,27 +154,55 @@ pub async fn add_light_weight_timer() {
}
fn setup_window_close_listener() {
WINDOW_CLOSE_LISTENER.setup(|| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的窗口关闭监听");
}
let handler_id = window.listen("tauri://close-requested", move |_event| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release);
}
}
fn cancel_window_close_listener() {
WINDOW_CLOSE_LISTENER.cancel();
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口关闭监听");
}
}
fn setup_webview_focus_listener() {
WEBVIEW_FOCUS_LISTENER.setup(|| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的窗口焦点监听");
}
let handler_id = window.listen("tauri://focus", move |_event| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release);
}
}
fn cancel_webview_focus_listener() {
WEBVIEW_FOCUS_LISTENER.cancel();
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口焦点监听");
}
}
async fn setup_light_weight_timer() {
-5
View File
@@ -435,10 +435,6 @@ impl CoreManager {
{
anyhow::bail!("Sidecar did not become ready");
}
// A Sidecar that was already running never loaded this generation.
if matches!(*mode, RunningMode::NotRunning) {
Config::runtime().await.apply();
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
@@ -496,7 +492,6 @@ impl CoreManager {
{
anyhow::bail!("Sidecar did not become ready after service uninstall");
}
Config::runtime().await.apply();
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
},
+1 -5
View File
@@ -5,7 +5,7 @@ mod state;
use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logging::{LogRing, Type, logging, logging_error};
use clash_verge_logging::{LogRing, Type, logging};
use std::{
fmt,
sync::{
@@ -274,10 +274,6 @@ impl CoreManager {
pub(crate) fn finish_config_update(&self) {
self.config_update_in_progress.store(false, Ordering::Release);
// Spawned because the caller may still hold the config write lock this waits for.
crate::process::AsyncHandler::spawn(|| async {
logging_error!(Type::Config, crate::config::Config::sync_dns_override().await);
});
}
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
+10 -2
View File
@@ -6,7 +6,7 @@ use crate::{
notification::{self, FailedOperation},
runstate::RUN_STATE,
service,
sysopt::{Sysopt, format_bypass},
sysopt::Sysopt,
},
process::AsyncHandler,
utils::{
@@ -204,6 +204,8 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
}
const LOOPBACK_HOST: &str = "127.0.0.1";
const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
@@ -325,7 +327,13 @@ fn service_bypass(verge: &IVerge) -> Result<String> {
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
let mut bypass: String = format_bypass(verge.use_default_bypass.unwrap_or(true), custom).into();
let mut bypass = if custom.is_empty() {
MACOS_DEFAULT_BYPASS.to_owned()
} else if verge.use_default_bypass.unwrap_or(true) {
format!("{MACOS_DEFAULT_BYPASS},{custom}")
} else {
custom.to_owned()
};
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
Ok(bypass)
}
+1 -71
View File
@@ -4,7 +4,7 @@ use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf, Prefix};
use std::path::{Component, Path, PathBuf};
pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
@@ -165,7 +165,6 @@ fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<Runtim
&mut assets,
&mut remote_providers,
)?;
collect_ssh_private_keys(&mut config, &config_root, &mut destinations, &mut assets);
for filename in GEO_ASSETS {
let source = config_root.join(filename);
if source.is_file() && destinations.insert((*filename).to_string()) {
@@ -271,75 +270,6 @@ fn collect_provider_assets(
Ok(())
}
/// Stages file-based ssh keys, which mihomo resolves against the core home directory.
fn collect_ssh_private_keys(
config: &mut Value,
config_root: &Path,
destinations: &mut HashSet<String>,
assets: &mut Vec<RuntimeAsset>,
) {
let Some(config) = config.as_mapping_mut() else {
return;
};
// mihomo parses `payload` for every provider type, eagerly as fallback proxies for file and http.
let mut proxy_lists = Vec::new();
for (key, value) in config.iter_mut() {
match key.as_str() {
Some("proxies") => proxy_lists.extend(value.as_sequence_mut()),
Some("proxy-providers") => proxy_lists.extend(
value
.as_mapping_mut()
.into_iter()
.flat_map(Mapping::values_mut)
.filter_map(|provider| provider.get_mut("payload").and_then(Value::as_sequence_mut)),
),
_ => {}
}
}
for proxy in proxy_lists.into_iter().flatten().filter_map(Value::as_mapping_mut) {
if proxy.get("type").and_then(Value::as_str) != Some("ssh") {
continue;
}
// mihomo reads any value containing "PRIVATE KEY" as inline key material.
let Some(raw_path) = proxy
.get("private-key")
.and_then(Value::as_str)
.filter(|key| !key.is_empty() && !key.contains("PRIVATE KEY") && is_local_path(key))
else {
continue;
};
let Some(source) = local_provider_source(config_root, raw_path)
.ok()
.filter(|source| source.is_file())
else {
let name = proxy.get("name").and_then(Value::as_str).unwrap_or_default();
logging!(
warn,
Type::Config,
"ssh proxy {name:?} private key is unavailable below the config root; leaving it to the core"
);
continue;
};
// A root-level alias keeps the user's path away from service name rules and mihomo's inline-key test.
let source = source.to_string_lossy().into_owned();
let destination = allocate_provider_destination("ssh-private-key", &source, destinations);
assets.push(RuntimeAsset {
source,
destination: destination.clone(),
});
proxy.insert(Value::String("private-key".to_owned()), Value::String(destination));
}
}
/// Resolving a UNC or device path would authenticate to its server, so those stay with the core.
fn is_local_path(raw_path: &str) -> bool {
match Path::new(raw_path).components().next() {
Some(Component::Prefix(prefix)) => matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_)),
_ => true,
}
}
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
let path = Path::new(raw_path);
let source = if path.is_absolute() {
+41 -111
View File
@@ -13,7 +13,7 @@ use crate::{
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
},
runtime_bundle::{GEO_ASSETS, RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
tray::Tray,
},
process::AsyncHandler,
@@ -101,7 +101,6 @@ struct ActiveServiceSession {
proof: OwnerSessionProof,
supports_runtime_staging: bool,
supports_runtime_file_read: bool,
supports_runtime_geo_read: bool,
}
fn generate_service_session_token() -> Result<String> {
@@ -133,13 +132,6 @@ pub(crate) fn active_service_supports_runtime_file_read() -> bool {
.is_some_and(|session| session.supports_runtime_file_read)
}
fn active_service_supports_runtime_geo_read() -> bool {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.is_some_and(|session| session.supports_runtime_geo_read)
}
pub(crate) fn clear_active_service_session() {
ACTIVE_SERVICE_SESSION.lock().take();
}
@@ -148,7 +140,6 @@ pub(crate) fn clear_active_service_session() {
struct ServiceCapabilities {
runtime_staging: bool,
runtime_file_read: bool,
runtime_geo_read: bool,
}
impl ServiceCapabilities {
@@ -156,7 +147,6 @@ impl ServiceCapabilities {
Self {
runtime_staging: info.supports_runtime_staging(),
runtime_file_read: info.supports_runtime_file_read(),
runtime_geo_read: info.supports_runtime_geo_read(),
}
}
}
@@ -603,71 +593,50 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R
}
#[cfg(target_os = "windows")]
pub(super) fn run_maybe_elevated(program: &Path, hide_window: bool) -> Result<std::process::ExitStatus> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::os::windows::process::CommandExt as _;
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
let status = match level {
PrivilegeLevel::NotPrivileged => {
let mut command = RunasCommand::new(program);
if hide_window {
command.show(false);
}
command.status()?
}
_ => {
let mut command = StdCommand::new(program);
if hide_window {
// 0x08000000 is CREATE_NO_WINDOW, the direct counterpart of runas's show(false).
command.creation_flags(0x08000000);
}
command.status()?
}
};
Ok(status)
}
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
fn uninstaller_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
let uninstall_path = packaged_service_tool_path(file_name, packaged_path)?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
Ok(uninstall_path)
}
fn ensure_uninstall_success(status: &std::process::ExitStatus) -> Result<()> {
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
let status = match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?,
_ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?,
};
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
let status = run_maybe_elevated(&uninstall_path, true)?;
ensure_uninstall_success(&status)
}
#[cfg(target_os = "linux")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() {
StdCommand::new(&uninstall_path).status()?
@@ -697,7 +666,14 @@ fn uninstall_service() -> Result<()> {
status.code().unwrap_or(-1)
);
ensure_uninstall_success(&status)
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "linux")]
@@ -712,10 +688,14 @@ fn linux_running_as_root() -> bool {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let uninstall_path = macos_service_tool_path(&uninstall_path)?;
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
@@ -731,7 +711,14 @@ fn uninstall_service() -> Result<()> {
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
ensure_uninstall_success(&status)
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn install_service() -> Result<()> {
@@ -969,7 +956,6 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
},
supports_runtime_staging: capabilities.runtime_staging,
supports_runtime_file_read: capabilities.runtime_file_read,
supports_runtime_geo_read: capabilities.runtime_geo_read,
});
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
@@ -1220,62 +1206,6 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
Ok(outcome)
}
/// Pulls geo databases the service core rewrote, because validation reads the app dir.
pub(crate) async fn sync_runtime_geo_by_service() {
if !active_service_supports_runtime_geo_read() {
return;
}
let (Ok(credentials), Ok(session), Ok(app_dir)) = (
current_owner_credentials(),
active_service_session(),
dirs::app_home_dir(),
) else {
return;
};
for asset in GEO_ASSETS {
if let Err(error) = sync_runtime_geo(&credentials, &session, asset, &app_dir.join(asset)).await {
logging!(warn, Type::Service, "failed to sync geo database {asset}: {error:#}");
}
}
}
async fn sync_runtime_geo(
credentials: &OwnerCredentials,
session: &OwnerSessionProof,
asset: &str,
target: &Path,
) -> Result<()> {
let Ok(local) = tokio::fs::metadata(target).await else {
return Ok(());
};
let Some((Some(_), _, Some(runtime_mtime_ns))) = read_chunk(credentials, session, asset, u64::MAX).await? else {
return Ok(());
};
// A newer local file is a change the next staging still has to copy into the runtime.
if UNIX_EPOCH + Duration::from_nanos(runtime_mtime_ns) <= local.modified()? {
return Ok(());
}
let (temp, file) = create_sync_temp(target).await?;
let result = async {
if let Fetch::Complete {
mtime_ns: Some(mtime_ns),
..
} = fetch_runtime_file(credentials, session, asset, file).await?
{
// Matching the runtime copy's time lets the service skip copying these bytes straight back.
std::fs::File::options()
.write(true)
.open(&temp)?
.set_modified(UNIX_EPOCH + Duration::from_nanos(mtime_ns))?;
tokio::fs::rename(&temp, target).await?;
}
anyhow::Ok(())
}
.await;
remove_temp(&temp).await;
result
}
// The on-disk YAML may describe a rejected apply; use the committed runtime.
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
let applied = runtime.data_arc();
+14 -19
View File
@@ -241,19 +241,20 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
}
pub(crate) struct Sysopt {
// Owns the proxy target pair; every reader and writer goes through this lock.
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
update_lock: TokioMutex<()>,
guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool,
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
guard: Arc<RwLock<GuardMonitor>>,
}
impl Default for Sysopt {
fn default() -> Self {
Self {
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
update_lock: TokioMutex::new(()),
guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false),
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
}
}
@@ -268,9 +269,10 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172
#[cfg(any(target_os = "linux", target_os = "macos"))]
static BYPASS_SEPARATOR: &str = ",";
#[cfg(target_os = "macos")]
static DEFAULT_BYPASS: &str = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS;
static DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
pub(super) fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
if custom_bypass.is_empty() {
DEFAULT_BYPASS.into()
} else if use_default {
@@ -359,7 +361,7 @@ impl Sysopt {
}
}
while self.access_guard().read().get_state().is_pendding() {
tokio::time::sleep(Duration::from_millis(10)).await;
tokio::task::yield_now().await;
}
// `start()` false may also mean a guard was already running.
!self.access_guard().read().get_state().is_stopped()
@@ -407,21 +409,18 @@ impl Sysopt {
/// configuration instead of a partially-applied one (e.g. SOCKS already
/// disabled but HTTP still enabled mid-transition).
pub(crate) async fn wait_idle(&self) {
let _ = self.proxy_state_lock.lock().await;
let _ = self.update_lock.lock().await;
}
/// init the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it"
)]
pub(super) async fn update_sysproxy(&self) -> Result<()> {
let mut proxy_state = self.proxy_state_lock.lock().await;
let _lock = self.update_lock.lock().await;
let verge = Config::verge().await.latest_arc();
// Configured, not live: this runs while the Core is being started or restarted, and
// asking a Core that is not up yet would only fall back here anyway.
let port = MixedPort::desired().await;
let pac_port = server::embedded_server_port()?;
// 先 await, 避免持有锁导致的 Send 问题
let bypass = get_bypass().await;
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
@@ -432,7 +431,7 @@ impl Sysopt {
);
let (sys, auto, guard_type) = {
let (sys, auto) = &mut *proxy_state;
let (sys, auto) = &mut *self.inner_proxy.write();
sys.host = proxy_host.into();
sys.port = port;
sys.bypass = bypass.into();
@@ -529,10 +528,6 @@ impl Sysopt {
}
/// reset the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it"
)]
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
if self
.reset_sysproxy
@@ -544,13 +539,13 @@ impl Sysopt {
defer! {
self.reset_sysproxy.store(false, Ordering::SeqCst);
}
let mut proxy_state = self.proxy_state_lock.lock().await;
let _lock = self.update_lock.lock().await;
let _guard_operation = self.guard_operation_lock.lock().await;
let drained = self.stop_proxy_guard_locked().await;
// 直接关闭所有代理
let (sys, auto) = {
let (sys, auto) = &mut *proxy_state;
let (sys, auto) = &mut *self.inner_proxy.write();
sys.enable = false;
auto.enable = false;
(sys.clone(), auto.clone())
+1 -2
View File
@@ -636,8 +636,7 @@ async fn create_tray_menu(
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
let include_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
let proxy_group_order_map = runtime_proxy_groups_order;
+55 -28
View File
@@ -18,14 +18,6 @@ pub struct SilentUpdater {
background_cancel: Mutex<Option<CancellationToken>>,
}
#[derive(Serialize, specta::Type)]
#[serde(rename_all = "snake_case")]
pub enum InstallUpdateOutcome {
Installed,
Cancelled,
UpdateChanged,
}
singleton!(SilentUpdater, SILENT_UPDATER);
impl SilentUpdater {
@@ -42,7 +34,7 @@ impl SilentUpdater {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, specta::Type)]
struct UpdateCacheMeta {
version: String,
downloaded_at: String,
@@ -109,11 +101,11 @@ impl SilentUpdater {
}
/// Returns the cached installer for `update`, deleting a cache that does not match it.
fn verified_cache(update: &Update) -> Option<Vec<u8>> {
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| {
update.verify(&bytes)?;
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
Ok(bytes)
})
} else {
@@ -135,6 +127,46 @@ impl SilentUpdater {
}
}
/// 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 {
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
return false;
@@ -359,7 +391,7 @@ impl SilentUpdater {
}
};
let Some(bytes) = Self::verified_cache(&update) else {
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
return false;
};
@@ -392,13 +424,13 @@ impl SilentUpdater {
}
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
/// A changed offer must be confirmed again before downloading or installing it.
/// `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<InstallUpdateOutcome> {
) -> Result<bool> {
let cancel = CancellationToken::new();
*self.manual_cancel.lock() = Some(cancel.clone());
let prepared = tokio::select! {
@@ -408,36 +440,31 @@ impl SilentUpdater {
// `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(prepared) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
logging!(info, Type::System, "Update v{version} cancelled");
return Ok(InstallUpdateOutcome::Cancelled);
};
let Some((update, bytes)) = prepared else {
return Ok(InstallUpdateOutcome::UpdateChanged);
return Ok(false);
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::delete_cache();
Ok(InstallUpdateOutcome::Installed)
Ok(true)
}
async fn prepare_install(
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<Option<(Update, Vec<u8>)>> {
let Some(update) = Self::check(app_handle)
) -> Result<(Update, Vec<u8>)> {
let update = Self::check(app_handle)
.await?
.filter(|update| update.version == version)
else {
return Ok(None);
};
let bytes = match Self::verified_cache(&update) {
.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(Some((update, bytes)))
Ok((update, bytes))
}
pub fn cancel_download(&self) {
@@ -614,7 +641,7 @@ impl SilentUpdater {
return Ok(());
}
if Self::verified_cache(&update).is_some() {
if Self::verified_cache(app_handle, &update).is_some() {
logging!(info, Type::System, "Silent updater: v{version} already cached");
} else {
logging!(info, Type::System, "Silent updater: downloading v{version}...");
-3
View File
@@ -324,7 +324,6 @@ impl CoreConfigValidator {
return Self::validate_file_syntax_outcome(config_path).await;
}
crate::core::service::sync_runtime_geo_by_service().await;
let is_script = match Self::is_script_file(config_path).await {
Ok(result) => result,
Err(err) => {
@@ -426,8 +425,6 @@ impl CoreConfigValidator {
}
logging!(info, Type::Validate, "生成临时配置文件用于验证");
// The test run reads the app dir, which never sees updates made by a service core.
crate::core::service::sync_runtime_geo_by_service().await;
let fingerprint = validation_fingerprint(yaml).await;
if *LAST_VALIDATED.lock() == Some(fingerprint) {
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
+11 -2
View File
@@ -1,7 +1,10 @@
#![cfg(target_os = "windows")]
use crate::{core::service, utils::dirs};
use crate::utils::dirs;
use anyhow::{Result, bail};
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::process::Command as StdCommand;
pub fn invoke_uwptools() -> Result<()> {
let resource_dir = dirs::app_resources_dir()?;
@@ -11,7 +14,13 @@ pub fn invoke_uwptools() -> Result<()> {
bail!("enableLoopback exe not found");
}
service::run_maybe_elevated(&tool_path, false)?;
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?,
_ => StdCommand::new(tool_path).status()?,
};
Ok(())
}
@@ -0,0 +1,8 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (config.mode === 'script') {
config.mode = 'rule'
}
return config
}
@@ -0,0 +1,12 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (Array.isArray(config.proxies)) {
config.proxies.forEach((p, i) => {
if (p.type === 'hysteria' && typeof p.alpn === 'string') {
config.proxies[i].alpn = [p.alpn]
}
})
}
return config
}
+67 -7
View File
@@ -22,6 +22,12 @@ pub enum ChainType {
Groups(SeqMap),
}
#[derive(Debug, Clone)]
pub enum ChainSupport {
ClashMeta,
ClashMetaAlpha,
}
// Helper trait to allow async conversion
pub trait AsyncChainItemFrom {
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
@@ -47,16 +53,70 @@ impl AsyncChainItemFrom for Option<ChainItem> {
uid,
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
}),
"rules" | "proxies" | "groups" => {
"rules" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
let data = match itype {
"rules" => ChainType::Rules(seq_map),
"proxies" => ChainType::Proxies(seq_map),
_ => ChainType::Groups(seq_map),
};
Some(ChainItem { uid, data })
Some(ChainItem {
uid,
data: ChainType::Rules(seq_map),
})
}
"proxies" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Proxies(seq_map),
})
}
"groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Groups(seq_map),
})
}
_ => None,
}
}
}
impl ChainItem {
/// 内建支持一些脚本
pub fn builtin() -> Vec<(ChainSupport, Self)> {
// meta 的一些处理
let meta_guard = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
// meta 的一些处理
let meta_guard_alpha = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn_alpha = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
vec![
(ChainSupport::ClashMeta, hy_alpn),
(ChainSupport::ClashMeta, meta_guard),
(ChainSupport::ClashMetaAlpha, hy_alpn_alpha),
(ChainSupport::ClashMetaAlpha, meta_guard_alpha),
]
}
pub fn to_script<U: Into<String>, D: Into<String>>(uid: U, data: D) -> Self {
Self {
uid: uid.into(),
data: ChainType::Script(data.into()),
}
}
}
impl ChainSupport {
pub fn is_support(&self, core: Option<&String>) -> bool {
match core {
Some(core) => matches!(
(self, core.as_str()),
(Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha")
),
None => true,
}
}
}
+12 -1
View File
@@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value {
pub fn use_lowercase(config: &Mapping) -> Mapping {
let mut lowercased = Mapping::new();
for (key, value) in config.iter() {
for (key, value) in config.into_iter() {
if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value.clone());
}
@@ -35,6 +35,17 @@ pub fn use_lowercase(config: &Mapping) -> Mapping {
lowercased
}
pub fn use_lowercase_owned(config: Mapping) -> Mapping {
let mut lowercased = Mapping::new();
for (key, value) in config {
if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value);
}
}
lowercased
}
pub fn use_sort(mut config: Mapping) -> Mapping {
let mut sorted = Mapping::new();
HANDLE_FIELDS.into_iter().for_each(|key| {
+127 -64
View File
@@ -8,7 +8,7 @@ mod tun;
use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase, use_sort},
field::{use_keys, use_lowercase_owned, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
@@ -34,6 +34,7 @@ type ResultLog = Vec<(String, String)>;
#[derive(Debug)]
struct ConfigValues {
clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool,
enable_builtin: bool,
socks_enabled: bool,
@@ -127,7 +128,8 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation;
let (enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
enable_tun_mode.unwrap_or(false),
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
@@ -146,6 +148,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
@@ -196,25 +199,35 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
let name = current_item.name.clone().unwrap_or_default();
let ProfileItems {
merge_item: default_merge,
script_item: default_script,
rules_item: default_rules,
proxies_item: default_proxies,
groups_item: default_groups,
global_merge: default_global_merge,
global_script: default_global_script,
..
} = ProfileItems::default();
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || default_merge,),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || default_proxies,),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || default_groups,),
chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,),
chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,),
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
);
Ok(ProfileItems {
@@ -230,16 +243,16 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
})
}
async fn process_override_items(
async fn process_global_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
@@ -249,11 +262,11 @@ async fn process_override_items(
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
result_map.entry(global_merge.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
if let ChainType::Script(script) = global_script.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
@@ -264,7 +277,7 @@ async fn process_override_items(
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
result_map.insert(global_script.uid, logs);
}
(config, exists_keys, result_map)
@@ -531,6 +544,46 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
config
}
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
@@ -602,12 +655,25 @@ fn merge_default_config(
config
}
fn apply_builtin_scripts(mut config: Mapping, enable_builtin: bool) -> Mapping {
fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
if enable_builtin {
// The JS path saw a lowercased view; lowercase once, only when the builtin runs.
config = use_lowercase(&config);
logging!(debug, Type::Core, "run builtin script verge_hy_alpn");
config = builtin_hy_alpn(config);
let items: Vec<_> = ChainItem::builtin()
.into_iter()
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
.map(|(_, c)| c)
.collect();
if !items.is_empty() {
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
config = use_lowercase_owned(config);
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
config = match item.uid.as_str() {
"verge_hy_alpn" => builtin_hy_alpn(config),
"verge_meta_guard" => builtin_meta_guard(config),
_ => config,
};
}
}
}
config
@@ -627,6 +693,14 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
config
}
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
if config.get("mode").and_then(Value::as_str) == Some("script") {
config.insert(Value::from("mode"), Value::from("rule"));
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
@@ -805,6 +879,7 @@ pub async fn enhance(
let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
@@ -854,13 +929,13 @@ pub async fn enhance(
tproxy_enabled,
);
let config = apply_builtin_scripts(config, enable_builtin);
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let (config, exists_keys, result_map) = process_override_items(
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
result_map,
@@ -871,7 +946,7 @@ pub async fn enhance(
)
.await;
let (config, exists_keys, result_map) = process_override_items(
let (config, exists_keys, result_map) = process_profile_items(
config,
exists_keys,
result_map,
@@ -1353,7 +1428,7 @@ mod authoritative_field_tests {
mod tests {
use super::{
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
process_override_items, use_keys,
process_global_items, process_profile_items, use_keys,
};
use std::collections::HashMap;
@@ -1401,28 +1476,22 @@ mod tests {
data: ChainType::Merge(merge.clone()),
};
let profile_name = "test-profile".into();
let (config, keys, logs) = process_override_items(
let (config, keys, logs) = process_global_items(
config,
vec![],
HashMap::new(),
merge_item("global-merge"),
ChainItem {
uid: "global-script".into(),
data: ChainType::Script(script.into()),
},
ChainItem::to_script("global-script", script),
&profile_name,
&authoritative,
)
.await;
let (config, _, logs) = process_override_items(
let (config, _, logs) = process_profile_items(
config,
keys,
logs,
merge_item("profile-merge"),
ChainItem {
uid: "profile-script".into(),
data: ChainType::Script(script.into()),
},
ChainItem::to_script("profile-script", script),
&profile_name,
&authoritative,
)
@@ -1467,19 +1536,16 @@ mod tests {
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
)),
};
let global_script = ChainItem {
uid: "Script".into(),
data: ChainType::Script(
r#"function main(config) {
let global_script = ChainItem::to_script(
"Script",
r#"function main(config) {
config["global-script-wins"] = "global-script";
config["profile-merge-wins"] = "global-script";
config["profile-script-wins"] = "global-script";
config.nested.winner = "global-script";
return config;
}"#
.into(),
),
};
}"#,
);
let profile_merge = ChainItem {
uid: "profile-merge".into(),
data: ChainType::Merge(mapping(
@@ -1487,21 +1553,18 @@ mod tests {
nested: {winner: profile-merge}}",
)),
};
let profile_script = ChainItem {
uid: "profile-script".into(),
data: ChainType::Script(
r#"function main(config) {
let profile_script = ChainItem::to_script(
"profile-script",
r#"function main(config) {
config["profile-script-wins"] = "profile-script";
config.nested.winner = "profile-script";
return config;
}"#
.into(),
),
};
}"#,
);
let profile_name = "test-profile".into();
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_override_items(
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
HashMap::new(),
@@ -1511,7 +1574,7 @@ mod tests {
&authoritative,
)
.await;
let (config, exists_keys, _) = process_override_items(
let (config, exists_keys, _) = process_profile_items(
config,
exists_keys,
result_map,
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::{process::AsyncHandler, utils::tmpl};
use super::field::use_lowercase;
use super::field::{use_lowercase, use_lowercase_owned};
use anyhow::{Error, Result};
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
use clash_verge_logging::{Type, logging};
@@ -22,7 +22,7 @@ type EvalOutcome = (Result<Mapping, std::string::String>, Vec<(String, String)>)
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
// The template is identity-plus-lowercasing; skip the Boa round-trip.
if script == tmpl::ITEM_SCRIPT {
return (use_lowercase(&config), vec![], vec![]);
return (use_lowercase_owned(config), vec![], vec![]);
}
let json = match serde_json::to_string(&use_lowercase(&config)) {
@@ -173,7 +173,7 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
}
match parse_json_safely(&result) {
Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()),
Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()),
Err(err) => {
logging!(
error,
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
.and_then(|v| v.as_str())
.unwrap_or("fake-ip");
if current_mode == "fake-ip" {
if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) {
revise!(dns_val, "enable", true);
revise!(dns_val, "ipv6", ipv6_val);
+52 -52
View File
@@ -9,7 +9,7 @@ use crate::{
};
use anyhow::{Result, anyhow};
use chrono::Utc;
use clash_verge_logging::{Type, logging};
use clash_verge_logging::{Type, logging, logging_error};
use reqwest_dav::list_cmd::ListFile;
use serde::Serialize;
use smartstring::alias::String;
@@ -33,8 +33,6 @@ async fn finalize_restored_verge_config(
webdav_username: Option<String>,
webdav_password: Option<String>,
) -> Result<()> {
let config_write = Config::lock_config_write().await;
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
restored.webdav_url = webdav_url;
@@ -45,42 +43,43 @@ async fn finalize_restored_verge_config(
let restored_clash = IClashTemp::new().await;
let clash_draft = Config::clash().await;
clash_draft.edit_draft(|d| {
*d = restored_clash;
*d = restored_clash.clone();
});
clash_draft.apply();
let restored_profiles = IProfiles::new().await;
let profiles_draft = Config::profiles().await;
profiles_draft.edit_draft(|d| {
*d = restored_profiles;
*d = restored_profiles.clone();
});
profiles_draft.apply();
let verge_draft = Config::verge().await;
verge_draft.edit_draft(|d| {
*d = restored.clone();
});
verge_draft.apply();
{
// Hold the write lock so a concurrent patch cannot stage a draft the proxy write reads.
let _config_write = Config::lock_config_write().await;
verge_draft.edit_draft(|d| {
*d = restored.clone();
});
verge_draft.apply();
// Turn it off here; a failing core side effect below would otherwise skip this step.
if !restored.enable_system_proxy.unwrap_or_default() {
let result = async {
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
proxy_control::apply().await?;
proxy_control::refresh_guard().await
}
.await;
if let Err(err) = result {
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
if SystemProxyStateUnknown::is_in(&err) {
return Err(err);
// Turn it off here; a failing core side effect below would otherwise skip this step.
if !restored.enable_system_proxy.unwrap_or_default() {
let result = async {
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
proxy_control::apply().await?;
proxy_control::refresh_guard().await
}
.await;
if let Err(err) = result {
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
if SystemProxyStateUnknown::is_in(&err) {
return Err(err);
}
}
}
}
// `patch_verge` re-acquires the configuration write lock.
drop(config_write);
// Run configuration side effects without rewriting the already-restored file.
if let Err(err) = super::patch_verge(&restored, true).await {
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
@@ -89,6 +88,7 @@ async fn finalize_restored_verge_config(
return Err(err);
}
}
logging_error!(Type::Config, Config::sync_dns_override().await);
Ok(())
}
@@ -134,34 +134,14 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
})
}
/// Backup creation strips the WebDAV credentials, so the live ones are kept.
async fn restore_backup_archive(path: PathBuf) -> Result<()> {
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let home_dir = app_home_dir()?;
AsyncHandler::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&path)?;
let mut zip = zip::ZipArchive::new(file)?;
zip.extract(&home_dir)?;
Ok(())
})
.await
.map_err(|join| anyhow!("backup extraction task failed: {join}"))??;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await
}
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge().await;
let verge_data = verge.latest_arc();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let backup_storage_path = app_home_dir()
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
.join(filename.as_str());
@@ -178,7 +158,12 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
err
})?;
let res = restore_backup_archive(backup_storage_path.clone()).await;
let value = backup_storage_path.clone();
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await;
let _ = backup_storage_path.remove_if_exists().await;
res
}
@@ -347,7 +332,22 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
return Err(anyhow!("Backup file not found: {}", filename));
}
restore_backup_archive(target_path).await
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await?;
Ok(())
}
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
+45 -11
View File
@@ -3,7 +3,7 @@ use crate::core::notify::{Refresh, announce};
use crate::{
config::{Config, MixedPort},
core::{CoreManager, handle, tray},
feat::run_exit_cleanup_flow,
feat::clean_async,
process::AsyncHandler,
utils,
};
@@ -14,8 +14,15 @@ use smartstring::alias::String;
use std::sync::{Arc, LazyLock};
#[allow(clippy::expect_used)]
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> =
LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions")));
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
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()))
.with_safe_default_protocol_versions()
.expect("Failed to set TLS versions")
.with_root_certificates(root_store)
.with_no_client_auth();
Arc::new(config)
});
pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await {
@@ -31,9 +38,38 @@ pub async fn restart_clash_core() {
pub async fn restart_app() {
logging!(debug, Type::System, "启动重启应用流程");
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppRestartCoreStopFailed).await;
handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await;
let cleanup_result = clean_async().await;
logging!(
info,
Type::System,
"资源清理完成,退出代码: {}",
if cleanup_result.all_success { 0 } else { 1 }
);
if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// 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(),
);
return;
}
@@ -67,13 +103,11 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
}
let clash = Config::clash().await;
let save_result = {
let _config_write = Config::lock_config_write().await;
clash.edit_draft(|d| d.patch_config(&mapping));
clash.apply();
clash.data_arc().save_config().await
};
if save_result.is_ok() {
clash.edit_draft(|d| d.patch_config(&mapping));
clash.apply();
let clash_data = clash.data_arc();
if clash_data.save_config().await.is_ok() {
announce(Refresh::Clash);
tray::Tray::global().update_menu_and_icon().await;
}
+25 -3
View File
@@ -7,7 +7,7 @@ use crate::{
utils::help::{mask_err, mask_url},
};
use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_logging::{Type, logging, logging_error};
use smartstring::alias::String;
/// Toggle proxy profile
@@ -47,11 +47,26 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await;
return;
}
Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}");
}
}
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name);
record_switched_node(group_name, proxy_name).await;
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "代理切换最终失败: {err:?}");
}
}
}
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
@@ -202,7 +217,10 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
if should_refresh {
logging!(debug, Type::Config, "[订阅更新] 更新内核配置");
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
Ok(outcome) if outcome.is_valid() => announce(Refresh::Clash),
Ok(outcome) if outcome.is_valid() => {
logging_error!(Type::Config, Config::sync_dns_override().await);
announce(Refresh::Clash);
}
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
}
@@ -229,5 +247,9 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
/// 增强配置
pub async fn enhance_profiles() -> Result<ValidationOutcome> {
CoreManager::global().update_config_forced().await
let outcome = CoreManager::global().update_config_forced().await?;
if outcome.is_valid() {
logging_error!(Type::Config, Config::sync_dns_override().await);
}
Ok(outcome)
}
+5 -11
View File
@@ -130,7 +130,9 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}");
}
pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> CleanupResult {
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await;
@@ -160,18 +162,11 @@ pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> C
}
}
handle::Handle::notice(
stop_failed_notice,
cleanup_result.stop_error.clone().unwrap_or_default(),
NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
}
cleanup_result
}
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppQuitCoreStopFailed).await;
utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
@@ -321,7 +316,6 @@ pub async fn hide() {
{
let _ = window.hide();
}
handle::Handle::global().set_activation_policy_accessory();
}
#[cfg(test)]
-3
View File
@@ -289,9 +289,6 @@ pub fn run() -> std::process::ExitCode {
}
pub fn handle_window_close(api: &tauri::WindowEvent) {
#[cfg(target_os = "macos")]
handle::Handle::global().set_activation_policy_accessory();
if core::handle::Handle::global().is_exiting() {
return;
}
+5 -2
View File
@@ -354,6 +354,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
Value::String("*.arpa".into()),
Value::String("time.*.com".into()),
Value::String("ntp.*.com".into()),
Value::String("time.*.com".into()),
Value::String("+.market.xiaomi.com".into()),
Value::String("localhost.ptlogin2.qq.com".into()),
Value::String("*.msftncsi.com".into()),
@@ -508,9 +509,11 @@ pub async fn init_resources() -> Result<()> {
let app_dir = dirs::app_home_dir()?;
let res_dir = dirs::app_resources_dir()?;
// app_home is created by `ensure_directories` in `init_config`, which always runs first.
if !app_dir.exists() {
std::mem::drop(fs::create_dir_all(&app_dir).await);
}
if !res_dir.exists() {
fs::create_dir_all(&res_dir).await?;
std::mem::drop(fs::create_dir_all(&res_dir).await);
}
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
+41 -41
View File
@@ -48,17 +48,6 @@ enum TlsRootMode {
StaticWebpkiRoots,
}
/// ALPN stays unset: raw TLS connectors offer none, while reqwest needs the h2 offer.
pub(crate) fn build_webpki_rooted_tls_config() -> Result<rustls::ClientConfig> {
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()))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(config)
}
pub struct NetworkManager;
impl NetworkManager {
@@ -113,7 +102,13 @@ impl NetworkManager {
}
fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> {
let mut config = build_webpki_rooted_tls_config()?;
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config =
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(config)
@@ -160,23 +155,6 @@ impl NetworkManager {
detail.contains("protocolversion") || detail.contains("protocol version")
}
/// `allow_fallback` stays false when the caller already disabled certificate verification.
async fn request_with_static_webpki_fallback<T, F, Fut>(allow_fallback: bool, mut request: F) -> Result<T>
where
F: FnMut(TlsRootMode) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
match request(TlsRootMode::PlatformVerifier).await {
Ok(value) => Ok(value),
Err(err) if allow_fallback && Self::should_retry_with_static_webpki_roots(&err) => {
request(TlsRootMode::StaticWebpkiRoots).await.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
})
}
Err(err) => Err(err),
}
}
async fn get_with_tls_mode(
&self,
url: &str,
@@ -286,21 +264,34 @@ impl NetworkManager {
user_agent: Option<String>,
accept_invalid_certs: bool,
) -> Result<HttpResponse> {
Self::request_with_static_webpki_fallback(!accept_invalid_certs, |tls_root_mode| {
let user_agent = user_agent.clone();
async move {
self.get_with_tls_mode(
let platform_result = self
.get_with_tls_mode(
url,
proxy_type,
timeout_secs,
user_agent.clone(),
accept_invalid_certs,
TlsRootMode::PlatformVerifier,
)
.await;
match platform_result {
Ok(response) => Ok(response),
Err(err) if !accept_invalid_certs && Self::should_retry_with_static_webpki_roots(&err) => self
.get_with_tls_mode(
url,
proxy_type,
timeout_secs,
user_agent,
accept_invalid_certs,
tls_root_mode,
TlsRootMode::StaticWebpkiRoots,
)
.await
}
})
.await
.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
}),
Err(err) => Err(err),
}
}
async fn get_bytes_with_tls_mode(
@@ -349,10 +340,19 @@ impl NetworkManager {
timeout_secs: Option<u64>,
max_bytes: usize,
) -> Result<Vec<u8>> {
Self::request_with_static_webpki_fallback(true, |tls_root_mode| async move {
self.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, tls_root_mode)
let platform_result = self
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::PlatformVerifier)
.await;
match platform_result {
Ok(body) => Ok(body),
Err(err) if Self::should_retry_with_static_webpki_roots(&err) => self
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::StaticWebpkiRoots)
.await
})
.await
.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
}),
Err(err) => Err(err),
}
}
}
+14
View File
@@ -1,6 +1,8 @@
use crate::core::notify::NoticeStatus;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use crate::{
config::Config,
core::{
@@ -95,6 +97,18 @@ async fn resolve_setup() {
resolve_done();
}
pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
CoreManager::global().stop_core().await?;
#[cfg(target_os = "macos")]
{
use dns::restore_public_dns;
restore_public_dns().await?;
}
Ok(())
}
fn init_scheme() {
logging_error!(Type::Setup, init::init_scheme());
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.6.0",
"version": "2.5.9-rc",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
-24
View File
@@ -21,27 +21,3 @@ These rules supplement the repository's root `AGENTS.md`.
- **Command ownership.** `services` owns backend command invocation and transport
wrappers. Components and hooks use those boundaries; plugin mutations follow
the same intent ownership as backend commands.
- **State liveness.** Every `useState`/`useRef` member has a reachable write
that differs from its initial value and a read that observes the difference.
A value that can only ever hold its initial value is a constant: declare the
constant and delete the state. A ref nothing reads is deleted, not kept.
- **Surface liveness.** Anything a module exposes — props, parameters, context
members, imperative-handle methods — ships with at least one external caller
in the same change. "Reserved for future use" fields, `_`-prefixed params
that callers still pass, and error or callback channels that every receiver
ignores are removed, not maintained.
- **Path liveness.** A fallback or branch names the concrete runtime condition
that reaches it. If no supported runtime can reach it, the branch is not
added — or it replaces the primary path. Constant subexpressions and cleanup
of resources that cannot exist yet are simplified away.
- **Effect economy.** One user interaction produces one state write and one
command. A handler does not re-apply a value its wrapper already applied,
and an effect does not re-run a refresh another branch of the same effect
just ran.
- **Mount economy.** Components that issue commands or subscriptions on mount
are mounted only where their UI is reachable. A dialog no rendered control
can open stays unmounted, not mounted-and-idle.
- **Resource bounds.** Every cache key, timer and subscription a feature
allocates is reused or released on refresh; refresh never allocates an
unbounded sequence of new keys. Retries re-create per-attempt resources so
a retry can actually succeed.
+30 -13
View File
@@ -22,16 +22,23 @@ interface BaseSplitChipEditorProps {
error?: boolean
helperText?: ReactNode
placeholder?: string
rows?: number
separator?: string
splitPattern?: RegExp
defaultMode?: BaseSplitChipEditorMode
showModeToggle?: boolean
ariaLabel?: string
addLabel?: ReactNode
emptyLabel?: ReactNode
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
renderHeader?: (modeToggle: ReactNode) => ReactNode
}
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
const splitValue = (value: string) =>
const splitValue = (value: string, splitPattern: RegExp) =>
value
.split(DEFAULT_SPLIT_PATTERN)
.split(splitPattern)
.map((item) => item.trim())
.filter(Boolean)
@@ -42,25 +49,35 @@ export const BaseSplitChipEditor = ({
error = false,
helperText,
placeholder,
rows = 4,
separator = ',',
splitPattern = DEFAULT_SPLIT_PATTERN,
defaultMode = 'visual',
showModeToggle = true,
ariaLabel,
addLabel,
emptyLabel,
modeLabels,
renderHeader,
}: BaseSplitChipEditorProps) => {
const { t } = useTranslation()
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
const [draft, setDraft] = useState('')
const resolvedLabels = useMemo(
() => ({
visual: t('shared.editorModes.visualization'),
advanced: t('shared.editorModes.advanced'),
add: t('shared.actions.new'),
empty: t('shared.statuses.empty'),
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
add: addLabel ?? t('shared.actions.new'),
empty: emptyLabel ?? t('shared.statuses.empty'),
}),
[t],
[t, modeLabels, addLabel, emptyLabel],
)
const values = useMemo(() => splitValue(value), [value])
const values = useMemo(
() => splitValue(value, splitPattern),
[value, splitPattern],
)
const items = useMemo(() => {
const counts = new Map<string, number>()
@@ -75,7 +92,7 @@ export const BaseSplitChipEditor = ({
}, [values])
const handleAddDraft = () => {
const nextValues = splitValue(draft)
const nextValues = splitValue(draft, splitPattern)
if (!nextValues.length) {
return
}
@@ -96,7 +113,7 @@ export const BaseSplitChipEditor = ({
const resolvedAriaLabel =
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
const modeToggle = (
const modeToggle = showModeToggle ? (
<Tooltip title={toggleLabel}>
<IconButton
size="small"
@@ -111,7 +128,7 @@ export const BaseSplitChipEditor = ({
<ToggleIcon fontSize="small" />
</IconButton>
</Tooltip>
)
) : null
return (
<>
@@ -185,7 +202,7 @@ export const BaseSplitChipEditor = ({
disabled={disabled}
size="small"
multiline
rows={4}
rows={rows}
sx={{ width: '100%' }}
value={value}
helperText={helperText}
+9 -3
View File
@@ -596,7 +596,7 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey],
)
const { select } = useProxySelection({
const { handleSelectChange } = useProxySelection({
onSuccess: () => {
refreshProxy()
},
@@ -766,9 +766,15 @@ export const CurrentProxyCard = () => {
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes.
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
handleSelectChange(
selectedGroup.name,
previousProxy!,
selectedGroup.fixed!,
)({
target: { value: nextName },
})
},
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
)
const goToProxies = useCallback(() => {
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
import { useVerge } from '@/hooks/use-verge'
import { debugLog } from '@/utils/debug'
import parseTraffic from '@/utils/parse-traffic'
import {
formatTrafficHourMinute,
@@ -22,7 +23,14 @@ import {
isSameTrafficData,
} from '@/utils/traffic-sampler'
interface ITrafficItem {
up: number
down: number
timestamp?: number
}
export interface EnhancedCanvasTrafficGraphRef {
appendData: (data: ITrafficItem) => void
toggleStyle: () => void
}
@@ -134,6 +142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
[],
)
const debounceTimeoutRef = useRef<number | null>(null)
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
const colors = useMemo(
() => ({
@@ -174,6 +183,8 @@ export const EnhancedCanvasTrafficGraph = memo(
if (displayData.length === 0) {
lastDataTimestampRef.current = 0
dataStaleRef.current = false
// eslint-disable-next-line @eslint-react/set-state-in-effect
setCurrentFPS(GRAPH_CONFIG.targetFPS)
return
}
@@ -189,11 +200,18 @@ export const EnhancedCanvasTrafficGraph = memo(
}
}, [displayData])
const handleFocusStateChange = useCallback((focused: boolean) => {
isWindowFocusedRef.current = focused
const handleFocusStateChange = useCallback(
(focused: boolean) => {
isWindowFocusedRef.current = focused
scheduleDrawGraphRef.current()
}, [])
if (focused || !pause_render_traffic_stats_on_blur) {
setCurrentFPS(GRAPH_CONFIG.targetFPS)
}
scheduleDrawGraphRef.current()
},
[pause_render_traffic_stats_on_blur],
)
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
@@ -666,13 +684,19 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.beginPath()
ctx.moveTo(getX(0), getY(0))
for (let i = 1; i < data.length; i++) {
const currentX = getX(i)
const currentY = getY(i)
const nextIndex = Math.min(i + 1, lastIndex)
const controlX = (currentX + getX(nextIndex)) / 2
const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
if (chartStyle === 'bezier') {
for (let i = 1; i < data.length; i++) {
const currentX = getX(i)
const currentY = getY(i)
const nextIndex = Math.min(i + 1, lastIndex)
const controlX = (currentX + getX(nextIndex)) / 2
const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
}
} else {
for (let i = 1; i < data.length; i++) {
ctx.lineTo(getX(i), getY(i))
}
}
ctx.lineTo(getX(lastIndex), height - padding.bottom)
@@ -960,7 +984,21 @@ export const EnhancedCanvasTrafficGraph = memo(
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
}, [])
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle])
const appendData = useCallback((data: ITrafficItem) => {
debugLog(
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
data,
)
}, [])
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
)
const getTimeRangeText = useCallback(() => {
return t('home.components.traffic.patterns.minutes', {
@@ -1104,7 +1142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
{t('home.components.traffic.diagnostics', {
points: displayData.length,
compressed: samplerStats.compressedBufferSize,
fps: GRAPH_CONFIG.targetFPS,
fps: currentFPS,
})}
</Box>
+6 -55
View File
@@ -3,14 +3,10 @@ import {
ArrowUpwardRounded,
CloudDownloadRounded,
CloudUploadRounded,
ErrorOutlineRounded,
LinkRounded,
MemoryRounded,
} from '@mui/icons-material'
import {
Alert,
Box,
Button,
Grid,
PaletteColor,
Paper,
@@ -19,9 +15,9 @@ import {
useTheme,
} from '@mui/material'
import { ReactNode, memo, useMemo, useRef } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
@@ -131,50 +127,6 @@ const CompactStatCard = memo(
// 添加显示名称
CompactStatCard.displayName = 'CompactStatCard'
const fallbackSx = {
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
}
const reloadPage = () => window.location.reload()
const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
const { t } = useTranslation()
return (
<Box sx={fallbackSx}>
<ErrorOutlineRounded color="error" sx={{ fontSize: 48, mb: 2 }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Alert>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" size="small" onClick={resetErrorBoundary}>
{t('shared.actions.retry')}
</Button>
<Button variant="outlined" size="small" onClick={reloadPage}>
{t('shared.actions.refreshPage')}
</Button>
</Box>
</Box>
)
}
export const EnhancedTrafficStats = () => {
const { t } = useTranslation()
const theme = useTheme()
@@ -302,11 +254,10 @@ export const EnhancedTrafficStats = () => {
}, [t, parsedData, displayMemory])
return (
<ErrorBoundary
FallbackComponent={TrafficFallback}
onError={(error, errorInfo) =>
console.error('[EnhancedTrafficStats]', error, errorInfo)
}
<TrafficErrorBoundary
onError={(error, errorInfo) => {
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
}}
>
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
{trafficGraph && (
@@ -322,6 +273,6 @@ export const EnhancedTrafficStats = () => {
</Grid>
))}
</Grid>
</ErrorBoundary>
</TrafficErrorBoundary>
)
}
+7 -12
View File
@@ -14,7 +14,7 @@ import {
useTheme,
Fade,
} from '@mui/material'
import { useState, useMemo, useCallback, memo, FC } from 'react'
import { useState, useMemo, memo, FC } from 'react'
import { useTranslation } from 'react-i18next'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
@@ -152,15 +152,10 @@ export const ProxyTunCard: FC = () => {
showNotice.error(err)
}
const handleSystemTabClick = useCallback(() => {
setActiveTab('system')
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, 'system')
}, [])
const handleTunTabClick = useCallback(() => {
setActiveTab('tun')
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, 'tun')
}, [])
const handleTabChange = (tab: string) => {
setActiveTab(tab)
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab)
}
const tabDescription = useMemo(() => {
if (activeTab === 'system') {
@@ -196,14 +191,14 @@ export const ProxyTunCard: FC = () => {
>
<TabButton
isActive={activeTab === 'system'}
onClick={handleSystemTabClick}
onClick={() => handleTabChange('system')}
icon={ComputerRounded}
label={t('settings.sections.system.toggles.systemProxy')}
hasIndicator={systemProxyIndicator}
/>
<TabButton
isActive={activeTab === 'tun'}
onClick={handleTunTabClick}
onClick={() => handleTabChange('tun')}
icon={TroubleshootRounded}
label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode! && isTunModeAvailable}
+13 -6
View File
@@ -86,11 +86,16 @@ export const SystemInfoCard = () => {
}, [verge, patchVerge])
const handleRunningModeClick = useCallback(async () => {
if (isSidecarMode) {
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
await installServiceAndRestartCore()
await mutateSystemState()
}
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState])
}, [
isSidecarMode,
isAdminMode,
installServiceAndRestartCore,
mutateSystemState,
])
const onCheckUpdate = useLockFn(async () => {
try {
@@ -117,16 +122,18 @@ export const SystemInfoCard = () => {
const runningModeStyle = useMemo(
() => ({
cursor: isSidecarMode ? 'pointer' : 'default',
textDecoration: isSidecarMode ? 'underline' : 'none',
cursor:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
display: 'flex',
alignItems: 'center',
gap: 0.5,
'&:hover': {
opacity: isSidecarMode ? 0.7 : 1,
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
},
}),
[isSidecarMode],
[isSidecarMode, isAdminMode],
)
const getModeIcon = () => {
+3 -3
View File
@@ -87,7 +87,7 @@ export const TestCard = () => {
x.uid === uid ? { ...x, ...patch } : x,
)
mutateVerge({ ...verge, test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
},
[testList, verge, mutateVerge],
)
@@ -96,7 +96,7 @@ export const TestCard = () => {
(uid: string) => {
const newList = testList.filter((x) => x.uid !== uid)
patchVerge({ test_list: newList })
mutateVerge({ ...verge, test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
},
[testList, verge, patchVerge, mutateVerge],
)
@@ -129,7 +129,7 @@ export const TestCard = () => {
newList.splice(newIndex, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => {
try {
patchVerge({ test_list: newList })
+4 -1
View File
@@ -35,7 +35,10 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
const handleMenuOrderOptimisticUpdate = useCallback(
(order: string[]) => {
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev))
mutateVerge(
(prev) => (prev ? { ...prev, menu_order: order } : prev),
false,
)
},
[mutateVerge],
)
+4 -33
View File
@@ -1,15 +1,14 @@
import {
ArrowDownwardRounded,
ArrowUpwardRounded,
ErrorOutlineRounded,
MemoryRounded,
} from '@mui/icons-material'
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
import { Box, Typography } from '@mui/material'
import { useEffect, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge'
@@ -18,30 +17,6 @@ import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from './traffic-graph'
const unavailableSx = {
p: 1,
minHeight: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}
const TrafficUnavailableFallback = () => {
const { t } = useTranslation()
return (
<Box sx={unavailableSx}>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
)
}
export const LayoutTraffic = () => {
const { t } = useTranslation()
const { verge } = useVerge()
@@ -100,12 +75,7 @@ export const LayoutTraffic = () => {
}
return (
<ErrorBoundary
FallbackComponent={TrafficUnavailableFallback}
onError={(error, errorInfo) =>
console.error('[LayoutTraffic]', error, errorInfo)
}
>
<LightweightTrafficErrorBoundary>
<Box sx={{ position: 'relative' }}>
{trafficGraph && pageVisible && (
<div
@@ -160,6 +130,7 @@ export const LayoutTraffic = () => {
cursor: 'auto',
}}
color={'disabled'}
onClick={async () => {}}
>
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
@@ -168,6 +139,6 @@ export const LayoutTraffic = () => {
)}
</Box>
</Box>
</ErrorBoundary>
</LightweightTrafficErrorBoundary>
)
}
+36 -4
View File
@@ -1,6 +1,11 @@
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from '@mui/material'
import { type PointerEvent, useCallback } from 'react'
import {
forwardRef,
type PointerEvent,
useCallback,
useImperativeHandle,
} from 'react'
import { useWindowControls } from '@/hooks/use-window'
import getSystem from '@/utils/get-system'
@@ -58,9 +63,36 @@ export const WindowResizeHandles = () => {
)
}
export const WindowControls = () => {
export const WindowControls = forwardRef(function WindowControls(props, ref) {
const OS = getSystem()
const { maximized, minimize, close, toggleMaximize } = useWindowControls()
const {
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls()
useImperativeHandle(
ref,
() => ({
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
}),
[
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
],
)
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
@@ -154,4 +186,4 @@ export const WindowControls = () => {
)}
</Box>
)
}
})
+3 -3
View File
@@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
}
}, [handleScroll])
const { select } = useProxySelection({
const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => {
onProxies()
},
@@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return
select(group.name, member.ref.name, group.now!, group.fixed!)
handleProxyGroupChange(group, { name: member.ref.name })
},
[select],
[handleProxyGroupChange],
)
const handleLocation = useStableCallback((group: ProxyGroupView) => {
@@ -30,8 +30,10 @@ export const useProxyGroupHeaderLayout = () => {
if (canceled || !operation.source || !operation.target) return
const position = toolsOnLeft ? 'right' : 'left'
mutateVerge((prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
mutateVerge(
(prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
false,
)
try {
await patchVerge({ proxy_group_tools_position: position })
+14 -14
View File
@@ -206,20 +206,17 @@ export const useRenderList = (
verge?.default_latency_timeout,
])
const renderGroups = useMemo(() => {
if (!proxyView) return []
if (mode === 'rule') return proxyView.groups
const global = proxyView.global ? [proxyView.global] : []
return mode === 'global' ? [...global, ...proxyView.groups] : global
}, [mode, proxyView])
// Every group this list draws, so a test settling in any of them re-sorts that group.
const renderedGroupNames = useMemo(() => {
if (!proxyView) return []
if (isChainMode)
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
return renderGroups.map(({ name }) => name)
}, [isChainMode, proxyView, renderGroups, selectedGroup])
return mode === 'rule' || mode === 'global'
? proxyView.groups.map(({ name }) => name)
: proxyView.global
? [proxyView.global.name]
: []
}, [isChainMode, mode, proxyView, selectedGroup])
const groupDelays = useGroupsDelays(renderedGroupNames)
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
@@ -261,7 +258,13 @@ export const useRenderList = (
}
const useRule = mode === 'rule' || mode === 'global'
const renderGroups = useRule
? proxyView.groups
: proxyView.global === null
? []
: [proxyView.global]
const cache = groupCacheRef.current
let anyChanged = false
const retList = renderGroups.flatMap((group) => {
const headState = headStates[group.name] || DEFAULT_STATE
@@ -278,6 +281,7 @@ export const useRenderList = (
return cached.items
}
anyChanged = true
const ret: IRenderItem[] = [
{
type: 0,
@@ -346,10 +350,7 @@ export const useRenderList = (
const filtered = !useRule
? retList.slice(1)
: retList.filter((item) => !item.group.hidden)
if (
prevListRef.current.length === filtered.length &&
filtered.every((item, index) => item === prevListRef.current[index])
) {
if (!anyChanged && prevListRef.current.length === filtered.length) {
return prevListRef.current
}
prevListRef.current = filtered
@@ -363,7 +364,6 @@ export const useRenderList = (
latencyTimeout,
mode,
proxyView,
renderGroups,
selectedGroup,
])
@@ -114,15 +114,17 @@ export const BackupConfigViewer = memo(
errorNotice: false,
},
)
await mutateVerge((current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
await mutateVerge(
(current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
false,
)
setWebdavStatus(signature, 'unknown')
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
+14 -4
View File
@@ -586,10 +586,20 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
? event.target.checked
: event.target.value
setValues((prev) => ({
...prev,
[field]: value,
}))
setValues((prev) => {
const newValues = {
...prev,
[field]: value,
}
if (visualization) {
setTimeout(() => {
updateYamlFromValues()
}, 0)
}
return newValues
})
}
return (
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
showNotice.error(err)
}
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
return (
+29 -18
View File
@@ -15,33 +15,44 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service'
const buildInitialValues = (verge?: IVergeConfig | null) => ({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const [open, setOpen] = useState(false)
const [values, setValues] = useState(() => buildInitialValues(null))
const [values, setValues] = useState({
appLogLevel: 'warn',
appLogMaxSize: 8,
appLogMaxCount: 12,
autoCloseConnection: true,
autoCheckUpdate: true,
enableBuiltinEnhanced: true,
proxyLayoutColumn: 6,
enableAutoDelayDetection: false,
autoDelayDetectionIntervalMinutes: 5,
defaultLatencyTest: '',
autoLogClean: 2,
defaultLatencyTimeout: 10000,
})
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValues(buildInitialValues(verge))
setValues({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
},
close: () => setOpen(false),
}))
+21 -14
View File
@@ -87,17 +87,6 @@ const splitBypass = (value?: string) =>
.map((item) => item.trim())
.filter(Boolean)
const buildInitialValues = (verge?: IVergeConfig | null) => ({
guard: verge?.enable_proxy_guard,
enable_bypass_check: verge?.enable_bypass_check ?? true,
bypass: verge?.system_proxy_bypass,
duration: verge?.proxy_guard_duration ?? 10,
use_default: verge?.use_default_bypass ?? true,
pac: verge?.proxy_auto_config,
pac_content: verge?.pac_file_content ?? DEFAULT_PAC,
proxy_host: verge?.proxy_host ?? '127.0.0.1',
})
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const systemName = getSystem()
@@ -127,7 +116,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
proxy_host,
} = verge ?? {}
const [value, setValue] = useState(() => buildInitialValues(null))
const [value, setValue] = useState({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
null,
)
@@ -194,7 +192,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValue(buildInitialValues(verge))
setValue({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
fetchNetworkInterfaces()
},
close: () => setOpen(false),
@@ -332,7 +339,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
Promise.resolve().then(async () => {
try {
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
if (Object.keys(patch).length > 0) {
await patchVerge(patch)
+18 -12
View File
@@ -76,28 +76,34 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
const buildInitialValues = (): TunnelFormValues => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const { t } = useTranslation()
const { clash, mutateClash, patchClash } = useClash()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState<TunnelFormValues>(buildInitialValues)
const [values, setValues] = useState<TunnelFormValues>({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
useImperativeHandle(ref, () => ({
open: () => {
setValues(buildInitialValues())
setValues(() => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
}))
setDraftTunnels(() => clash?.tunnels ?? [])
setOpen(true)
// 如果没有隧道,则自动展开
+42 -40
View File
@@ -1,8 +1,14 @@
import { alpha, Box, Button, LinearProgress } from '@mui/material'
import type { Update } from '@tauri-apps/plugin-updater'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
import { lazy, Suspense, useImperativeHandle, useMemo, useState } from 'react'
import {
lazy,
Suspense,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type { Options as ReactMarkdownOptions } from 'react-markdown'
@@ -174,16 +180,12 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const updateState = useUpdateState()
const setUpdateState = useSetUpdateState()
const { updateInfo: detectedUpdate, checkUpdate } = useUpdate()
const [refreshedUpdate, setRefreshedUpdate] = useState<Update | null>()
const updateInfo =
refreshedUpdate === undefined ? detectedUpdate : refreshedUpdate
const { updateInfo } = useUpdate()
const [download, setDownload] = useState({
downloaded: 0,
total: 0,
})
const { downloaded, total } = download
const [downloaded, setDownloaded] = useState(0)
const [total, setTotal] = useState(0)
const downloadedRef = useRef(0)
const totalRef = useRef(0)
const openUrlWithNotice = (url: string) => {
void openExternalUrl(url).catch(showNotice.error)
@@ -195,10 +197,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}, [downloaded, total])
useImperativeHandle(ref, () => ({
open: () => {
setRefreshedUpdate(undefined)
setOpen(true)
},
open: () => setOpen(true),
close: () => setOpen(false),
}))
@@ -243,21 +242,32 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
if (updateState) return
setUpdateState(true)
setDownload({ downloaded: 0, total: 0 })
setDownloaded(0)
setTotal(0)
downloadedRef.current = 0
totalRef.current = 0
const onDownloadEvent = (event: DownloadEvent) => {
if (event.event === 'Started') {
setDownload({ downloaded: 0, total: event.data.contentLength ?? 0 })
} else if (event.event === 'Progress') {
setDownload((prev) => ({
...prev,
downloaded: prev.downloaded + event.data.chunkLength,
}))
} else if (event.event === 'Finished') {
setDownload((prev) => ({
...prev,
total: prev.total || prev.downloaded,
}))
const contentLength = event.data.contentLength ?? 0
totalRef.current = contentLength
setTotal(contentLength)
setDownloaded(0)
downloadedRef.current = 0
return
}
if (event.event === 'Progress') {
setDownloaded((prev) => {
const next = prev + event.data.chunkLength
downloadedRef.current = next
return next
})
}
if (event.event === 'Finished' && totalRef.current === 0) {
totalRef.current = downloadedRef.current
setTotal(downloadedRef.current)
}
}
@@ -269,18 +279,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
errorNotice: false,
},
)
if (result.ok && result.value === 'update_changed') {
const { data } = await checkUpdate()
setRefreshedUpdate(data)
if (data) {
showNotice.info('settings.modals.update.messages.available')
} else {
setOpen(false)
showNotice.info(
'settings.components.verge.advanced.notifications.latestVersion',
)
}
} else if (result.ok && result.value === 'installed') {
if (result.ok && result.value) {
await mutate(() => restartApp(), {
id: 'restart-app',
errorNotice: false,
@@ -290,7 +289,10 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
showNotice.error(err)
} finally {
setUpdateState(false)
setDownload({ downloaded: 0, total: 0 })
setDownloaded(0)
setTotal(0)
downloadedRef.current = 0
totalRef.current = 0
}
})
@@ -50,21 +50,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value]
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList })
})
const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList]
newList[index] = value ?? ''
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList })
})
const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList]
newList.splice(index, 1)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList })
})
+13 -3
View File
@@ -1,12 +1,14 @@
import React from 'react'
import React, { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Switch, TooltipIcon } from '@/components/base'
import { DialogRef, Switch, TooltipIcon } from '@/components/base'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
import { useVerge } from '@/hooks/use-verge'
import { GuardState } from './mods/guard-state'
import { SettingList, SettingItem } from './mods/setting-comp'
import { SysproxyViewer } from './mods/sysproxy-viewer'
import { TunViewer } from './mods/tun-viewer'
interface Props {
onError?: (err: Error) => void
@@ -19,16 +21,22 @@ const SettingSystem = ({ onError }: Props) => {
const { enable_auto_launch, enable_silent_start } = verge ?? {}
const sysproxyRef = useRef<DialogRef>(null)
const tunRef = useRef<DialogRef>(null)
const onSwitchFormat = (
_e: React.ChangeEvent<HTMLInputElement>,
value: boolean,
) => value
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
return (
<SettingList title={t('settings.sections.system.title')}>
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<ProxyControlSwitches
label={t('settings.sections.system.toggles.tunMode')}
onError={onError}
@@ -50,6 +58,8 @@ const SettingSystem = ({ onError }: Props) => {
}}
onGuard={async (e) => {
try {
// 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e })
await patchVerge({ enable_auto_launch: e })
return Promise.resolve()
} catch (error) {
@@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
const backupRef = useRef<DialogRef>(null)
const onChangeData = (patch: any) => {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
const onCopyClashEnv = useCallback(async () => {
@@ -163,7 +163,7 @@ const ProxyControlSwitches = ({
})
return false
}
mutateVerge({ ...verge, enable_tun_mode: value })
mutateVerge({ ...verge, enable_tun_mode: value }, false)
await patchVerge({ enable_tun_mode: value })
}
@@ -229,8 +229,8 @@ const ProxyControlSwitches = ({
/>
)}
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />}
{isTunMode && <TunViewer ref={tunRef} />}
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
</Box>
)
}
@@ -0,0 +1,301 @@
import {
ErrorOutlineRounded,
RefreshRounded,
BugReportRounded,
} from '@mui/icons-material'
import { Box, Typography, Button, Alert, Collapse } from '@mui/material'
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
children: ReactNode
fallbackComponent?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
}
export class TrafficErrorBoundary extends Component<Props, State> {
private retryCount = 0
private maxRetries = 3
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo)
this.setState({
error,
errorInfo,
})
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
this.reportError(error, errorInfo)
}
private reportError = (error: Error, errorInfo: ErrorInfo) => {
const errorReport = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
}
console.error('[TrafficErrorBoundary] 错误报告:', errorReport)
}
private handleRetry = () => {
if (this.retryCount < this.maxRetries) {
this.retryCount++
console.log(
`[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`,
)
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
})
} else {
console.warn('[TrafficErrorBoundary] 已达到最大重试次数')
}
}
private handleRefresh = () => {
window.location.reload()
}
private toggleDetails = () => {
this.setState((prev) => ({ showDetails: !prev.showDetails }))
}
render() {
if (this.state.hasError) {
if (this.props.fallbackComponent) {
return this.props.fallbackComponent
}
return (
<TrafficErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
showDetails={this.state.showDetails}
canRetry={this.retryCount < this.maxRetries}
retryCount={this.retryCount}
maxRetries={this.maxRetries}
onRetry={this.handleRetry}
onRefresh={this.handleRefresh}
onToggleDetails={this.toggleDetails}
/>
)
}
return this.props.children
}
}
interface TrafficErrorFallbackProps {
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
canRetry: boolean
retryCount: number
maxRetries: number
onRetry: () => void
onRefresh: () => void
onToggleDetails: () => void
}
const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
error,
errorInfo,
showDetails,
canRetry,
retryCount,
maxRetries,
onRetry,
onRefresh,
onToggleDetails,
}) => {
const { t } = useTranslation()
return (
<Box
sx={{
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ fontSize: 48, mb: 2, color: 'error.main' }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2, textAlign: 'center' }}
>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="body2">
<strong>{t('shared.feedback.errors.label')}:</strong>{' '}
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Typography>
{retryCount > 0 && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>
{t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries}
</Typography>
)}
</Alert>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{canRetry && (
<Button
variant="contained"
color="primary"
startIcon={<RefreshRounded />}
onClick={onRetry}
size="small"
>
{t('shared.actions.retry')}
</Button>
)}
<Button variant="outlined" onClick={onRefresh} size="small">
{t('shared.actions.refreshPage')}
</Button>
<Button
variant="text"
startIcon={<BugReportRounded />}
onClick={onToggleDetails}
size="small"
>
{showDetails
? t('shared.actions.hideDetails')
: t('shared.actions.showDetails')}
</Button>
</Box>
<Collapse in={showDetails} sx={{ width: '100%', maxWidth: 600 }}>
<Box
sx={{
p: 2,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" gutterBottom>
{t('shared.feedback.errors.details')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{error?.stack}
</Typography>
{errorInfo?.componentStack && (
<>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
{t('shared.feedback.errors.componentStack')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{errorInfo.componentStack}
</Typography>
</>
)}
</Box>
</Collapse>
</Box>
)
}
export const LightweightTrafficErrorBoundary: React.FC<{
children: ReactNode
}> = ({ children }) => {
const { t } = useTranslation()
return (
<TrafficErrorBoundary
fallbackComponent={
<Box
sx={{
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 60,
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
}
>
{children}
</TrafficErrorBoundary>
)
}
+4 -2
View File
@@ -1,4 +1,5 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { useMemo } from 'react'
import { downloadIconCache } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
@@ -22,8 +23,9 @@ export const useIconCache = ({
const iconValue = icon?.trim() ?? ''
const cacheKeyValue = cacheKey?.trim() ?? ''
const isEnabled =
enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
const isEnabled = useMemo(() => {
return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
}, [enabled, iconValue, cacheKeyValue])
const { data } = useQuery({
queryKey: ['icon-cache', iconValue, cacheKeyValue],
+8 -4
View File
@@ -54,7 +54,8 @@ export const useLogData = () => {
const hasLoadedInitialLogsRef = useRef(false)
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
subscriptionKey: enableLog ? 'getClashLog' : null,
storageKey: 'mihomo_logs_date',
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
fallbackData: [],
connect: () => MihomoWebSocket.connect_logs(logLevel),
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
@@ -76,13 +77,14 @@ export const useLogData = () => {
}
const pendingLogs = buffer.splice(0, buffer.length)
flushTimeStr = null
next((current) => appendLogs(current, pendingLogs))
next(null, (current) => appendLogs(current, pendingLogs))
flushTimer = null
}
return {
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data)
void scheduleReconnect()
return
}
@@ -106,7 +108,9 @@ export const useLogData = () => {
if (!flushTimer) {
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
}
} catch {}
} catch (error) {
next(error)
}
},
async onConnected() {
if (hasLoadedInitialLogsRef.current) {
@@ -115,7 +119,7 @@ export const useLogData = () => {
const logs = await getClashLogs()
hasLoadedInitialLogsRef.current = true
if (isMounted()) {
next((current) => {
next(null, (current) => {
if (!current || current.length === 0) {
return clampLogs(filterLogsByLevel(logs, allowedTypes))
}
+7 -3
View File
@@ -33,13 +33,15 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
subscriptionKey: enabled ? 'getClashMemory' : null,
storageKey: 'mihomo_memory_date',
buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null),
fallbackData: FALLBACK_MEMORY_USAGE,
connect: () => MihomoWebSocket.connect_memory(),
throttleMs: 500,
setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data, FALLBACK_MEMORY_USAGE)
void scheduleReconnect()
return
}
@@ -49,8 +51,10 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
if (shouldSkipDuplicateMemory(parsed)) {
return
}
next(parsed)
} catch {}
next(null, parsed)
} catch (error) {
next(error, FALLBACK_MEMORY_USAGE)
}
},
}),
})
+68 -32
View File
@@ -1,7 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useRef } from 'react'
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import { setCacheData, useQuery } from '@/services/query-client'
import {
removeCacheData,
setCacheData,
useQuery,
} from '@/services/query-client'
const RECONNECT_DELAY_MS = 1000
@@ -26,6 +31,7 @@ interface SharedSubscriptionEntry {
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
const subscriptionSnapshots = new Map<string, unknown>()
const initialSubscriptionDate = Date.now()
const getSubscriptionSnapshot = <T>(key: string) =>
subscriptionSnapshots.get(key) as T | undefined
@@ -130,13 +136,15 @@ const createSharedSubscriptionEntry = (
return entry
}
type Updater<T> = (current?: T) => T
/**
* Pass either a plain value or a functional updater; the updater is resolved
* against the current subscription snapshot before the cache is written.
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
* functional updater `(current?: T) => T`. The functional form is resolved
* against the current subscription snapshot before updating SWR.
*/
type NextFn<T> = (data: T | Updater<T>) => void
type NextFn<T> = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => void
interface HandlerContext<T> {
next: NextFn<T>
@@ -151,7 +159,8 @@ interface HandlerResult {
}
interface UseMihomoWsSubscriptionOptions<T> {
subscriptionKey: string | null
storageKey: string
buildSubscriptKey: (date: number) => string | null
fallbackData: T
connect: () => Promise<MihomoWebSocket>
/**
@@ -169,12 +178,18 @@ interface UseMihomoWsSubscriptionOptions<T> {
export const useMihomoWsSubscription = <T>(
options: UseMihomoWsSubscriptionOptions<T>,
) => {
const { subscriptionKey, fallbackData, connect, throttleMs, setupHandlers } =
options
const {
storageKey,
buildSubscriptKey,
fallbackData,
connect,
throttleMs,
setupHandlers,
} = options
const subscriptionCacheKey = subscriptionKey
? `$sub$${subscriptionKey}`
: null
const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate)
const subscriptKey = buildSubscriptKey(date)
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
if (subscriptionCacheKey) {
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
@@ -182,9 +197,20 @@ export const useMihomoWsSubscription = <T>(
const responseCacheKey =
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
// Bumping the epoch re-runs the subscription effect, rebuilding the shared
// entry (and its captured `connect`/handler closures) under the same key.
const [refreshEpoch, setRefreshEpoch] = useState(0)
const resolveNextData = useCallback(
(
data: T | ((current?: T) => T | undefined) | undefined,
cacheKey: string,
): T => {
if (typeof data === 'function') {
const updater = data as (current?: T) => T | undefined
const current = getSubscriptionSnapshot<T>(cacheKey)
return updater(current) ?? fallbackData
}
return data ?? fallbackData
},
[fallbackData],
)
const response = useQuery<T>({
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
@@ -212,35 +238,44 @@ export const useMihomoWsSubscription = <T>(
let throttleCleanup: (() => void) | undefined
let wrappedNext: NextFn<T>
const baseNext: NextFn<T> = (data) => {
const resolved =
typeof data === 'function'
? (data as Updater<T>)(
getSubscriptionSnapshot<T>(subscriptionCacheKey),
)
: (data as T)
const baseNext: NextFn<T> = (error, data) => {
if (error !== undefined && error !== null) {
return
}
if (data === undefined) return
const resolved = resolveNextData(data, subscriptionCacheKey)
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
}
if (throttleMs && throttleMs > 0) {
let pendingData: T | Updater<T> | undefined
let pendingData: T | ((current?: T) => T | undefined) | undefined
let hasPending = false
let timerId: ReturnType<typeof setTimeout> | null = null
const flush = () => {
timerId = null
if (pendingData !== undefined) {
if (hasPending) {
const data = pendingData
pendingData = undefined
baseNext(data)
hasPending = false
baseNext(undefined, data)
}
}
wrappedNext = (data) => {
wrappedNext = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => {
if (error !== undefined && error !== null) {
baseNext(error, data)
return
}
if (!timerId) {
baseNext(data)
baseNext(undefined, data)
timerId = setTimeout(flush, throttleMs)
} else {
pendingData = data
hasPending = true
}
}
@@ -306,14 +341,15 @@ export const useMihomoWsSubscription = <T>(
}
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
}, [subscriptionCacheKey, refreshEpoch])
}, [subscriptionCacheKey])
const refresh = useCallback(() => {
if (subscriptionCacheKey) {
writeSubscriptionSnapshot(subscriptionCacheKey, fallbackData)
subscriptionSnapshots.delete(subscriptionCacheKey)
void removeCacheData([subscriptionCacheKey])
}
setRefreshEpoch((epoch) => epoch + 1)
}, [subscriptionCacheKey, fallbackData])
setDate(Date.now())
}, [subscriptionCacheKey, setDate])
const setData = useCallback(
(data: T) => {
+1 -6
View File
@@ -17,12 +17,7 @@ const PRESET_PROXY_NAMES = [
'COMPATIBLE',
]
const identity = (previous: DelayUpdate, next: DelayUpdate): DelayUpdate =>
previous.delay === next.delay &&
previous.elapsed === next.elapsed &&
previous.updatedAt === next.updatedAt
? previous
: next
const identity = (_: DelayUpdate, next: DelayUpdate): DelayUpdate => next
const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 }
+2 -2
View File
@@ -35,8 +35,8 @@ it('applies and records independent group selections while another group is pend
const secondSuccess = vi.fn()
const first = useProxySelection({ onSuccess: firstSuccess })
const second = useProxySelection({ onSuccess: secondSuccess })
first.select('Group A', 'Node A')
second.select('Group B', 'Node B')
first.changeProxy('Group A', 'Node A')
second.changeProxy('Group B', 'Node B')
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
)
+28 -4
View File
@@ -35,6 +35,7 @@ const cleanupConnections = async (previousProxy: string) => {
interface ProxySelectionOptions {
onSuccess?: () => void
onError?: (error: any) => void
enableConnectionCleanup?: boolean
}
interface ProxyChangeRequest {
@@ -52,7 +53,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
const isProcessingRef = useRef(false)
const { onSuccess, onError } = options
const { onSuccess, onError, enableConnectionCleanup = true } = options
const autoCloseConnection = verge?.auto_close_connection ?? false
@@ -97,7 +98,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
)
}
if (autoCloseConnection && previousProxy) {
if (enableConnectionCleanup && autoCloseConnection && previousProxy) {
void cleanupConnections(previousProxy)
}
} catch (error) {
@@ -111,6 +112,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
[
autoCloseConnection,
forgetSelection,
enableConnectionCleanup,
onError,
onSuccess,
recordSelection,
@@ -136,7 +138,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}
}, [executeChange])
const select = useCallback(
const changeProxy = useCallback(
(
groupName: string,
proxyName: string,
@@ -154,5 +156,27 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
[flushChangeQueue],
)
return { select }
const handleSelectChange = useCallback(
(groupName: string, previousProxy?: string, fixed?: string) =>
(event: { target: { value: string } }) => {
changeProxy(groupName, event.target.value, previousProxy, fixed)
},
[changeProxy],
)
const handleProxyGroupChange = useCallback(
(
group: { name: string; now?: string | null; fixed?: string | null },
proxy: { name: string },
) => {
changeProxy(group.name, proxy.name, group.now!, group.fixed!)
},
[changeProxy],
)
return {
changeProxy,
handleSelectChange,
handleProxyGroupChange,
}
}
+6 -4
View File
@@ -52,8 +52,9 @@ export const useSystemProxyState = () => {
const toggleSystemProxy = async (enabled: boolean) => {
// Roll failed optimistic writes back to the latest confirmed state.
let confirmed = verge?.enable_system_proxy ?? false
mutateVerge((prev) =>
prev ? { ...prev, enable_system_proxy: enabled } : prev,
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
false,
)
pendingRef.current = enabled
@@ -77,8 +78,9 @@ export const useSystemProxyState = () => {
}
}
} catch (error) {
mutateVerge((prev) =>
prev ? { ...prev, enable_system_proxy: confirmed } : prev,
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev),
false,
)
// Queued requests were based on a state that never landed.
pendingRef.current = null
+1 -2
View File
@@ -41,8 +41,7 @@ export function useAppUptime() {
const { data: uptime = 0 } = useQuery({
queryKey: appUptimeQueryKey,
queryFn: getAppUptime,
// The wrapper maps staleTime to SWR's dedupingInterval, which drops
// refetchInterval ticks while it exceeds the interval.
staleTime: 5000,
refetchInterval: 3000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
+7 -3
View File
@@ -32,13 +32,15 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
graphData: { appendData },
} = useTrafficMonitorEnhanced({ subscribe: false, enabled })
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
subscriptionKey: enabled ? 'getClashTraffic' : null,
storageKey: 'mihomo_traffic_date',
buildSubscriptKey: (date) => (enabled ? `getClashTraffic-${date}` : null),
fallbackData: FALLBACK_TRAFFIC,
connect: () => MihomoWebSocket.connect_traffic(),
throttleMs: 200,
setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data, FALLBACK_TRAFFIC)
void scheduleReconnect()
return
}
@@ -49,8 +51,10 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
return
}
appendData(parsed)
next(parsed)
} catch {}
next(null, parsed)
} catch (error) {
next(error, FALLBACK_TRAFFIC)
}
},
}),
})
+216 -32
View File
@@ -3,6 +3,7 @@ import { Traffic } from 'tauri-plugin-mihomo-api'
import { useVisibility } from '@/hooks/use-visibility'
import { debugLog } from '@/utils/debug'
import { TrafficDataSampler, isSameTrafficData } from '@/utils/traffic-sampler'
// 引用计数管理器
class ReferenceCounter {
@@ -31,11 +32,119 @@ const WORKER_CONFIG = {
defaultRangeMinutes: 10,
}
class InlineTrafficMonitor {
private config = { ...WORKER_CONFIG }
private sampler = new TrafficDataSampler(this.config)
private throttleTimer: ReturnType<typeof setTimeout> | null = null
private agingTimer: ReturnType<typeof setInterval> | null = null
private currentRange = this.config.defaultRangeMinutes
private lastTimestamp: number | undefined
private lastBroadcast: ITrafficDataPoint[] = []
constructor(
private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void,
) {}
start(rangeMinutes?: number) {
this.currentRange = rangeMinutes ?? this.currentRange
this.handle({
type: 'init',
config: {
...this.config,
defaultRangeMinutes: this.currentRange,
},
})
}
stop() {
if (this.throttleTimer !== null) {
clearTimeout(this.throttleTimer)
this.throttleTimer = null
}
if (this.agingTimer !== null) {
clearInterval(this.agingTimer)
this.agingTimer = null
}
this.sampler.clear()
this.lastTimestamp = undefined
}
handle(message: TrafficWorkerRequestMessage) {
switch (message.type) {
case 'init': {
this.config = { ...message.config }
this.sampler = new TrafficDataSampler(this.config)
this.currentRange = message.config.defaultRangeMinutes
// Re-broadcast when points age out of the range, not on every tick
this.agingTimer = setInterval(() => {
if (this.throttleTimer !== null) return
const slice = this.sampler.getDataForTimeRange(this.currentRange)
if (!isSameTrafficData(this.lastBroadcast, slice)) {
this.emitSnapshot()
}
}, 1000)
this.emitSnapshot()
break
}
case 'append': {
const timestamp = message.payload.timestamp ?? Date.now()
const dataPoint: ITrafficDataPoint = {
up: message.payload.up || 0,
down: message.payload.down || 0,
timestamp,
}
this.lastTimestamp = timestamp
this.sampler.addDataPoint(dataPoint)
this.scheduleSnapshot()
break
}
case 'setRange': {
if (this.currentRange !== message.minutes) {
this.currentRange = message.minutes
this.emitSnapshot()
}
break
}
case 'requestSnapshot': {
this.emitSnapshot()
break
}
default:
break
}
}
private emitSnapshot() {
const dataPoints = this.sampler.getDataForTimeRange(this.currentRange)
this.emit({
type: 'snapshot',
dataPoints,
samplerStats: this.sampler.getStats(),
lastTimestamp: this.lastTimestamp,
})
this.lastBroadcast = dataPoints
}
private scheduleSnapshot() {
if (this.throttleTimer !== null) return
this.throttleTimer = setTimeout(() => {
this.throttleTimer = null
this.emitSnapshot()
}, this.config.snapshotIntervalMs)
}
}
class TrafficWorkerClient {
private worker: Worker | null = null
private inlineMonitor: InlineTrafficMonitor | null = null
private mode: 'worker' | 'inline' | null = null
private listeners = new Set<
(snapshot: ITrafficWorkerSnapshotMessage) => void
>()
private pendingMessages: TrafficWorkerRequestMessage[] = []
private ready = false
private currentRange = WORKER_CONFIG.defaultRangeMinutes
start(rangeMinutes?: number) {
@@ -46,52 +155,101 @@ class TrafficWorkerClient {
this.currentRange = rangeMinutes ?? this.currentRange
if (this.worker) return
if (this.ready) return
const initMessage: TrafficWorkerRequestMessage = {
type: 'init',
config: { ...WORKER_CONFIG, defaultRangeMinutes: this.currentRange },
config: {
rawDataMinutes: WORKER_CONFIG.rawDataMinutes,
compressedDataMinutes: WORKER_CONFIG.compressedDataMinutes,
compressionRatio: WORKER_CONFIG.compressionRatio,
snapshotIntervalMs: WORKER_CONFIG.snapshotIntervalMs,
defaultRangeMinutes: this.currentRange,
},
}
this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' },
)
if (typeof Worker !== 'undefined') {
try {
this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' },
)
this.mode = 'worker'
this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>,
) => {
const message = event.data
if (message.type === 'snapshot') {
if (import.meta.env.MODE === 'perf') {
document.documentElement.dataset.perfWorker = String(
message.lastTimestamp ?? 0,
)
document.documentElement.dataset.perfPoints = String(
message.dataPoints.length,
)
const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String(last?.up ?? 0)
document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0,
)
this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>,
) => {
const message = event.data
if (message.type === 'snapshot') {
if (import.meta.env.MODE === 'perf') {
document.documentElement.dataset.perfWorker = String(
message.lastTimestamp ?? 0,
)
document.documentElement.dataset.perfPoints = String(
message.dataPoints.length,
)
const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String(
last?.up ?? 0,
)
document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0,
)
}
this.listeners.forEach((listener) => {
listener(message)
})
}
}
this.listeners.forEach((listener) => {
listener(message)
})
this.worker.onerror = (error) => {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
}
this.ready = true
this.post(initMessage)
this.flushQueue()
return
} catch (error) {
debugLog(
`[TrafficWorkerClient] Worker initialization failed, falling back to inline sampler: ${String(error)}`,
)
this.worker = null
this.mode = null
}
} else {
debugLog(
'[TrafficWorkerClient] Worker not supported, using inline sampler',
)
}
this.worker.onerror = (error) => {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
}
this.startInline(initMessage)
}
private startInline(initMessage: TrafficWorkerRequestMessage) {
this.inlineMonitor = new InlineTrafficMonitor((snapshot) => {
this.listeners.forEach((listener) => {
listener(snapshot)
})
})
this.mode = 'inline'
this.ready = true
this.post(initMessage)
this.flushQueue()
}
stop() {
this.worker?.terminate()
if (this.worker) {
this.worker.terminate()
}
if (this.inlineMonitor) {
this.inlineMonitor.stop()
}
this.worker = null
this.inlineMonitor = null
this.mode = null
this.ready = false
this.pendingMessages = []
}
onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) {
@@ -102,11 +260,37 @@ class TrafficWorkerClient {
}
private post(message: TrafficWorkerRequestMessage) {
this.worker?.postMessage(message)
if (!this.ready) {
this.pendingMessages.push(message)
return
}
if (this.mode === 'worker' && this.worker) {
this.worker.postMessage(message)
return
}
if (this.mode === 'inline' && this.inlineMonitor) {
this.inlineMonitor.handle(message)
return
}
this.pendingMessages.push(message)
}
private flushQueue() {
if (!this.ready || this.pendingMessages.length === 0) {
return
}
const queued = [...this.pendingMessages]
this.pendingMessages = []
queued.forEach((message) => {
this.post(message)
})
}
private ensureStarted() {
if (!this.worker) {
if (!this.ready) {
this.start(this.currentRange)
}
}
+1
View File
@@ -25,6 +25,7 @@ export const useVerge = () => {
| IVergeConfig
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
| undefined,
_revalidate?: boolean,
) => {
if (updaterOrData === undefined) {
void refetch()
+18 -6
View File
@@ -11,24 +11,36 @@ const useWindow = () => {
}
export const useWindowControls = () => {
const { maximized, minimize, toggleMaximize, close, currentWindow } =
useWindow()
const {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} = useWindow()
return {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} satisfies Pick<
WindowContextType,
'maximized' | 'minimize' | 'toggleMaximize' | 'close' | 'currentWindow'
| 'maximized'
| 'minimize'
| 'toggleMaximize'
| 'close'
| 'toggleFullscreen'
| 'currentWindow'
>
}
export const useWindowDecorations = () => {
const { decorated, toggleDecorations } = useWindow()
return { decorated, toggleDecorations } satisfies Pick<
const { decorated, toggleDecorations, refreshDecorated } = useWindow()
return { decorated, toggleDecorations, refreshDecorated } satisfies Pick<
WindowContextType,
'decorated' | 'toggleDecorations'
'decorated' | 'toggleDecorations' | 'refreshDecorated'
>
}
+21 -4
View File
@@ -1,7 +1,8 @@
import { Paper, ThemeProvider } from '@mui/material'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useNavigate } from 'react-router'
import { BaseErrorBoundary } from '@/components/base'
@@ -37,6 +38,7 @@ const OS = getSystem()
const Layout = () => {
const mode = useThemeMode()
const isDark = mode !== 'light'
const { t } = useTranslation()
const { theme } = useCustomTheme()
const { verge } = useVerge()
const { language } = verge ?? {}
@@ -44,6 +46,7 @@ const Layout = () => {
const { switchLanguage } = useI18n()
const navigate = useNavigate()
const themeReady = useMemo(() => Boolean(theme), [theme])
const windowControlsRef = useRef<any>(null)
const { decorated } = useWindowDecorations()
const customTitlebar = useMemo(
@@ -54,7 +57,7 @@ const Layout = () => {
className="the_titlebar-drag-region"
data-tauri-drag-region="true"
/>
<WindowControls />
<WindowControls ref={windowControlsRef} />
</div>
) : null,
[decorated],
@@ -66,12 +69,12 @@ const Layout = () => {
(payload: [string, string]) => {
const [status, msg] = payload
try {
handleNoticeMessage(status, msg, navigate)
handleNoticeMessage(status, msg, t, navigate)
} catch (error) {
console.error('[通知处理] 失败:', error)
}
},
[navigate],
[t, navigate],
)
useEventBus(handleNotice)
@@ -107,6 +110,20 @@ const Layout = () => {
<NoticeManager position={verge?.notice_position} />
<ServiceMigrationDialog />
<SysproxyPrivilegeDialog />
<div
style={{
animation: 'fadeIn 0.5s',
WebkitAnimation: 'fadeIn 0.5s',
}}
/>
<style>
{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}
</style>
<Paper
square
elevation={0}
+4 -2
View File
@@ -83,6 +83,7 @@ const ProfilePage = () => {
const { t } = useTranslation()
const location = useLocation()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
const [activatings, setActivatings] = useState<string[]>([])
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
@@ -256,6 +257,7 @@ const ProfilePage = () => {
)
}
} finally {
setDisabled(false)
setLoading(false)
}
}
@@ -822,7 +824,7 @@ const ProfilePage = () => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return
}
if (!url || loading) {
if (!url || disabled || loading) {
return
}
event.preventDefault()
@@ -855,7 +857,7 @@ const ProfilePage = () => {
}}
/>
<Button
disabled={!url}
disabled={!url || disabled}
loading={loading}
variant="contained"
size="small"
+4 -1
View File
@@ -24,8 +24,11 @@ const RulesPage = () => {
const [showScrollTop, setShowScrollTop] = useState(false)
const pageVisible = useVisibility()
// 页面可见时刷新规则数据:挂载时可见或从隐藏恢复可见各触发一次
// 在组件挂载时和页面获得焦点时刷新规则数据
useEffect(() => {
refreshRules()
refreshRuleProviders()
if (pageVisible) {
refreshRules()
refreshRuleProviders()
+1 -3
View File
@@ -26,9 +26,7 @@ const TQ_MIHOMO = {
refetchOnReconnect: false,
staleTime: 1500,
retry: 3,
// SWR's default retry waits about 2^retryCount * errorRetryInterval with
// jitter, so 100 reproduces the previous 200 * 2^attempt backoff.
retryDelay: 100,
retryDelay: (attempt: number) => Math.min(200 * 2 ** attempt, 3000),
} as const
const TQ_DEFAULTS = {
+2
View File
@@ -5,9 +5,11 @@ export interface WindowContextType {
decorated: boolean | null
maximized: boolean | null
toggleDecorations: () => Promise<void>
refreshDecorated: () => Promise<boolean>
minimize: () => Promise<void>
close: () => Promise<void>
toggleMaximize: () => Promise<void>
toggleFullscreen: () => Promise<void>
currentWindow: ReturnType<typeof getCurrentWindow>
}
+8
View File
@@ -61,6 +61,10 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
}
}, [currentWindow])
const toggleFullscreen = useCallback(async () => {
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()))
}, [currentWindow])
const refreshDecorated = useCallback(async () => {
const val = await currentWindow.isDecorated()
setDecorated(val)
@@ -83,18 +87,22 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
}),
[
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
],
)
+37 -38
View File
@@ -35,6 +35,7 @@ interface IpInfo {
interface ServiceConfig {
url: string
mapping: (data: any) => IpInfo
timeout?: number // 保留timeout字段(如有需要)
}
// 可用的IP检测服务列表及字段映射
@@ -155,52 +156,48 @@ export const getIpInfo = async (): Promise<
for (const service of shuffledServices) {
debugLog(`尝试IP检测服务: ${service.url}`)
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, service.timeout || serviceTimeout)
try {
return await asyncRetry(
async (bail) => {
console.debug('Fetching IP information:', service.url)
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, serviceTimeout)
const response = await fetch(service.url, {
method: 'GET',
signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout),
connectTimeout: service.timeout || serviceTimeout,
headers: {
'User-Agent': userAgent,
},
})
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
)
}
let data: any
try {
const response = await fetch(service.url, {
method: 'GET',
signal: timeoutController.signal,
connectTimeout: serviceTimeout,
headers: {
'User-Agent': userAgent,
},
data = await response.json()
} catch {
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
}
if (data && data.ip) {
debugLog(`IP检测成功,使用服务: ${service.url}`)
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
})
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
)
}
let data: any
try {
data = await response.json()
} catch {
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
}
if (data && data.ip) {
debugLog(`IP检测成功,使用服务: ${service.url}`)
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
})
} else {
return bail(new Error(`无效的响应格式 from ${service.url}`))
}
} finally {
clearTimeout(timeoutId)
} else {
return bail(new Error(`无效的响应格式 from ${service.url}`))
}
},
{
@@ -213,6 +210,8 @@ export const getIpInfo = async (): Promise<
} catch (error) {
debugLog(`IP检测服务失败: ${service.url}`, error)
lastError = error
} finally {
clearTimeout(timeoutId)
}
}

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