Compare commits

..
Author SHA1 Message Date
renovate[bot] 56b57b0f7d chore(deps): update rust crate smartstring to v2 2026-11-02 02:02:27 +00:00
Tunglies 067a9f7cad fix(webdav): allow clearing saved configuration
Close #8495
2026-11-02 03:26:36 +08:00
124 changed files with 2326 additions and 3514 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
+26 -25
View File
@@ -1081,7 +1081,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.6.0"
version = "2.5.9"
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",
@@ -1333,7 +1334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2014,7 +2015,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2273,7 +2274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2789,7 +2790,7 @@ dependencies = [
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.8",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3343,7 +3344,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -4214,7 +4215,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4381,7 +4382,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4858,7 +4859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5523,7 +5524,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6027,7 +6028,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6040,7 +6041,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6099,7 +6100,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6702,9 +6703,9 @@ dependencies = [
[[package]]
name = "smartstring"
version = "1.0.1"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
checksum = "a155190f9c039a1e9b6e92efbfa50ae68daad98a0fd6d5521a5a09ef782383e8"
dependencies = [
"autocfg",
"serde",
@@ -6719,7 +6720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7785,10 +7786,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8454,7 +8455,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8563,7 +8564,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9105,7 +9106,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9126,7 +9127,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"windows-version",
]
@@ -9701,7 +9702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -85,7 +85,7 @@ reqwest = { version = "0.13.5", features = [
] }
rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" }
smartstring = { version = "2.0.0" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.154" }
+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>
-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>
-34
View File
@@ -256,40 +256,6 @@ export default defineConfig([
},
},
},
{
files: ['scripts/react/*.{mjs,mts,ts,tsx}'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.json',
},
},
settings: {
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: './scripts/react/tsconfig.json',
}),
],
},
},
{
files: ['scripts/react/*.{mjs,mts}'],
languageOptions: {
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.node.json',
},
},
},
{
files: ['scripts/react/{boundaries,material,scene}.tsx'],
// The isolated fixtures use full reloads and preserve aliased Hook names.
rules: {
'react-refresh/only-export-components': 'off',
'@eslint-react/no-unnecessary-use-prefix': 'off',
},
},
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
+1 -10
View File
@@ -1,15 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"src/polyfills/*.js!",
"scripts/check-contract-changes.mjs!",
"scripts/react/scene.tsx!",
"scripts/react/profiler.ts!",
"scripts/react/boundaries.tsx!",
"scripts/react/material.tsx!",
"scripts/react/yaml.ts!",
"scripts/react/wdyr.ts!"
],
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
+3 -10
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.6.0",
"version": "2.5.9",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -40,11 +40,7 @@
"perf:build": "node scripts/perf/build.mjs",
"perf:run": "node scripts/perf/run.mjs",
"perf:compare": "node scripts/perf/compare.mjs",
"perf:test": "node --test scripts/perf/report.test.mjs",
"dev:profile": "cross-env CVR_REACT_DIAGNOSTICS=profiler node scripts/dev.mjs dev",
"react:ab": "node scripts/react/ab.mjs",
"dev:wdyr": "cross-env CVR_REACT_DIAGNOSTICS=wdyr node scripts/dev.mjs dev",
"audit:react": "node scripts/react/doctor.mjs"
"perf:test": "node --test scripts/perf/report.test.mjs"
},
"dependencies": {
"@dnd-kit/collision": "^0.5.0",
@@ -138,10 +134,7 @@
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.1.0",
"ws": "8.22.1",
"playwright": "1.64.1",
"@welldone-software/why-did-you-render": "10.0.1",
"react-doctor": "0.9.21"
"ws": "8.22.1"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
+2 -940
View File
File diff suppressed because it is too large Load Diff
+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.
-47
View File
@@ -1,47 +0,0 @@
# React diagnostics
`pnpm dev:profile` runs the usual development launcher with a root React
Profiler. In WebView DevTools, `window.__CVR_REACT_PROFILER__.read()` returns
copies of the latest 1,000 standard Profiler callback records; `.clear()` drops
them. Records contain timing and commit metadata, never business props. Normal
development and production builds do not load the collector.
`pnpm dev:wdyr` instead initializes why-did-you-render before loading the app
and uses its development JSX runtime. It reports avoidable renders for
`ProxyItem`, `ProxyItemMini` and `ProxyTunCard` in the console. The modes are
mutually exclusive; `CVR_REACT_DIAGNOSTICS` accepts only `profiler` or `wdyr` on
the development server. Each mode uses its own Vite cache. Production builds
ignore this variable and include neither diagnostic entry nor runtime.
`pnpm audit:react` scans supported source files under `src`. Pass file paths to
limit the scan, for example `pnpm audit:react src/hooks/use-proxy-delay-state.ts`;
add `--json` for the original structured report. Diagnostics are advisory.
Execution errors and incomplete scans fail the command. Telemetry, remote scores
and supply-chain scanning are disabled; the command does not fix code, erase
historical diagnostics or run in CI.
Install the browser explicitly with `pnpm exec playwright install chromium`.
Then run `pnpm react:ab --base <ref> --head <ref>`. Both Git trees run against
one installed dependency set and the same fixed DNS, delay-hook and proxy-tab
fixtures. The command never installs a browser or changes either checkout.
It retains source hashes, dependency identity, browser/environment information,
behavior assertions, render observations and profiles under `/tmp/cvr-react-ab-*`.
Each version and mode has separate Vite caches and output. Three pairs alternate
order in ordinary development, Strict Mode and production. A behavior failure
returns a nonzero exit status. DNS stale serialization and same-value renders
are observations so a known baseline can be compared with its fix. Append
`--assert dns` to require the head version to keep the latest serialized YAML in sync with
continuous field edits; this is the focused DNS regression assertion.
`--assert delay` requires same-value delay updates to retain their reference and memoized consumer,
while each changed field (including clearing elapsed time) propagates.
`--assert tabs` requires zero tab renders during unrelated parent updates. Use
`--assert dns --assert delay --assert tabs` for a cumulative candidate.
The fixtures execute the actual selected components and hook. IPC, data,
editor and UI rendering boundaries are replaced, and outgoing network requests
are rejected. DNS serialization is observed at `js-yaml.dump`; tab execution is
observed at the Material Paper boundary. These are component results, not native
Tauri integration, real DNS/proxy/TUN writes, or whole-application CPU/RSS gains.
Production React does not emit Profiler callbacks. Timing is diagnostic and has
no hard acceptance threshold; measure native release CPU/RSS separately and
calibrate A/A before claiming a performance gain. Keep raw reports out of Git.
-596
View File
@@ -1,596 +0,0 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
symlink,
writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import react from '@vitejs/plugin-react'
import { chromium } from 'playwright'
import { build, createServer, preview } from 'vite'
const { values } = parseArgs({
options: {
base: { type: 'string' },
head: { type: 'string' },
assert: { type: 'string', multiple: true },
},
})
if (!values.base || !values.head)
throw new Error('Usage: pnpm react:ab --base <ref> --head <ref>')
const assertions = new Set(values.assert ?? [])
for (const name of assertions) {
if (!['dns', 'delay', 'tabs'].includes(name))
throw new Error(`Unknown regression assertion: ${name}`)
}
const harness = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(harness, '../..')
const output = await mkdtemp('/tmp/cvr-react-ab-')
const dependencies = path.join(root, 'node_modules')
const frozenHarness = path.join(output, 'harness')
await cp(harness, frozenHarness, {
recursive: true,
filter: (file) => !path.basename(file).startsWith('tsconfig'),
})
const files = [
'src/components/setting/mods/dns-viewer.tsx',
'src/hooks/use-proxy-delay-state.ts',
'src/components/home/proxy-tun-card.tsx',
]
const hash = (content) => createHash('sha256').update(content).digest('hex')
function git(...args) {
const result = spawnSync('git', args, {
cwd: root,
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(result.stderr.toString())
return result.stdout
}
const manifest = {
assertions: [...assertions],
environment: {
platform: process.platform,
architecture: process.arch,
node: process.version,
os: os.release(),
lock: hash(await readFile(path.join(root, 'pnpm-lock.yaml'))),
},
harness: Object.fromEntries(
await Promise.all(
(await readdir(frozenHarness)).map(async (file) => [
file,
hash(await readFile(path.join(frozenHarness, file))),
]),
),
),
installedLock: hash(
await readFile(path.join(dependencies, '.pnpm/lock.yaml')),
),
sources: {},
runs: [],
}
let browser
try {
// Playwright intentionally fails here if its explicitly installed browser is missing.
browser = await chromium.launch({ headless: true })
manifest.environment.browser = browser.version()
for (const [variant, ref] of Object.entries({
base: values.base,
head: values.head,
})) {
const tree = git('rev-parse', '--verify', `${ref}^{tree}`).toString().trim()
const source = path.join(output, variant)
await mkdir(source)
const archive = git('archive', tree, 'src')
const extracted = spawnSync('tar', ['-x', '-C', source], { input: archive })
if (extracted.status !== 0) throw new Error(extracted.stderr.toString())
await cp(frozenHarness, path.join(source, 'fixture'), { recursive: true })
await symlink(dependencies, path.join(source, 'node_modules'), 'dir')
manifest.sources[variant] = {
ref,
tree,
files: Object.fromEntries(
await Promise.all(
files.map(async (file) => [
file,
hash(await readFile(path.join(source, file))),
]),
),
),
}
}
for (const mode of ['development', 'strict', 'production']) {
const servers = {}
try {
for (const variant of ['base', 'head']) {
const source = path.join(output, variant)
const fixture = path.join(source, 'fixture')
const boundary = path.join(fixture, 'boundaries.tsx')
const aliases = [
'@/hooks/use-clash',
'@/hooks/use-verge',
'@/hooks/use-profiles',
'@/hooks/use-system-state',
'@/hooks/use-system-proxy-state',
'@/services/states',
'@/services/cmds',
'@/services/mutate',
'@/services/notice-service',
'@/components/base',
'@/components/shared/proxy-control-switches',
'react-i18next',
].map((find) => ({ find, replacement: boundary }))
const config = {
configFile: false,
root: fixture,
cacheDir: path.join(output, 'cache', variant, mode),
plugins: [react()],
resolve: {
alias: [
...aliases,
{
find: '@/services/delay',
replacement: path.join(fixture, 'delay.ts'),
},
{
find: /^@mui\/material$/,
replacement: path.join(fixture, 'material.tsx'),
},
{
find: 'fixture-material',
replacement: path.join(dependencies, '@mui/material/index.mjs'),
},
{ find: 'js-yaml', replacement: path.join(fixture, 'yaml.ts') },
{
find: 'fixture-yaml',
replacement: path.join(
dependencies,
'js-yaml/dist/js-yaml.mjs',
),
},
{ find: '@', replacement: path.join(source, 'src') },
{
find: /^react(?=\/|$)/,
replacement: path.join(dependencies, 'react'),
},
{
find: /^react-dom(?=\/|$)/,
replacement: path.join(dependencies, 'react-dom'),
},
],
dedupe: ['react', 'react-dom'],
},
define: { OS_PLATFORM: JSON.stringify(process.platform) },
server: {
host: '127.0.0.1',
port: 0,
hmr: false,
fs: {
allow: [
output,
await realpath(output),
await realpath(dependencies),
],
},
},
build: {
outDir: path.join(output, 'dist', variant, mode),
emptyOutDir: true,
},
preview: { host: '127.0.0.1', port: 0 },
}
if (mode === 'production') {
await build(config)
servers[variant] = await preview(config)
} else {
servers[variant] = await createServer(config)
await servers[variant].listen()
}
}
for (const [pair, order] of [
['base', 'head'],
['head', 'base'],
['base', 'head'],
].entries()) {
for (const variant of order) {
const label = `${mode}-${pair + 1}-${variant}`
const context = await browser.newContext()
const errors = []
const address = servers[variant].httpServer.address()
const origin = `http://127.0.0.1:${address.port}`
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin === origin)
return route.continue()
errors.push(`Unexpected network: ${route.request().url()}`)
return route.abort()
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(String(error)))
const result = { label, variant, mode, pair: pair + 1, errors }
try {
await page.goto(`${origin}/${mode === 'strict' ? '?strict' : ''}`)
await page.waitForSelector('#delay', { timeout: 60000 })
await page.waitForFunction(
() => window.__REACT_AB__.read().listeners.length === 1,
)
result.tabs = await tabs(page)
if (variant === 'head' && assertions.has('tabs')) {
assert.equal(
result.tabs.unrelated,
0,
'Unrelated parent updates must not rerender the memoized tabs',
)
}
result.dns = await dns(page)
if (variant === 'head' && assertions.has('dns')) {
for (const edit of result.dns.edits) {
assert.equal(
edit.serialized.at(-1),
edit.value,
'Latest DNS serialization must match the edited field',
)
}
}
result.delay = await delay(page)
if (variant === 'head' && assertions.has('delay')) {
for (const [index, observation] of result.delay.entries()) {
assert.equal(
observation.sameReference,
index === 1,
'Preserve the reference only when all three delay fields match',
)
}
assert.equal(
result.delay[1].childRenders,
result.delay[0].childRenders,
'Equal delay state must not rerender the memoized consumer',
)
}
result.profile = await page.evaluate(() => {
const before = window.__CVR_REACT_PROFILER__.read()
window.__CVR_REACT_PROFILER__.clear()
return { before, after: window.__CVR_REACT_PROFILER__.read() }
})
assert.equal(result.profile.after.length, 0)
result.collector = await page.evaluate(() =>
window.__REACT_AB__.checkCollector(),
)
assert.deepEqual(result.collector, {
length: 1000,
first: '5',
last: '1004',
copied: true,
cleared: true,
})
if (mode !== 'production')
assert(
result.profile.before.length > 0 &&
result.profile.before.length <= 1000,
)
assert.deepEqual(errors, [])
assert.deepEqual(
await page.evaluate(() =>
window.__REACT_AB__.fixture.notices.filter(
(notice) => notice.error,
),
),
[],
)
result.ok = true
} catch (error) {
result.snapshot = await page
.evaluate(() => window.__REACT_AB__?.read())
.catch(() => null)
result.ok = false
result.error = String(error)
throw error
} finally {
manifest.runs.push(result)
await writeFile(
path.join(output, `${label}.json`),
JSON.stringify(result, null, 2),
)
await context.close()
console.log(`${label}: ${result.ok ? 'pass' : 'FAIL'}`)
}
}
}
} finally {
for (const server of Object.values(servers)) {
if (server.close) await server.close()
else
await new Promise((resolve, reject) =>
server.httpServer.close((error) =>
error ? reject(error) : resolve(),
),
)
}
}
}
} finally {
await browser?.close()
await writeFile(
path.join(output, 'report.json'),
JSON.stringify(manifest, null, 2),
)
console.log(`Evidence: ${output}`)
}
async function settle(page) {
await page.waitForTimeout(100)
}
async function tabs(page) {
const buttons = page.locator('#tabs .MuiPaper-root')
const readTabs = () =>
page.evaluate(() =>
Object.fromEntries(
window.__REACT_AB__.fixture.tabRenders.map((tab) => [
tab.label.split('.').at(-1),
tab,
]),
),
)
assert.equal(await buttons.count(), 2)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
const before = await page.evaluate(
() => window.__REACT_AB__.fixture.tabRenders.length,
)
for (let index = 0; index < 5; index++) {
await page.evaluate(() => window.__REACT_AB__.update())
await settle(page)
}
const unrelated =
(await page.evaluate(() => window.__REACT_AB__.fixture.tabRenders.length)) -
before
await buttons.nth(1).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, false)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'tun',
)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: true }))
await settle(page)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal((await readTabs()).systemProxy.active, false)
await buttons.nth(0).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'system',
)
await page.evaluate(() => {
window.__REACT_AB__.fixture.indicator = true
window.__REACT_AB__.fixture.verge.enable_tun_mode = true
window.__REACT_AB__.update()
})
await settle(page)
assert(Object.values(await readTabs()).every((tab) => tab.indicator))
await page.evaluate(() => {
window.__REACT_AB__.fixture.language = 'zh'
window.__REACT_AB__.update()
})
await settle(page)
const updated = Object.values(await readTabs())
assert(updated.every((tab) => tab.label.startsWith('zh:')))
assert(
(await page.locator('#tabs').textContent()).includes(
'zh:home.components.proxyTun.status.systemProxyEnabled',
),
)
const beforeColor = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
await page.evaluate(() => {
window.__REACT_AB__.fixture.theme = 'dark'
window.__REACT_AB__.update()
})
await settle(page)
const color = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
assert.notEqual(color, beforeColor)
return { unrelated, updated, beforeColor, color }
}
async function dns(page) {
await page.evaluate(() => window.__REACT_AB__.open())
const listen = page.locator('#dns input[placeholder=":53"]')
await page.waitForFunction(
() =>
document.querySelector('#dns input[placeholder=":53"]')?.value ===
':5353',
)
const edits = []
for (const value of [':54', ':55', ':56']) {
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(value)
await settle(page)
assert.equal(await listen.inputValue(), value)
edits.push({
value,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map(
(config) => config.dns.listen,
),
),
})
}
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(':')
await listen.pressSequentially('567', { delay: 0 })
await settle(page)
assert.equal(await listen.inputValue(), ':567')
edits.push({
value: ':567',
rapid: true,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map((config) => config.dns.listen),
),
})
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 1,
)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.fixture.saved[0].dns.listen),
':567',
)
await page.evaluate(() => window.__REACT_AB__.open())
await page.waitForSelector('#dns')
await page
.getByRole('button', { name: /shared.actions.resetToDefault$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':53')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
const editor = page.locator('#fixture-monaco')
assert.match(await editor.inputValue(), /listen: ["']?:53/)
await editor.fill(
'dns:\n enable: true\n listen: ":8053"\nhosts:\n example.test: 127.0.0.1\n',
)
await page
.getByRole('button', { name: /shared.editorModes.visualization$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':8053')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 2,
)
const saved = await page.evaluate(() => window.__REACT_AB__.fixture.saved)
assert.equal(saved[1].dns.listen, ':8053')
assert.equal(saved[1].hosts['example.test'], '127.0.0.1')
return { edits, saved }
}
async function delay(page) {
const updates = [
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 101 },
{ delay: 11, updatedAt: 101 },
{ delay: -1, updatedAt: 102 },
{ delay: 0, updatedAt: 103 },
{ delay: 1000000, elapsed: 20, updatedAt: 104 },
]
const observations = []
for (const update of updates) {
await page.evaluate((value) => {
window.__REACT_AB__.remember()
window.__REACT_AB__.delay.emit('Node 1', 'Group 1', value)
}, update)
await settle(page)
const result = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(result.delay, update)
observations.push({
update,
sameReference: result.sameReference,
childRenders: result.childRenders,
})
}
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({ delay: 0, updatedAt: 200 }),
)
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
0,
)
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({
delay: 1000000,
elapsed: 20,
updatedAt: 201,
}),
)
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().delay),
{ delay: 1000000, elapsed: 20, updatedAt: 201 },
)
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
let state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 1:Node 2'])
assert.deepEqual(state.delay, { delay: -1, updatedAt: 0 })
await page.evaluate(() => window.__REACT_AB__.update({ group: 'Group 2' }))
await settle(page)
state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 2:Node 2'])
for (const name of ['DIRECT', 'Unknown']) {
await page.evaluate((name) => window.__REACT_AB__.update({ name }), name)
await settle(page)
const preset = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(preset.listeners, [])
assert.deepEqual(preset.delay, { delay: -1, updatedAt: 0 })
}
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
['Group 2:Node 2'],
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
[],
)
return observations
}
-68
View File
@@ -1,68 +0,0 @@
import { Switch as MuiSwitch } from '@mui/material'
import { fixture } from './state'
const translations = {
en: (key: string) => `en:${key}`,
zh: (key: string) => `zh:${key}`,
}
export const useTranslation = () => ({
t: translations[fixture.language as 'en' | 'zh'],
})
export const useVerge = () => ({ verge: fixture.verge })
export const useProfiles = () => ({ current: { uid: 'fixture' } })
export const useClash = () => ({
mutateClash: () => {
throw new Error('Unexpected mutateClash')
},
})
export const useSystemState = () => ({ isTunModeAvailable: fixture.available })
export const useSystemProxyState = () => ({ indicator: fixture.indicator })
export const useThemeMode = () => fixture.theme
export const getDnsConfigContent = async () => {
fixture.commands.push('getDnsConfigContent')
return fixture.initialDns
}
export const saveDnsConfig = async (config: any) => {
fixture.commands.push('saveDnsConfig')
fixture.saved.push(structuredClone(config))
}
export const validateDnsConfig = async () => {
fixture.commands.push('validateDnsConfig')
return { status: 'valid' }
}
export const applyDnsConfig = () => {
throw new Error('Unexpected applyDnsConfig')
}
export const mutate = (fn: () => unknown) => fn()
export const showNotice = {
error: (...args: any[]) => fixture.notices.push({ error: args }),
success: (...args: any[]) => fixture.notices.push({ success: args }),
}
export const Switch = MuiSwitch
export function BaseDialog({ open, title, children, onClose, onOk }: any) {
return open ? (
<section id="dns">
{title}
<button id="close-dns" onClick={onClose}>
close
</button>
<button id="save-dns" onClick={onOk}>
save
</button>
{children}
</section>
) : null
}
export function MonacoEditor({ value, onChange }: any) {
return (
<textarea
id="fixture-monaco"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export default function ProxyControlSwitches({ label }: any) {
return <output id="fixture-controls">{label}</output>
}
-36
View File
@@ -1,36 +0,0 @@
type Update = { delay: number; elapsed?: number; updatedAt: number }
const listeners = new Map<string, (update: Update) => void>()
const cache = new Map<string, Update>()
let finish: ((update: Update) => void) | undefined
const key = (name: string, group: string) => `${group}:${name}`
export const delayFixture = {
listeners,
emit(name: string, group: string, update: Update) {
cache.set(key(name, group), update)
listeners.get(key(name, group))?.(update)
},
finish(update: Update) {
if (!finish) throw new Error('No pending delay request')
finish(update)
finish = undefined
},
}
export default {
setListener(name: string, group: string, listener: (update: Update) => void) {
listeners.set(key(name, group), listener)
},
removeListener(name: string, group: string) {
listeners.delete(key(name, group))
},
getDelayUpdate(name: string, group: string) {
return cache.get(key(name, group))
},
getDelayFix() {
return -1
},
checkDelay() {
return new Promise<Update>((resolve) => {
finish = resolve
})
},
}
-77
View File
@@ -1,77 +0,0 @@
import { spawnSync } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import { globSync } from 'glob'
const { values, positionals } = parseArgs({
options: { json: { type: 'boolean' } },
allowPositionals: true,
})
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const files = positionals.length
? positionals
: globSync('src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,html}', {
cwd: root,
nodir: true,
}).sort()
if (files.length === 0) throw new Error('No source files found')
for (const file of files) {
if (
!/\.(?:[jt]sx?|[cm][jt]s|html)$/.test(file) ||
!statSync(path.resolve(root, file)).isFile()
)
throw new Error(`Expected a source file: ${file}`)
}
const executable = fileURLToPath(
new URL('../bin/react-doctor.js', import.meta.resolve('react-doctor')),
)
const result = spawnSync(
process.execPath,
[
executable,
...files,
'--json',
'--no-telemetry',
'--no-supply-chain',
'--blocking',
'none',
'--yes',
],
{
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 64 * 1024 * 1024,
},
)
if (result.error) throw result.error
if (result.signal)
throw new Error(`React Doctor terminated by ${result.signal}`)
const report = JSON.parse(result.stdout)
if (values.json) {
process.stdout.write(result.stdout)
} else {
for (const diagnostic of report.diagnostics) {
console.log(
`${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.plugin}/${diagnostic.rule}\n ${diagnostic.message}`,
)
}
console.log(
`${report.summary.totalDiagnosticCount} advisory diagnostics across ${report.projects.reduce((count, project) => count + project.analyzedFileCount, 0)} analyzed files`,
)
}
// In 0.9.21, --blocking none also masks linter execution failures.
const incomplete = report.projects.filter((project) => !project.complete)
if (report.error) console.error(report.error.message)
for (const project of incomplete) {
console.error(
'Incomplete React Doctor scan:',
project.directory,
project.skippedCheckReasons,
)
}
process.exitCode =
result.status || (!report.ok || incomplete.length > 0 ? 1 : 0)
-5
View File
@@ -1,5 +0,0 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><title>React component A/B</title></head>
<body><div id="root"></div><script type="module" src="./scene.tsx"></script></body>
</html>
-16
View File
@@ -1,16 +0,0 @@
import { Paper as MuiPaper } from 'fixture-material'
export * from 'fixture-material'
import { fixture } from './state'
export function Paper(props: any) {
const { onClick, elevation, children, sx } = props
if (onClick && sx?.maxWidth === 160) {
fixture.tabRenders.push({
active: elevation === 2,
indicator: Boolean(children[2]),
label: children[1].props.children,
})
}
return <MuiPaper {...props} />
}
-19
View File
@@ -1,19 +0,0 @@
import type { ProfilerOnRenderCallback } from 'react'
export type ProfileRecord = Parameters<ProfilerOnRenderCallback>
const records: ProfileRecord[] = []
export const recordRender: ProfilerOnRenderCallback = (...record) => {
if (records.length === 1000) records.shift()
records.push(record)
}
export const profiler = {
read: () => records.map((record) => [...record]),
clear: () => {
records.length = 0
},
}
Object.assign(window, { __CVR_REACT_PROFILER__: profiler })
-124
View File
@@ -1,124 +0,0 @@
import { ThemeProvider, createTheme } from '@mui/material'
import {
Profiler,
StrictMode,
memo,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createRoot } from 'react-dom/client'
import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { DnsViewer } from '@/components/setting/mods/dns-viewer'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import { delayFixture } from './delay'
import { profiler, recordRender } from './profiler'
import { fixture } from './state'
const member = (name: string): any =>
name === 'Unknown'
? {
kind: 'unresolved',
ref: { kind: 'unresolved', name, reason: 'missing' },
}
: {
kind: 'node',
ref: { kind: 'node', recordId: name, name },
node: { name, history: [], source: { kind: 'core', proxyName: name } },
}
let update: (patch?: any) => void
let open: () => void
let delay: ReturnType<typeof useProxyDelayState>
let previous: unknown
let childRenders = 0
const DelayValue = memo(({ state }: any) => {
childRenders++
return <output id="delay">{JSON.stringify(state)}</output>
})
function DelayProbe({ name, group }: any) {
const resolved = useMemo(() => member(name), [name])
const current = useProxyDelayState(resolved, group)
useEffect(() => {
delay = current
})
return <DelayValue state={current.delayState} />
}
function Scene() {
const [state, setState] = useState({
revision: 0,
name: 'Node 1',
group: 'Group 1',
mounted: true,
})
const ref = useRef<any>(null)
useEffect(() => {
update = (patch = {}) =>
setState((old) => ({ ...old, ...patch, revision: old.revision + 1 }))
open = () => ref.current.open()
}, [])
const mode = fixture.theme
const theme = useMemo(() => createTheme({ palette: { mode } }), [mode])
return (
<ThemeProvider theme={theme}>
<main data-revision={state.revision}>
<section id="tabs" key={state.mounted ? 'mounted' : 'reset'}>
<ProxyTunCard />
</section>
<DnsViewer ref={ref} />
{state.mounted && <DelayProbe name={state.name} group={state.group} />}
</main>
</ThemeProvider>
)
}
const strict = new URLSearchParams(location.search).has('strict')
const app = (
<Profiler id="fixtures" onRender={recordRender}>
<Scene />
</Profiler>
)
createRoot(document.getElementById('root')!).render(
strict ? <StrictMode>{app}</StrictMode> : app,
)
Object.assign(window, {
__REACT_AB__: {
update: (patch?: any) => update(patch),
open: () => open(),
fixture,
delay: delayFixture,
request: () => {
void delay.onDelay()
},
remember: () => {
previous = delay.delayState
},
checkCollector: () => {
profiler.clear()
for (let index = 0; index < 1005; index++)
recordRender(String(index), 'update', 1, 2, 3, 4)
const records = profiler.read()
records[0][0] = 'changed copy'
const preserved = profiler.read()
const result = {
length: preserved.length,
first: preserved[0][0],
last: preserved.at(-1)![0],
copied: preserved[0][0] === '5',
cleared: false,
}
profiler.clear()
result.cleared = profiler.read().length === 0
return result
},
read: () => ({
fixture,
profile: profiler.read(),
childRenders,
sameReference: previous === delay.delayState,
delay: delay.delayState,
listeners: [...delayFixture.listeners.keys()],
}),
},
})
-13
View File
@@ -1,13 +0,0 @@
export const fixture = {
language: 'en',
theme: 'light' as 'light' | 'dark',
verge: { enable_tun_mode: false },
indicator: false,
available: true,
initialDns: 'dns:\n enable: true\n listen: ":5353"\n',
saved: [] as any[],
serialized: [] as any[],
tabRenders: [] as any[],
notices: [] as any[],
commands: [] as string[],
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../../src/*"],
"@root/*": ["../../*"],
"fixture-material": ["../../node_modules/@mui/material"],
"fixture-yaml": ["../../node_modules/js-yaml"]
}
},
"include": ["./*.ts", "./*.tsx", "../../src"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": true,
"types": ["node"]
},
"include": ["./*.mjs", "./*.mts"]
}
-61
View File
@@ -1,61 +0,0 @@
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
export function reactDiagnostics(mode: string | undefined): Plugin[] {
if (!mode) return []
return [
{
name: 'react-diagnostics',
enforce: 'pre',
apply: 'serve',
config() {
if (mode !== 'profiler' && mode !== 'wdyr')
throw new Error(`Unknown CVR_REACT_DIAGNOSTICS mode: ${mode}`)
return {
cacheDir: path.resolve('node_modules/.vite', mode),
...(mode === 'wdyr'
? {
oxc: {
jsx: {
importSource: '@welldone-software/why-did-you-render',
},
},
optimizeDeps: {
include: [
'@welldone-software/why-did-you-render',
'@welldone-software/why-did-you-render/jsx-dev-runtime',
],
},
}
: {}),
}
},
transformIndexHtml(html) {
if (mode !== 'wdyr') return html
const entry = 'src="./main.tsx"'
if (!html.includes(entry))
throw new Error('WDYR entry no longer matches index.html')
return html.replace(
entry,
`src="/@fs/${normalizePath(path.resolve('scripts/react/wdyr.ts'))}"`,
)
},
transform(code, id) {
if (mode !== 'profiler') return
if (id !== normalizePath(path.resolve('src/main.tsx'))) return
const open = '<React.StrictMode>'
const close = '</React.StrictMode>'
if (!code.includes(open) || !code.includes(close)) {
throw new Error('React profiling entry no longer matches main.tsx')
}
return {
code: `import { Profiler } from 'react'\nimport { recordRender } from '../scripts/react/profiler'\n${code}`
.replace(open, `<Profiler id="app" onRender={recordRender}>${open}`)
.replace(close, `${close}</Profiler>`),
map: null,
}
},
},
]
}
-10
View File
@@ -1,10 +0,0 @@
import whyDidYouRender from '@welldone-software/why-did-you-render'
import React from 'react'
whyDidYouRender(React, {
include: [/^(ProxyItem|ProxyItemMini|ProxyTunCard)$/],
trackAllPureComponents: false,
trackHooks: true,
})
await import('../../src/main')
-9
View File
@@ -1,9 +0,0 @@
import * as yaml from 'fixture-yaml'
export * from 'fixture-yaml'
import { fixture } from './state'
export function dump(value: any, options: any) {
fixture.serialized.push(structuredClone(value))
return yaml.dump(value, options)
}
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.6.0"
version = "2.5.9"
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",
"$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>
)
}
+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')
@@ -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) => {
+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'),
)

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