mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-04 14:22:11 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d8f702af | ||
|
|
067a9f7cad |
@@ -157,12 +157,15 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -185,6 +188,14 @@ jobs:
|
|||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
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
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -193,6 +204,12 @@ jobs:
|
|||||||
- name: Release ${{ env.TAG_CHANNEL }} Version
|
- name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
run: pnpm release-version autobuild-latest
|
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
|
- name: Tauri build for Windows-macOS-Linux
|
||||||
uses: tauri-apps/tauri-action@v1
|
uses: tauri-apps/tauri-action@v1
|
||||||
env:
|
env:
|
||||||
@@ -243,12 +260,15 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -265,6 +285,14 @@ jobs:
|
|||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
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
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -317,6 +345,12 @@ jobs:
|
|||||||
gcc-arm-linux-gnueabihf \
|
gcc-arm-linux-gnueabihf \
|
||||||
g++-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
|
- name: Tauri Build for Linux
|
||||||
run: |
|
run: |
|
||||||
export PKG_CONFIG_ALLOW_CROSS=1
|
export PKG_CONFIG_ALLOW_CROSS=1
|
||||||
@@ -370,18 +404,15 @@ jobs:
|
|||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Add Rust Target
|
||||||
uses: dtolnay/rust-toolchain@master
|
run: rustup target add ${{ matrix.target }}
|
||||||
with:
|
|
||||||
toolchain: '1.99.1'
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -398,6 +429,14 @@ jobs:
|
|||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
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
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -413,6 +452,12 @@ jobs:
|
|||||||
Remove-Item .\src-tauri\tauri.windows.conf.json
|
Remove-Item .\src-tauri\tauri.windows.conf.json
|
||||||
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json 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
|
- name: Tauri build for Windows
|
||||||
id: build
|
id: build
|
||||||
uses: tauri-apps/tauri-action@v1
|
uses: tauri-apps/tauri-action@v1
|
||||||
|
|||||||
@@ -77,12 +77,11 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@1.99.1
|
uses: dtolnay/rust-toolchain@1.99.1
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
if: github.event.inputs[matrix.input] == 'true'
|
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -107,6 +106,15 @@ jobs:
|
|||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
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
|
- name: Pnpm install and check
|
||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ jobs:
|
|||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
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
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run Prettier check
|
- name: Run Prettier check
|
||||||
|
|||||||
@@ -206,12 +206,15 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -223,22 +226,27 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
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
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: '26.13.0'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
pnpm run prebuild ${{ matrix.target }}
|
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
|
- name: Tauri build
|
||||||
uses: tauri-apps/tauri-action@v1.0.0
|
uses: tauri-apps/tauri-action@v1.0.0
|
||||||
env:
|
env:
|
||||||
@@ -311,27 +319,29 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
cache-workspace-crates: true
|
cache-workspace-crates: true
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
@@ -382,6 +392,12 @@ jobs:
|
|||||||
gcc-arm-linux-gnueabihf \
|
gcc-arm-linux-gnueabihf \
|
||||||
g++-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
|
- name: Build for Linux
|
||||||
run: |
|
run: |
|
||||||
export PKG_CONFIG_ALLOW_CROSS=1
|
export PKG_CONFIG_ALLOW_CROSS=1
|
||||||
@@ -442,27 +458,29 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
cache-workspace-crates: true
|
cache-workspace-crates: true
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
name: Install pnpm
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '26.13.0'
|
||||||
cache: 'pnpm'
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
name: Install pnpm
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
@@ -476,6 +494,12 @@ jobs:
|
|||||||
Remove-Item .\src-tauri\tauri.windows.conf.json
|
Remove-Item .\src-tauri\tauri.windows.conf.json
|
||||||
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json 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
|
- name: Tauri build
|
||||||
uses: tauri-apps/tauri-action@v1.0.0
|
uses: tauri-apps/tauri-action@v1.0.0
|
||||||
env:
|
env:
|
||||||
|
|||||||
Generated
+4
-3
@@ -1081,7 +1081,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.6.0"
|
version = "2.5.9-rc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1111,6 +1111,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
"minisign-verify",
|
||||||
"nanoid",
|
"nanoid",
|
||||||
"network-interface",
|
"network-interface",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -1228,8 +1229,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clash_verge_service_ipc"
|
name = "clash_verge_service_ipc"
|
||||||
version = "2.7.7"
|
version = "2.7.6"
|
||||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.7#3ed29700b093190b5e7addcaa892e77f172af544"
|
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"kode-bridge",
|
"kode-bridge",
|
||||||
|
|||||||
+28
-23
@@ -1,41 +1,46 @@
|
|||||||
<!-- Release-notes skeleton. Entries accumulate in ./Changelog.md as PRs land;
|
## v2.5.9
|
||||||
before publishing a release, distill them:
|
|
||||||
- user-visible changes only — no dev tooling, CI fixes, or refactors whose
|
- **Mihomo(Meta) 内核升级至 v1.19.33**
|
||||||
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
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
- 修复检测更新后又发布新版时,应用内更新失败
|
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
|
||||||
|
- 修复 重启应用后定时自动备份不执行的问题
|
||||||
|
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
|
||||||
|
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
|
||||||
|
- 修复 首页 网站测速失败后卡片一直显示测试中
|
||||||
|
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
|
||||||
|
- 修复 代理 在全局模式下无法展开节点组
|
||||||
|
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
|
||||||
|
- 修复 WebDAV 设置清空后无法保存
|
||||||
|
|
||||||
|
**🍎 macOS**
|
||||||
|
|
||||||
|
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||||
|
|
||||||
|
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
|
||||||
|
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
|
||||||
|
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
|
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -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
|
## v2.5.8
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -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}'],
|
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
|
||||||
rules: ipcBoundaryRules(),
|
rules: ipcBoundaryRules(),
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||||
"entry": [
|
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
|
||||||
"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!"
|
|
||||||
],
|
|
||||||
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
|
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
|
||||||
"include": [
|
"include": [
|
||||||
"files",
|
"files",
|
||||||
|
|||||||
+3
-10
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.6.0",
|
"version": "2.5.9-rc",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
@@ -40,11 +40,7 @@
|
|||||||
"perf:build": "node scripts/perf/build.mjs",
|
"perf:build": "node scripts/perf/build.mjs",
|
||||||
"perf:run": "node scripts/perf/run.mjs",
|
"perf:run": "node scripts/perf/run.mjs",
|
||||||
"perf:compare": "node scripts/perf/compare.mjs",
|
"perf:compare": "node scripts/perf/compare.mjs",
|
||||||
"perf:test": "node --test scripts/perf/report.test.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"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/collision": "^0.5.0",
|
"@dnd-kit/collision": "^0.5.0",
|
||||||
@@ -138,10 +134,7 @@
|
|||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.1",
|
||||||
"webdriverio": "10.1.0",
|
"webdriverio": "10.1.0",
|
||||||
"ws": "8.22.1",
|
"ws": "8.22.1"
|
||||||
"playwright": "1.64.1",
|
|
||||||
"@welldone-software/why-did-you-render": "10.0.1",
|
|
||||||
"react-doctor": "0.9.21"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,mjs}": [
|
"*.{ts,tsx,js,mjs}": [
|
||||||
|
|||||||
Generated
+2
-940
File diff suppressed because it is too large
Load Diff
@@ -41,10 +41,7 @@ fi
|
|||||||
|
|
||||||
tmpfile=$(mktemp)
|
tmpfile=$(mktemp)
|
||||||
{
|
{
|
||||||
node -e '
|
cat "$CHANGELOG"
|
||||||
const fs = require("node:fs");
|
|
||||||
process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(/<!--[\s\S]*?-->/g, "").trimStart());
|
|
||||||
' "$CHANGELOG"
|
|
||||||
echo
|
echo
|
||||||
echo ""
|
echo ""
|
||||||
if [ -f "$HISTORY" ]; then
|
if [ -f "$HISTORY" ]; then
|
||||||
|
|||||||
@@ -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() {
|
function prepare() {
|
||||||
let refresh
|
let refresh
|
||||||
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
|
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
|
||||||
@@ -236,15 +211,8 @@ function prepare() {
|
|||||||
: last
|
: last
|
||||||
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
|
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
|
||||||
: `${base}-rc`)
|
: `${base}-rc`)
|
||||||
if (tags.includes(tag)) throw new Error(`Tag ${tag} already exists.`)
|
if (occupied.includes(tag))
|
||||||
for (const [prefix, target] of [
|
throw new Error(`Tag ${tag} already exists or is reserved.`)
|
||||||
['release/', 'dev'],
|
|
||||||
['publish/', 'main'],
|
|
||||||
]) {
|
|
||||||
const branch = `${prefix}${tag}`
|
|
||||||
if (refs.includes(`refs/heads/${branch}`) && branch !== refresh?.head.ref)
|
|
||||||
discardClosedCandidate(branch, target, prefix)
|
|
||||||
}
|
|
||||||
const sha = git('rev-parse', 'HEAD')
|
const sha = git('rev-parse', 'HEAD')
|
||||||
const body = releaseBody(tag, sha, tags, false)
|
const body = releaseBody(tag, sha, tags, false)
|
||||||
summary(body)
|
summary(body)
|
||||||
@@ -345,8 +313,6 @@ function releaseBody(tag, sha, tags, publishing) {
|
|||||||
.join('\n\n') || 'No new commits.'
|
.join('\n\n') || 'No new commits.'
|
||||||
return `## ${titleFor(tag)}
|
return `## ${titleFor(tag)}
|
||||||
|
|
||||||
@clash-verge-rev/release
|
|
||||||
|
|
||||||
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
|
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
|
||||||
${
|
${
|
||||||
publishing
|
publishing
|
||||||
@@ -487,7 +453,7 @@ function publishPr() {
|
|||||||
validateVersionCommit(tag, sha)
|
validateVersionCommit(tag, sha)
|
||||||
const branch = `publish/${tag}`
|
const branch = `publish/${tag}`
|
||||||
const existing = list(
|
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) {
|
if (existing.length) {
|
||||||
const publication = existing[0]
|
const publication = existing[0]
|
||||||
@@ -601,31 +567,28 @@ function publish() {
|
|||||||
requireApproval(pr, sha)
|
requireApproval(pr, sha)
|
||||||
const main = git('rev-parse', 'origin/main')
|
const main = git('rev-parse', 'origin/main')
|
||||||
git('merge-base', '--is-ancestor', main, sha)
|
git('merge-base', '--is-ancestor', main, sha)
|
||||||
git(
|
const object = api('git/tags', {
|
||||||
'-c',
|
|
||||||
'user.name=github-actions[bot]',
|
|
||||||
'-c',
|
|
||||||
'user.email=41898282+github-actions[bot]@users.noreply.github.com',
|
|
||||||
'tag',
|
|
||||||
'--annotate',
|
|
||||||
'--no-sign',
|
|
||||||
'--message',
|
|
||||||
marker,
|
|
||||||
tag,
|
tag,
|
||||||
sha,
|
message: marker,
|
||||||
)
|
object: sha,
|
||||||
// GraphQL updateRefs rejects annotated tag objects; Git preserves atomicity.
|
type: 'commit',
|
||||||
git(
|
})
|
||||||
'push',
|
// The tag records run ownership in the same transaction as the fast-forward.
|
||||||
'--atomic',
|
updateRefs(pr.base.repo.node_id, [
|
||||||
`--force-with-lease=refs/heads/main:${main}`,
|
{ name: 'refs/heads/main', beforeOid: main, afterOid: sha, force: false },
|
||||||
`--force-with-lease=refs/heads/${pr.head.ref}:${sha}`,
|
{
|
||||||
`--force-with-lease=refs/tags/${tag}:`,
|
name: `refs/heads/${pr.head.ref}`,
|
||||||
'origin',
|
beforeOid: sha,
|
||||||
`${sha}:refs/heads/main`,
|
afterOid: sha,
|
||||||
`${sha}:refs/heads/${pr.head.ref}`,
|
force: false,
|
||||||
`refs/tags/${tag}:refs/tags/${tag}`,
|
},
|
||||||
)
|
{
|
||||||
|
name: `refs/tags/${tag}`,
|
||||||
|
beforeOid: '0'.repeat(40),
|
||||||
|
afterOid: object.sha,
|
||||||
|
force: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
|
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
|
||||||
}
|
}
|
||||||
releaseOutput(tag, sha)
|
releaseOutput(tag, sha)
|
||||||
@@ -654,14 +617,9 @@ function validateTag() {
|
|||||||
function prepareDraft() {
|
function prepareDraft() {
|
||||||
const tag = env.RELEASE_TAG
|
const tag = env.RELEASE_TAG
|
||||||
verifyTarget()
|
verifyTarget()
|
||||||
const result = run('gh', [
|
const release = list('releases?per_page=100').find(
|
||||||
'api',
|
(release) => release.tag_name === tag,
|
||||||
'--paginate',
|
)
|
||||||
endpoint('releases?per_page=100'),
|
|
||||||
'--jq',
|
|
||||||
`.[] | select(.tag_name == ${JSON.stringify(tag)}) | {id, draft}`,
|
|
||||||
])
|
|
||||||
const release = result ? JSON.parse(result) : null
|
|
||||||
if (release && !release.draft)
|
if (release && !release.draft)
|
||||||
throw new Error('This release is already public.')
|
throw new Error('This release is already public.')
|
||||||
const draft =
|
const draft =
|
||||||
|
|||||||
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!META_MAP[`${platform}-${arch}`]) {
|
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}`]) {
|
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 name = META_ALPHA_MAP[`${platform}-${arch}`]
|
||||||
const isWin = platform === 'win32'
|
const isWin = platform === 'win32'
|
||||||
const urlExt = isWin ? 'zip' : 'gz'
|
const urlExt = isWin ? 'zip' : 'gz'
|
||||||
@@ -285,7 +285,7 @@ function mihomoAlpha() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mihomo() {
|
function clashMeta() {
|
||||||
const name = META_MAP[`${platform}-${arch}`]
|
const name = META_MAP[`${platform}-${arch}`]
|
||||||
const isWin = platform === 'win32'
|
const isWin = platform === 'win32'
|
||||||
const urlExt = isWin ? 'zip' : 'gz'
|
const urlExt = isWin ? 'zip' : 'gz'
|
||||||
@@ -733,12 +733,13 @@ const tasks = [
|
|||||||
{
|
{
|
||||||
name: 'verge-mihomo-alpha',
|
name: 'verge-mihomo-alpha',
|
||||||
func: () =>
|
func: () =>
|
||||||
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
|
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
|
||||||
retry: 5,
|
retry: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'verge-mihomo',
|
name: 'verge-mihomo',
|
||||||
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
|
func: () =>
|
||||||
|
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
|
||||||
retry: 5,
|
retry: 5,
|
||||||
},
|
},
|
||||||
// After both sidecar tasks: it hashes what they downloaded.
|
// After both sidecar tasks: it hashes what they downloaded.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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>
|
|
||||||
@@ -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} />
|
|
||||||
}
|
|
||||||
@@ -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 })
|
|
||||||
@@ -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()],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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[],
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["./*.mjs", "./*.mts"]
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.6.0"
|
version = "2.5.9-rc"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -84,6 +84,7 @@ flate2 = "1.1.10"
|
|||||||
reqwest_dav = "0.3.3"
|
reqwest_dav = "0.3.3"
|
||||||
aes-gcm = { version = "0.11.1" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
|
minisign-verify = "0.3.0"
|
||||||
sha2 = "0.11.1"
|
sha2 = "0.11.1"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
@@ -97,7 +98,7 @@ tracing = { workspace = true }
|
|||||||
tauri-plugin-devtools = { version = "2.2.2", optional = 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" }
|
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.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",
|
"client",
|
||||||
] }
|
] }
|
||||||
arc-swap = "1.9.2"
|
arc-swap = "1.9.2"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ ManifestDPIAwareness PerMonitorV2
|
|||||||
!include "FileAssociation.nsh"
|
!include "FileAssociation.nsh"
|
||||||
!include "Win\COM.nsh"
|
!include "Win\COM.nsh"
|
||||||
!include "Win\Propkey.nsh"
|
!include "Win\Propkey.nsh"
|
||||||
!include "Win\RestartManager.nsh"
|
|
||||||
!include "WinVer.nsh"
|
!include "WinVer.nsh"
|
||||||
!include "LogicLib.nsh"
|
!include "LogicLib.nsh"
|
||||||
!include "StrFunc.nsh"
|
!include "StrFunc.nsh"
|
||||||
@@ -333,8 +332,6 @@ Function PageLeaveReinstall
|
|||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
reinst_uninstall:
|
reinst_uninstall:
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
|
||||||
|
|
||||||
HideWindow
|
HideWindow
|
||||||
ClearErrors
|
ClearErrors
|
||||||
|
|
||||||
@@ -371,10 +368,7 @@ Function PageLeaveReinstall
|
|||||||
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
|
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
|
||||||
Abort
|
Abort
|
||||||
${EndIf}
|
${EndIf}
|
||||||
Return
|
|
||||||
|
|
||||||
reinst_done:
|
reinst_done:
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
; 5. Choose install directory page
|
; 5. Choose install directory page
|
||||||
@@ -907,7 +901,8 @@ Section Install
|
|||||||
|
|
||||||
nsExec::Exec 'netsh int tcp res'
|
nsExec::Exec 'netsh int tcp res'
|
||||||
|
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
!include "Win\RestartManager.nsh"
|
||||||
|
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
|
|
||||||
; Ensure startup folders exist
|
; Ensure startup folders exist
|
||||||
@@ -1106,7 +1101,7 @@ Section Uninstall
|
|||||||
!insertmacro NSIS_HOOK_PREUNINSTALL
|
!insertmacro NSIS_HOOK_PREUNINSTALL
|
||||||
!endif
|
!endif
|
||||||
|
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
!insertmacro RemoveVergeService
|
!insertmacro RemoveVergeService
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{
|
use crate::{
|
||||||
cmd::StringifyErr as _,
|
cmd::StringifyErr as _,
|
||||||
core::{
|
core::{SilentUpdater, updater::DownloadEvent},
|
||||||
SilentUpdater,
|
|
||||||
updater::{DownloadEvent, InstallUpdateOutcome},
|
|
||||||
},
|
|
||||||
feat,
|
feat,
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
@@ -64,7 +61,8 @@ pub async fn install_update(
|
|||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
version: std::string::String,
|
version: std::string::String,
|
||||||
on_event: Channel<DownloadEvent>,
|
on_event: Channel<DownloadEvent>,
|
||||||
) -> CmdResult<InstallUpdateOutcome> {
|
) -> CmdResult<bool> {
|
||||||
|
let version: String = version.into();
|
||||||
SilentUpdater::global()
|
SilentUpdater::global()
|
||||||
.install_update(&app_handle, &version, |event| {
|
.install_update(&app_handle, &version, |event| {
|
||||||
let _ = on_event.send(event);
|
let _ = on_event.send(event);
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
|||||||
logging!(info, Type::Config, "Config regenerated successfully");
|
logging!(info, Type::Config, "Config regenerated successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ pub async fn delete_profile(index: std::string::String) -> CmdResult {
|
|||||||
drop(config_update_guard);
|
drop(config_update_guard);
|
||||||
drop(profile_write_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 {
|
if let Err(e) = Tray::global().update_tooltip().await {
|
||||||
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
|
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);
|
handle::Handle::notify_profile_changed(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
Ok(ValidationOutcome::Valid)
|
Ok(ValidationOutcome::Valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ async fn handle_saved_profile_file(
|
|||||||
);
|
);
|
||||||
match CoreManager::global().update_config_forced().await {
|
match CoreManager::global().update_config_forced().await {
|
||||||
Ok(outcome) if outcome.is_valid() => {
|
Ok(outcome) if outcome.is_valid() => {
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
Ok(ValidationOutcome::Valid)
|
Ok(ValidationOutcome::Valid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,41 +184,84 @@ impl IClashTemp {
|
|||||||
_ => None,
|
_ => 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
|
let mut port = config
|
||||||
.get(key)
|
.get("redir-port")
|
||||||
.and_then(|value| match value {
|
.and_then(|value| match value {
|
||||||
Value::String(val_str) => val_str.parse().ok(),
|
Value::String(val_str) => val_str.parse().ok(),
|
||||||
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.unwrap_or(default);
|
.unwrap_or(7895);
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = default;
|
port = 7895;
|
||||||
}
|
}
|
||||||
port
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn guard_tproxy_port(config: &Mapping) -> u16 {
|
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 {
|
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 {
|
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 {
|
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 {
|
pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
|
||||||
|
|||||||
@@ -14,15 +14,6 @@ mod verge;
|
|||||||
pub(crate) use self::config::Config;
|
pub(crate) use self::config::Config;
|
||||||
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
|
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) {
|
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;";
|
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-71
@@ -1,5 +1,3 @@
|
|||||||
use super::patch_opt_field;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::profiles,
|
config::profiles,
|
||||||
utils::{
|
utils::{
|
||||||
@@ -133,18 +131,19 @@ impl PrfOption {
|
|||||||
match (one, other) {
|
match (one, other) {
|
||||||
(Some(a_ref), Some(b_ref)) => {
|
(Some(a_ref), Some(b_ref)) => {
|
||||||
let mut result = a_ref.clone();
|
let mut result = a_ref.clone();
|
||||||
patch_opt_field!(result, b_ref, user_agent);
|
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
|
||||||
patch_opt_field!(result, b_ref, with_proxy);
|
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
|
||||||
patch_opt_field!(result, b_ref, self_proxy);
|
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
|
||||||
patch_opt_field!(result, b_ref, danger_accept_invalid_certs);
|
result.danger_accept_invalid_certs =
|
||||||
patch_opt_field!(result, b_ref, allow_auto_update);
|
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
|
||||||
patch_opt_field!(result, b_ref, update_interval);
|
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
|
||||||
patch_opt_field!(result, b_ref, merge);
|
result.update_interval = b_ref.update_interval.or(result.update_interval);
|
||||||
patch_opt_field!(result, b_ref, script);
|
result.merge = b_ref.merge.clone().or(result.merge);
|
||||||
patch_opt_field!(result, b_ref, rules);
|
result.script = b_ref.script.clone().or(result.script);
|
||||||
patch_opt_field!(result, b_ref, proxies);
|
result.rules = b_ref.rules.clone().or(result.rules);
|
||||||
patch_opt_field!(result, b_ref, groups);
|
result.proxies = b_ref.proxies.clone().or(result.proxies);
|
||||||
patch_opt_field!(result, b_ref, timeout_seconds);
|
result.groups = b_ref.groups.clone().or(result.groups);
|
||||||
|
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
|
||||||
Some(result)
|
Some(result)
|
||||||
}
|
}
|
||||||
(Some(a_ref), None) => Some(a_ref.clone()),
|
(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(
|
async fn from_local(
|
||||||
name: String,
|
name: String,
|
||||||
desc: String,
|
desc: String,
|
||||||
@@ -230,7 +197,31 @@ impl PrfItem {
|
|||||||
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
|
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
|
||||||
let mut groups = opt_ref.and_then(|o| o.groups.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 {
|
Ok(Self {
|
||||||
uid: Some(uid),
|
uid: Some(uid),
|
||||||
itype: Some("local".into()),
|
itype: Some("local".into()),
|
||||||
@@ -384,7 +375,31 @@ impl PrfItem {
|
|||||||
bail!("profile does not contain `proxies` or `proxy-providers`");
|
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 {
|
Ok(Self {
|
||||||
uid: Some(uid),
|
uid: Some(uid),
|
||||||
@@ -446,30 +461,46 @@ impl PrfItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self {
|
fn from_rules() -> Self {
|
||||||
let uid = help::get_uid(prefix).into();
|
let uid = help::get_uid("r").into();
|
||||||
let file = format!("{uid}.yaml").into(); // yaml ext
|
let file = format!("{uid}.yaml").into(); // yaml ext
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
uid: Some(uid),
|
uid: Some(uid),
|
||||||
itype: Some(itype.into()),
|
itype: Some("rules".into()),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||||
file_data: Some(template.into()),
|
file_data: Some(tmpl::ITEM_RULES.into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_rules() -> Self {
|
|
||||||
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_proxies() -> Self {
|
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 {
|
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> {
|
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 {
|
impl PrfItem {
|
||||||
current_field!(current_merge, merge);
|
pub(crate) fn current_merge(&self) -> Option<&String> {
|
||||||
current_field!(current_script, script);
|
self.option.as_ref().and_then(|o| o.merge.as_ref())
|
||||||
current_field!(current_rules, rules);
|
}
|
||||||
current_field!(current_proxies, proxies);
|
|
||||||
current_field!(current_groups, groups);
|
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 {
|
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::{
|
use super::{
|
||||||
PrfOption, patch_opt_field,
|
PrfOption,
|
||||||
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
||||||
};
|
};
|
||||||
use crate::core::notify::{Refresh, announce};
|
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 {
|
impl IProfiles {
|
||||||
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
|
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)?;
|
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
|
||||||
@@ -236,15 +244,15 @@ impl IProfiles {
|
|||||||
|
|
||||||
for each in items.iter_mut() {
|
for each in items.iter_mut() {
|
||||||
if each.uid.as_deref() == Some(uid) {
|
if each.uid.as_deref() == Some(uid) {
|
||||||
patch_opt_field!(each, item, itype);
|
patch!(each, item, itype);
|
||||||
patch_opt_field!(each, item, name);
|
patch!(each, item, name);
|
||||||
patch_opt_field!(each, item, desc);
|
patch!(each, item, desc);
|
||||||
patch_opt_field!(each, item, file);
|
patch!(each, item, file);
|
||||||
patch_opt_field!(each, item, url);
|
patch!(each, item, url);
|
||||||
patch_opt_field!(each, item, selected);
|
patch!(each, item, selected);
|
||||||
patch_opt_field!(each, item, extra);
|
patch!(each, item, extra);
|
||||||
patch_opt_field!(each, item, updated);
|
patch!(each, item, updated);
|
||||||
patch_opt_field!(each, item, option);
|
patch!(each, item, option);
|
||||||
|
|
||||||
self.items = Some(items);
|
self.items = Some(items);
|
||||||
return self.save_file().await;
|
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
|
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<()> {
|
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
|
||||||
let profiles = Config::profiles().await;
|
let profiles = Config::profiles().await;
|
||||||
profiles_append_item_to_safe(&profiles, item).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<()> {
|
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<()> {
|
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<()> {
|
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<()> {
|
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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
@@ -696,21 +713,19 @@ where
|
|||||||
Ok(())
|
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(¤t))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a backend-made selection so the next core start restores it.
|
/// Records a backend-made selection so the next core start restores it.
|
||||||
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
||||||
let group_name = String::from(group_name);
|
let group_name = String::from(group_name);
|
||||||
let node = String::from(node);
|
let node = String::from(node);
|
||||||
persist_selected_update(true, move |mut profiles| async move {
|
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(¤t)))
|
||||||
|
else {
|
||||||
return Ok((profiles, false));
|
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<()> {
|
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
||||||
persist_selected_update(true, move |mut profiles| async move {
|
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(¤t)))
|
||||||
|
else {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted},
|
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
|
||||||
constants::network,
|
constants::network,
|
||||||
utils::{dirs, help},
|
utils::{dirs, help},
|
||||||
};
|
};
|
||||||
@@ -39,7 +39,7 @@ pub struct IVerge {
|
|||||||
|
|
||||||
pub traffic_graph: Option<bool>,
|
pub traffic_graph: Option<bool>,
|
||||||
|
|
||||||
/// show memory info (only for Mihomo)
|
/// show memory info (only for Clash Meta)
|
||||||
pub enable_memory_usage: Option<bool>,
|
pub enable_memory_usage: Option<bool>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -432,90 +432,98 @@ impl IVerge {
|
|||||||
|
|
||||||
#[allow(clippy::cognitive_complexity)]
|
#[allow(clippy::cognitive_complexity)]
|
||||||
pub fn patch_config(&mut self, patch: &Self) {
|
pub fn patch_config(&mut self, patch: &Self) {
|
||||||
patch_opt_field!(self, patch, app_log_level);
|
macro_rules! patch {
|
||||||
patch_opt_field!(self, patch, app_log_max_size);
|
($key: tt) => {
|
||||||
patch_opt_field!(self, patch, app_log_max_count);
|
if patch.$key.is_some() {
|
||||||
|
self.$key = patch.$key.clone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
patch_opt_field!(self, patch, language);
|
patch!(app_log_level);
|
||||||
patch_opt_field!(self, patch, theme_mode);
|
patch!(app_log_max_size);
|
||||||
patch_opt_field!(self, patch, tray_event);
|
patch!(app_log_max_count);
|
||||||
patch_opt_field!(self, patch, env_type);
|
|
||||||
patch_opt_field!(self, patch, start_page);
|
patch!(language);
|
||||||
patch_opt_field!(self, patch, startup_script);
|
patch!(theme_mode);
|
||||||
patch_opt_field!(self, patch, traffic_graph);
|
patch!(tray_event);
|
||||||
patch_opt_field!(self, patch, enable_memory_usage);
|
patch!(env_type);
|
||||||
patch_opt_field!(self, patch, enable_group_icon);
|
patch!(start_page);
|
||||||
patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur);
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
patch_opt_field!(self, patch, tray_icon);
|
patch!(tray_icon);
|
||||||
patch_opt_field!(self, patch, menu_icon);
|
patch!(menu_icon);
|
||||||
patch_opt_field!(self, patch, menu_order);
|
patch!(menu_order);
|
||||||
patch_opt_field!(self, patch, proxy_group_tools_position);
|
patch!(proxy_group_tools_position);
|
||||||
patch_opt_field!(self, patch, notice_position);
|
patch!(notice_position);
|
||||||
patch_opt_field!(self, patch, collapse_navbar);
|
patch!(collapse_navbar);
|
||||||
patch_opt_field!(self, patch, common_tray_icon);
|
patch!(common_tray_icon);
|
||||||
patch_opt_field!(self, patch, sysproxy_tray_icon);
|
patch!(sysproxy_tray_icon);
|
||||||
patch_opt_field!(self, patch, tun_tray_icon);
|
patch!(tun_tray_icon);
|
||||||
|
|
||||||
patch_opt_field!(self, patch, enable_tun_mode);
|
patch!(enable_tun_mode);
|
||||||
patch_opt_field!(self, patch, enable_auto_launch);
|
patch!(enable_auto_launch);
|
||||||
patch_opt_field!(self, patch, enable_silent_start);
|
patch!(enable_silent_start);
|
||||||
patch_opt_field!(self, patch, enable_hover_jump_navigator);
|
patch!(enable_hover_jump_navigator);
|
||||||
patch_opt_field!(self, patch, hover_jump_navigator_delay);
|
patch!(hover_jump_navigator_delay);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
patch_opt_field!(self, patch, verge_redir_port);
|
patch!(verge_redir_port);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
patch_opt_field!(self, patch, verge_redir_enabled);
|
patch!(verge_redir_enabled);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
patch_opt_field!(self, patch, verge_tproxy_port);
|
patch!(verge_tproxy_port);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
patch_opt_field!(self, patch, verge_tproxy_enabled);
|
patch!(verge_tproxy_enabled);
|
||||||
patch_opt_field!(self, patch, verge_mixed_port);
|
patch!(verge_mixed_port);
|
||||||
patch_opt_field!(self, patch, verge_socks_port);
|
patch!(verge_socks_port);
|
||||||
patch_opt_field!(self, patch, verge_socks_enabled);
|
patch!(verge_socks_enabled);
|
||||||
patch_opt_field!(self, patch, verge_port);
|
patch!(verge_port);
|
||||||
patch_opt_field!(self, patch, verge_http_enabled);
|
patch!(verge_http_enabled);
|
||||||
patch_opt_field!(self, patch, enable_system_proxy);
|
patch!(enable_system_proxy);
|
||||||
patch_opt_field!(self, patch, enable_proxy_guard);
|
patch!(enable_proxy_guard);
|
||||||
patch_opt_field!(self, patch, enable_bypass_check);
|
patch!(enable_bypass_check);
|
||||||
patch_opt_field!(self, patch, use_default_bypass);
|
patch!(use_default_bypass);
|
||||||
patch_opt_field!(self, patch, system_proxy_bypass);
|
patch!(system_proxy_bypass);
|
||||||
patch_opt_field!(self, patch, proxy_guard_duration);
|
patch!(proxy_guard_duration);
|
||||||
patch_opt_field!(self, patch, proxy_auto_config);
|
patch!(proxy_auto_config);
|
||||||
patch_opt_field!(self, patch, pac_file_content);
|
patch!(pac_file_content);
|
||||||
patch_opt_field!(self, patch, proxy_host);
|
patch!(proxy_host);
|
||||||
patch_opt_field!(self, patch, theme_setting);
|
patch!(theme_setting);
|
||||||
patch_opt_field!(self, patch, web_ui_list);
|
patch!(web_ui_list);
|
||||||
patch_opt_field!(self, patch, clash_core);
|
patch!(clash_core);
|
||||||
patch_opt_field!(self, patch, hotkeys);
|
patch!(hotkeys);
|
||||||
patch_opt_field!(self, patch, enable_global_hotkey);
|
patch!(enable_global_hotkey);
|
||||||
|
|
||||||
patch_opt_field!(self, patch, auto_close_connection);
|
patch!(auto_close_connection);
|
||||||
patch_opt_field!(self, patch, auto_check_update);
|
patch!(auto_check_update);
|
||||||
patch_opt_field!(self, patch, default_latency_test);
|
patch!(default_latency_test);
|
||||||
patch_opt_field!(self, patch, default_latency_timeout);
|
patch!(default_latency_timeout);
|
||||||
patch_opt_field!(self, patch, enable_auto_delay_detection);
|
patch!(enable_auto_delay_detection);
|
||||||
patch_opt_field!(self, patch, auto_delay_detection_interval_minutes);
|
patch!(auto_delay_detection_interval_minutes);
|
||||||
patch_opt_field!(self, patch, enable_builtin_enhanced);
|
patch!(enable_builtin_enhanced);
|
||||||
patch_opt_field!(self, patch, proxy_layout_column);
|
patch!(proxy_layout_column);
|
||||||
patch_opt_field!(self, patch, test_list);
|
patch!(test_list);
|
||||||
patch_opt_field!(self, patch, auto_log_clean);
|
patch!(auto_log_clean);
|
||||||
patch_opt_field!(self, patch, enable_auto_backup_schedule);
|
patch!(enable_auto_backup_schedule);
|
||||||
patch_opt_field!(self, patch, auto_backup_interval_hours);
|
patch!(auto_backup_interval_hours);
|
||||||
patch_opt_field!(self, patch, auto_backup_on_change);
|
patch!(auto_backup_on_change);
|
||||||
|
|
||||||
patch_opt_field!(self, patch, webdav_url);
|
patch!(webdav_url);
|
||||||
patch_opt_field!(self, patch, webdav_username);
|
patch!(webdav_username);
|
||||||
patch_opt_field!(self, patch, webdav_password);
|
patch!(webdav_password);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
patch_opt_field!(self, patch, enable_tray_speed);
|
patch!(enable_tray_speed);
|
||||||
patch_opt_field!(self, patch, tray_proxy_groups_display_mode);
|
patch!(tray_proxy_groups_display_mode);
|
||||||
patch_opt_field!(self, patch, tray_inline_outbound_modes);
|
patch!(tray_inline_outbound_modes);
|
||||||
patch_opt_field!(self, patch, enable_auto_light_weight_mode);
|
patch!(enable_auto_light_weight_mode);
|
||||||
patch_opt_field!(self, patch, auto_light_weight_minutes);
|
patch!(auto_light_weight_minutes);
|
||||||
patch_opt_field!(self, patch, enable_dns_settings);
|
patch!(enable_dns_settings);
|
||||||
patch_opt_field!(self, patch, home_cards);
|
patch!(home_cards);
|
||||||
patch_opt_field!(self, patch, enable_external_controller);
|
patch!(enable_external_controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_log_level(&self) -> LevelFilter {
|
pub fn get_log_level(&self) -> LevelFilter {
|
||||||
|
|||||||
@@ -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 {
|
pub mod timing {
|
||||||
use super::Duration;
|
use super::Duration;
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ use crate::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{
|
sync::{
|
||||||
OnceLock,
|
Arc, OnceLock,
|
||||||
atomic::{AtomicBool, AtomicI64, Ordering},
|
atomic::{AtomicBool, AtomicI64, Ordering},
|
||||||
},
|
},
|
||||||
time::{Duration, UNIX_EPOCH},
|
time::{Duration, UNIX_EPOCH},
|
||||||
@@ -81,9 +82,8 @@ impl Default for AutoBackupSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AutoBackupManager {
|
pub struct AutoBackupManager {
|
||||||
|
settings: Arc<RwLock<AutoBackupSettings>>,
|
||||||
settings_tx: watch::Sender<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,
|
runner_started: AtomicBool,
|
||||||
exec_lock: Mutex<()>,
|
exec_lock: Mutex<()>,
|
||||||
last_backup: AtomicI64,
|
last_backup: AtomicI64,
|
||||||
@@ -93,10 +93,10 @@ impl AutoBackupManager {
|
|||||||
pub fn global() -> &'static Self {
|
pub fn global() -> &'static Self {
|
||||||
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
||||||
INSTANCE.get_or_init(|| {
|
INSTANCE.get_or_init(|| {
|
||||||
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
|
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
||||||
Self {
|
Self {
|
||||||
settings_tx,
|
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
|
||||||
settings_rx,
|
settings_tx: tx,
|
||||||
runner_started: AtomicBool::new(false),
|
runner_started: AtomicBool::new(false),
|
||||||
exec_lock: Mutex::new(()),
|
exec_lock: Mutex::new(()),
|
||||||
last_backup: AtomicI64::new(0),
|
last_backup: AtomicI64::new(0),
|
||||||
@@ -106,6 +106,9 @@ impl AutoBackupManager {
|
|||||||
|
|
||||||
pub async fn init(&self) -> Result<()> {
|
pub async fn init(&self) -> Result<()> {
|
||||||
let settings = Self::load_settings().await;
|
let settings = Self::load_settings().await;
|
||||||
|
{
|
||||||
|
*self.settings.write() = settings;
|
||||||
|
}
|
||||||
let _ = self.settings_tx.send_replace(settings);
|
let _ = self.settings_tx.send_replace(settings);
|
||||||
self.maybe_start_runner(settings);
|
self.maybe_start_runner(settings);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -113,6 +116,9 @@ impl AutoBackupManager {
|
|||||||
|
|
||||||
pub async fn refresh_settings(&self) -> Result<()> {
|
pub async fn refresh_settings(&self) -> Result<()> {
|
||||||
let settings = Self::load_settings().await;
|
let settings = Self::load_settings().await;
|
||||||
|
{
|
||||||
|
*self.settings.write() = settings;
|
||||||
|
}
|
||||||
let _ = self.settings_tx.send_replace(settings);
|
let _ = self.settings_tx.send_replace(settings);
|
||||||
self.maybe_start_runner(settings);
|
self.maybe_start_runner(settings);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -187,7 +193,7 @@ impl AutoBackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
|
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 {
|
if trigger.is_schedule() && !snapshot.schedule_enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -466,3 +466,12 @@ impl Hotkey {
|
|||||||
(del_list, add_list)
|
(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,47 +40,8 @@ impl LightweightState {
|
|||||||
|
|
||||||
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
|
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
|
||||||
|
|
||||||
struct WindowListener {
|
static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
|
||||||
handler_id: AtomicU32,
|
static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
|
||||||
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 CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
|
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() {
|
fn setup_window_close_listener() {
|
||||||
WINDOW_CLOSE_LISTENER.setup(|| {
|
if let Some(window) = WindowManager::get_main_window() {
|
||||||
std::mem::drop(AsyncHandler::spawn(|| async {
|
let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
setup_light_weight_timer().await;
|
if previous_handler_id != 0 {
|
||||||
}));
|
window.unlisten(previous_handler_id);
|
||||||
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
|
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() {
|
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() {
|
fn setup_webview_focus_listener() {
|
||||||
WEBVIEW_FOCUS_LISTENER.setup(|| {
|
if let Some(window) = WindowManager::get_main_window() {
|
||||||
cancel_light_weight_timer();
|
let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
|
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() {
|
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() {
|
async fn setup_light_weight_timer() {
|
||||||
|
|||||||
@@ -435,10 +435,6 @@ impl CoreManager {
|
|||||||
{
|
{
|
||||||
anyhow::bail!("Sidecar did not become ready");
|
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());
|
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||||
self.apply_proxy_after_start().await
|
self.apply_proxy_after_start().await
|
||||||
}
|
}
|
||||||
@@ -496,7 +492,6 @@ impl CoreManager {
|
|||||||
{
|
{
|
||||||
anyhow::bail!("Sidecar did not become ready after service uninstall");
|
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());
|
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||||
self.apply_proxy_after_start().await
|
self.apply_proxy_after_start().await
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod state;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||||
use clash_verge_logging::{LogRing, Type, logging, logging_error};
|
use clash_verge_logging::{LogRing, Type, logging};
|
||||||
use std::{
|
use std::{
|
||||||
fmt,
|
fmt,
|
||||||
sync::{
|
sync::{
|
||||||
@@ -274,10 +274,6 @@ impl CoreManager {
|
|||||||
|
|
||||||
pub(crate) fn finish_config_update(&self) {
|
pub(crate) fn finish_config_update(&self) {
|
||||||
self.config_update_in_progress.store(false, Ordering::Release);
|
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))]
|
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
notification::{self, FailedOperation},
|
notification::{self, FailedOperation},
|
||||||
runstate::RUN_STATE,
|
runstate::RUN_STATE,
|
||||||
service,
|
service,
|
||||||
sysopt::{Sysopt, format_bypass},
|
sysopt::Sysopt,
|
||||||
},
|
},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
utils::{
|
utils::{
|
||||||
@@ -204,6 +204,8 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LOOPBACK_HOST: &str = "127.0.0.1";
|
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;
|
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
|
||||||
|
|
||||||
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
|
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("");
|
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
||||||
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
|
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);
|
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
|
||||||
Ok(bypass)
|
Ok(bypass)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
|
|||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::path::{Component, Path, PathBuf, Prefix};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
pub(crate) const GEO_ASSETS: &[&str] = &[
|
pub(crate) const GEO_ASSETS: &[&str] = &[
|
||||||
"Country.mmdb",
|
"Country.mmdb",
|
||||||
@@ -165,7 +165,6 @@ fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<Runtim
|
|||||||
&mut assets,
|
&mut assets,
|
||||||
&mut remote_providers,
|
&mut remote_providers,
|
||||||
)?;
|
)?;
|
||||||
collect_ssh_private_keys(&mut config, &config_root, &mut destinations, &mut assets);
|
|
||||||
for filename in GEO_ASSETS {
|
for filename in GEO_ASSETS {
|
||||||
let source = config_root.join(filename);
|
let source = config_root.join(filename);
|
||||||
if source.is_file() && destinations.insert((*filename).to_string()) {
|
if source.is_file() && destinations.insert((*filename).to_string()) {
|
||||||
@@ -271,75 +270,6 @@ fn collect_provider_assets(
|
|||||||
Ok(())
|
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> {
|
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
|
||||||
let path = Path::new(raw_path);
|
let path = Path::new(raw_path);
|
||||||
let source = if path.is_absolute() {
|
let source = if path.is_absolute() {
|
||||||
|
|||||||
+41
-111
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
||||||
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
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,
|
tray::Tray,
|
||||||
},
|
},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
@@ -101,7 +101,6 @@ struct ActiveServiceSession {
|
|||||||
proof: OwnerSessionProof,
|
proof: OwnerSessionProof,
|
||||||
supports_runtime_staging: bool,
|
supports_runtime_staging: bool,
|
||||||
supports_runtime_file_read: bool,
|
supports_runtime_file_read: bool,
|
||||||
supports_runtime_geo_read: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_service_session_token() -> Result<String> {
|
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)
|
.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() {
|
pub(crate) fn clear_active_service_session() {
|
||||||
ACTIVE_SERVICE_SESSION.lock().take();
|
ACTIVE_SERVICE_SESSION.lock().take();
|
||||||
}
|
}
|
||||||
@@ -148,7 +140,6 @@ pub(crate) fn clear_active_service_session() {
|
|||||||
struct ServiceCapabilities {
|
struct ServiceCapabilities {
|
||||||
runtime_staging: bool,
|
runtime_staging: bool,
|
||||||
runtime_file_read: bool,
|
runtime_file_read: bool,
|
||||||
runtime_geo_read: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServiceCapabilities {
|
impl ServiceCapabilities {
|
||||||
@@ -156,7 +147,6 @@ impl ServiceCapabilities {
|
|||||||
Self {
|
Self {
|
||||||
runtime_staging: info.supports_runtime_staging(),
|
runtime_staging: info.supports_runtime_staging(),
|
||||||
runtime_file_read: info.supports_runtime_file_read(),
|
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")]
|
#[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 deelevate::{PrivilegeLevel, Token};
|
||||||
use runas::Command as RunasCommand;
|
use runas::Command as RunasCommand;
|
||||||
use std::os::windows::process::CommandExt as _;
|
use std::os::windows::process::CommandExt as _;
|
||||||
|
|
||||||
let token = Token::with_current_process()?;
|
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
|
||||||
let level = token.privilege_level()?;
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
if !uninstall_path.exists() {
|
||||||
bail!(format!("uninstaller not found: {uninstall_path:?}"));
|
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() {
|
if !status.success() {
|
||||||
bail!(
|
bail!(
|
||||||
"failed to uninstall service with status {}",
|
"failed to uninstall service with status {}",
|
||||||
status.code().unwrap_or(-1)
|
status.code().unwrap_or(-1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
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"))
|
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 elevator = crate::utils::help::linux_elevator();
|
||||||
let status = if linux_running_as_root() {
|
let status = if linux_running_as_root() {
|
||||||
StdCommand::new(&uninstall_path).status()?
|
StdCommand::new(&uninstall_path).status()?
|
||||||
@@ -697,7 +666,14 @@ fn uninstall_service() -> Result<()> {
|
|||||||
status.code().unwrap_or(-1)
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -712,10 +688,14 @@ fn linux_running_as_root() -> bool {
|
|||||||
fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
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"))
|
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_path = macos_service_tool_path(&uninstall_path)?;
|
||||||
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
|
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()?;
|
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<()> {
|
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_staging: capabilities.runtime_staging,
|
||||||
supports_runtime_file_read: capabilities.runtime_file_read,
|
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)`.
|
// 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)
|
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.
|
// 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>> {
|
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
|
||||||
let applied = runtime.data_arc();
|
let applied = runtime.data_arc();
|
||||||
|
|||||||
@@ -241,19 +241,20 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Sysopt {
|
pub(crate) struct Sysopt {
|
||||||
// Owns the proxy target pair; every reader and writer goes through this lock.
|
update_lock: TokioMutex<()>,
|
||||||
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
|
|
||||||
guard_operation_lock: TokioMutex<()>,
|
guard_operation_lock: TokioMutex<()>,
|
||||||
reset_sysproxy: AtomicBool,
|
reset_sysproxy: AtomicBool,
|
||||||
|
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
|
||||||
guard: Arc<RwLock<GuardMonitor>>,
|
guard: Arc<RwLock<GuardMonitor>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Sysopt {
|
impl Default for Sysopt {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
|
update_lock: TokioMutex::new(()),
|
||||||
guard_operation_lock: TokioMutex::new(()),
|
guard_operation_lock: TokioMutex::new(()),
|
||||||
reset_sysproxy: AtomicBool::new(false),
|
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)))),
|
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"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
static BYPASS_SEPARATOR: &str = ",";
|
static BYPASS_SEPARATOR: &str = ",";
|
||||||
#[cfg(target_os = "macos")]
|
#[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() {
|
if custom_bypass.is_empty() {
|
||||||
DEFAULT_BYPASS.into()
|
DEFAULT_BYPASS.into()
|
||||||
} else if use_default {
|
} else if use_default {
|
||||||
@@ -359,7 +361,7 @@ impl Sysopt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while self.access_guard().read().get_state().is_pendding() {
|
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.
|
// `start()` false may also mean a guard was already running.
|
||||||
!self.access_guard().read().get_state().is_stopped()
|
!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
|
/// configuration instead of a partially-applied one (e.g. SOCKS already
|
||||||
/// disabled but HTTP still enabled mid-transition).
|
/// disabled but HTTP still enabled mid-transition).
|
||||||
pub(crate) async fn wait_idle(&self) {
|
pub(crate) async fn wait_idle(&self) {
|
||||||
let _ = self.proxy_state_lock.lock().await;
|
let _ = self.update_lock.lock().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// init the sysproxy
|
/// 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<()> {
|
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();
|
let verge = Config::verge().await.latest_arc();
|
||||||
// Configured, not live: this runs while the Core is being started or restarted, and
|
// 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.
|
// asking a Core that is not up yet would only fall back here anyway.
|
||||||
let port = MixedPort::desired().await;
|
let port = MixedPort::desired().await;
|
||||||
let pac_port = server::embedded_server_port()?;
|
let pac_port = server::embedded_server_port()?;
|
||||||
|
// 先 await, 避免持有锁导致的 Send 问题
|
||||||
let bypass = get_bypass().await;
|
let bypass = get_bypass().await;
|
||||||
|
|
||||||
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
||||||
@@ -432,7 +431,7 @@ impl Sysopt {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (sys, auto, guard_type) = {
|
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.host = proxy_host.into();
|
||||||
sys.port = port;
|
sys.port = port;
|
||||||
sys.bypass = bypass.into();
|
sys.bypass = bypass.into();
|
||||||
@@ -529,10 +528,6 @@ impl Sysopt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// reset the sysproxy
|
/// 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<()> {
|
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
|
||||||
if self
|
if self
|
||||||
.reset_sysproxy
|
.reset_sysproxy
|
||||||
@@ -544,13 +539,13 @@ impl Sysopt {
|
|||||||
defer! {
|
defer! {
|
||||||
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
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 _guard_operation = self.guard_operation_lock.lock().await;
|
||||||
let drained = self.stop_proxy_guard_locked().await;
|
let drained = self.stop_proxy_guard_locked().await;
|
||||||
|
|
||||||
// 直接关闭所有代理
|
// 直接关闭所有代理
|
||||||
let (sys, auto) = {
|
let (sys, auto) = {
|
||||||
let (sys, auto) = &mut *proxy_state;
|
let (sys, auto) = &mut *self.inner_proxy.write();
|
||||||
sys.enable = false;
|
sys.enable = false;
|
||||||
auto.enable = false;
|
auto.enable = false;
|
||||||
(sys.clone(), auto.clone())
|
(sys.clone(), auto.clone())
|
||||||
|
|||||||
@@ -636,8 +636,7 @@ async fn create_tray_menu(
|
|||||||
.tray_proxy_groups_display_mode
|
.tray_proxy_groups_display_mode
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("default");
|
.unwrap_or("default");
|
||||||
let include_proxy_groups =
|
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
|
||||||
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
|
|
||||||
|
|
||||||
let proxy_group_order_map = runtime_proxy_groups_order;
|
let proxy_group_order_map = runtime_proxy_groups_order;
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ pub struct SilentUpdater {
|
|||||||
background_cancel: Mutex<Option<CancellationToken>>,
|
background_cancel: Mutex<Option<CancellationToken>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, specta::Type)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum InstallUpdateOutcome {
|
|
||||||
Installed,
|
|
||||||
Cancelled,
|
|
||||||
UpdateChanged,
|
|
||||||
}
|
|
||||||
|
|
||||||
singleton!(SilentUpdater, SILENT_UPDATER);
|
singleton!(SilentUpdater, SILENT_UPDATER);
|
||||||
|
|
||||||
impl SilentUpdater {
|
impl SilentUpdater {
|
||||||
@@ -42,7 +34,7 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, specta::Type)]
|
||||||
struct UpdateCacheMeta {
|
struct UpdateCacheMeta {
|
||||||
version: String,
|
version: String,
|
||||||
downloaded_at: String,
|
downloaded_at: String,
|
||||||
@@ -109,11 +101,11 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the cached installer for `update`, deleting a cache that does not match it.
|
/// 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 meta = Self::read_cache_meta().ok()?;
|
||||||
let bytes = if meta.version == update.version {
|
let bytes = if meta.version == update.version {
|
||||||
Self::read_cache_bytes().and_then(|bytes| {
|
Self::read_cache_bytes().and_then(|bytes| {
|
||||||
update.verify(&bytes)?;
|
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
})
|
})
|
||||||
} else {
|
} 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 {
|
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
||||||
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
|
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
|
||||||
return false;
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,13 +424,13 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
|
/// 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(
|
pub async fn install_update(
|
||||||
&self,
|
&self,
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
version: &str,
|
version: &str,
|
||||||
on_event: impl Fn(DownloadEvent) + Sync,
|
on_event: impl Fn(DownloadEvent) + Sync,
|
||||||
) -> Result<InstallUpdateOutcome> {
|
) -> Result<bool> {
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
*self.manual_cancel.lock() = Some(cancel.clone());
|
*self.manual_cancel.lock() = Some(cancel.clone());
|
||||||
let prepared = tokio::select! {
|
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
|
// `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.
|
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
|
||||||
self.manual_cancel.lock().take();
|
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");
|
logging!(info, Type::System, "Update v{version} cancelled");
|
||||||
return Ok(InstallUpdateOutcome::Cancelled);
|
return Ok(false);
|
||||||
};
|
|
||||||
let Some((update, bytes)) = prepared else {
|
|
||||||
return Ok(InstallUpdateOutcome::UpdateChanged);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
logging!(info, Type::System, "Installing update v{version}...");
|
logging!(info, Type::System, "Installing update v{version}...");
|
||||||
Self::install(update, bytes).await?;
|
Self::install(update, bytes).await?;
|
||||||
Self::delete_cache();
|
Self::delete_cache();
|
||||||
Ok(InstallUpdateOutcome::Installed)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_install(
|
async fn prepare_install(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
version: &str,
|
version: &str,
|
||||||
on_event: impl Fn(DownloadEvent) + Sync,
|
on_event: impl Fn(DownloadEvent) + Sync,
|
||||||
) -> Result<Option<(Update, Vec<u8>)>> {
|
) -> Result<(Update, Vec<u8>)> {
|
||||||
let Some(update) = Self::check(app_handle)
|
let update = Self::check(app_handle)
|
||||||
.await?
|
.await?
|
||||||
.filter(|update| update.version == version)
|
.filter(|update| update.version == version)
|
||||||
else {
|
.with_context(|| format!("v{version} is no longer offered"))?;
|
||||||
return Ok(None);
|
let bytes = match Self::verified_cache(app_handle, &update) {
|
||||||
};
|
|
||||||
let bytes = match Self::verified_cache(&update) {
|
|
||||||
Some(bytes) => bytes,
|
Some(bytes) => bytes,
|
||||||
None => download(&update, on_event).await?,
|
None => download(&update, on_event).await?,
|
||||||
};
|
};
|
||||||
Ok(Some((update, bytes)))
|
Ok((update, bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_download(&self) {
|
pub fn cancel_download(&self) {
|
||||||
@@ -614,7 +641,7 @@ impl SilentUpdater {
|
|||||||
return Ok(());
|
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");
|
logging!(info, Type::System, "Silent updater: v{version} already cached");
|
||||||
} else {
|
} else {
|
||||||
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
||||||
|
|||||||
@@ -324,7 +324,6 @@ impl CoreConfigValidator {
|
|||||||
return Self::validate_file_syntax_outcome(config_path).await;
|
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 {
|
let is_script = match Self::is_script_file(config_path).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -426,8 +425,6 @@ impl CoreConfigValidator {
|
|||||||
}
|
}
|
||||||
logging!(info, Type::Validate, "生成临时配置文件用于验证");
|
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;
|
let fingerprint = validation_fingerprint(yaml).await;
|
||||||
if *LAST_VALIDATED.lock() == Some(fingerprint) {
|
if *LAST_VALIDATED.lock() == Some(fingerprint) {
|
||||||
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
|
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#![cfg(target_os = "windows")]
|
#![cfg(target_os = "windows")]
|
||||||
|
|
||||||
use crate::{core::service, utils::dirs};
|
use crate::utils::dirs;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
use deelevate::{PrivilegeLevel, Token};
|
||||||
|
use runas::Command as RunasCommand;
|
||||||
|
use std::process::Command as StdCommand;
|
||||||
|
|
||||||
pub fn invoke_uwptools() -> Result<()> {
|
pub fn invoke_uwptools() -> Result<()> {
|
||||||
let resource_dir = dirs::app_resources_dir()?;
|
let resource_dir = dirs::app_resources_dir()?;
|
||||||
@@ -11,7 +14,13 @@ pub fn invoke_uwptools() -> Result<()> {
|
|||||||
bail!("enableLoopback exe not found");
|
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(())
|
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
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ pub enum ChainType {
|
|||||||
Groups(SeqMap),
|
Groups(SeqMap),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ChainSupport {
|
||||||
|
ClashMeta,
|
||||||
|
ClashMetaAlpha,
|
||||||
|
}
|
||||||
|
|
||||||
// Helper trait to allow async conversion
|
// Helper trait to allow async conversion
|
||||||
pub trait AsyncChainItemFrom {
|
pub trait AsyncChainItemFrom {
|
||||||
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
|
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
|
||||||
@@ -47,16 +53,70 @@ impl AsyncChainItemFrom for Option<ChainItem> {
|
|||||||
uid,
|
uid,
|
||||||
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
|
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
|
||||||
}),
|
}),
|
||||||
"rules" | "proxies" | "groups" => {
|
"rules" => {
|
||||||
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
|
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
|
||||||
let data = match itype {
|
Some(ChainItem {
|
||||||
"rules" => ChainType::Rules(seq_map),
|
uid,
|
||||||
"proxies" => ChainType::Proxies(seq_map),
|
data: ChainType::Rules(seq_map),
|
||||||
_ => ChainType::Groups(seq_map),
|
})
|
||||||
};
|
}
|
||||||
Some(ChainItem { uid, data })
|
"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,
|
_ => 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value {
|
|||||||
pub fn use_lowercase(config: &Mapping) -> Mapping {
|
pub fn use_lowercase(config: &Mapping) -> Mapping {
|
||||||
let mut lowercased = Mapping::new();
|
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() {
|
if let Some(key_str) = key.as_str() {
|
||||||
lowercased.insert(lowercase_key(key_str), value.clone());
|
lowercased.insert(lowercase_key(key_str), value.clone());
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,17 @@ pub fn use_lowercase(config: &Mapping) -> Mapping {
|
|||||||
lowercased
|
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 {
|
pub fn use_sort(mut config: Mapping) -> Mapping {
|
||||||
let mut sorted = Mapping::new();
|
let mut sorted = Mapping::new();
|
||||||
HANDLE_FIELDS.into_iter().for_each(|key| {
|
HANDLE_FIELDS.into_iter().for_each(|key| {
|
||||||
|
|||||||
+127
-64
@@ -8,7 +8,7 @@ mod tun;
|
|||||||
|
|
||||||
use self::{
|
use self::{
|
||||||
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
|
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
|
||||||
field::{use_keys, use_lowercase, use_sort},
|
field::{use_keys, use_lowercase_owned, use_sort},
|
||||||
merge::use_merge,
|
merge::use_merge,
|
||||||
script::use_script,
|
script::use_script,
|
||||||
seq::{SeqMap, use_seq},
|
seq::{SeqMap, use_seq},
|
||||||
@@ -34,6 +34,7 @@ type ResultLog = Vec<(String, String)>;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ConfigValues {
|
struct ConfigValues {
|
||||||
clash_config: Mapping,
|
clash_config: Mapping,
|
||||||
|
clash_core: Option<String>,
|
||||||
enable_tun: bool,
|
enable_tun: bool,
|
||||||
enable_builtin: bool,
|
enable_builtin: bool,
|
||||||
socks_enabled: 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_settings = verge_arc.dns_settings_for(profile_uid);
|
||||||
let dns_override_confirmation = dns_settings.confirmation;
|
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_tun_mode.unwrap_or(false),
|
||||||
enable_builtin_enhanced.unwrap_or(true),
|
enable_builtin_enhanced.unwrap_or(true),
|
||||||
verge_socks_enabled.unwrap_or(false),
|
verge_socks_enabled.unwrap_or(false),
|
||||||
@@ -146,6 +148,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
|
|||||||
|
|
||||||
ConfigValues {
|
ConfigValues {
|
||||||
clash_config,
|
clash_config,
|
||||||
|
clash_core,
|
||||||
enable_tun,
|
enable_tun,
|
||||||
enable_builtin,
|
enable_builtin,
|
||||||
socks_enabled,
|
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 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!(
|
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(&merge_uid).ok(), || ChainItem {
|
||||||
chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,),
|
uid: "".into(),
|
||||||
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,),
|
data: ChainType::Merge(Mapping::new()),
|
||||||
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(&script_uid).ok(), || ChainItem {
|
||||||
chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,),
|
uid: "".into(),
|
||||||
chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,),
|
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 {
|
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 config: Mapping,
|
||||||
mut exists_keys: Vec<String>,
|
mut exists_keys: Vec<String>,
|
||||||
mut result_map: HashMap<String, ResultLog>,
|
mut result_map: HashMap<String, ResultLog>,
|
||||||
merge_item: ChainItem,
|
global_merge: ChainItem,
|
||||||
script_item: ChainItem,
|
global_script: ChainItem,
|
||||||
profile_name: &String,
|
profile_name: &String,
|
||||||
authoritative: &AuthoritativeFields,
|
authoritative: &AuthoritativeFields,
|
||||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
) -> (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));
|
exists_keys.extend(use_keys(&merge));
|
||||||
let before = authoritative.current(&config);
|
let before = authoritative.current(&config);
|
||||||
config = use_merge(&merge, config);
|
config = use_merge(&merge, config);
|
||||||
@@ -249,11 +262,11 @@ async fn process_override_items(
|
|||||||
.map(discarded_note)
|
.map(discarded_note)
|
||||||
.collect();
|
.collect();
|
||||||
if !notes.is_empty() {
|
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 before = authoritative.current(&config);
|
||||||
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||||
exists_keys.extend(changed_keys);
|
exists_keys.extend(changed_keys);
|
||||||
@@ -264,7 +277,7 @@ async fn process_override_items(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(discarded_note),
|
.map(discarded_note),
|
||||||
);
|
);
|
||||||
result_map.insert(script_item.uid, logs);
|
result_map.insert(global_script.uid, logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
(config, exists_keys, result_map)
|
(config, exists_keys, result_map)
|
||||||
@@ -531,6 +544,46 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
|
|||||||
config
|
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.
|
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
||||||
fn merge_default_config(
|
fn merge_default_config(
|
||||||
mut config: Mapping,
|
mut config: Mapping,
|
||||||
@@ -602,12 +655,25 @@ fn merge_default_config(
|
|||||||
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 {
|
if enable_builtin {
|
||||||
// The JS path saw a lowercased view; lowercase once, only when the builtin runs.
|
let items: Vec<_> = ChainItem::builtin()
|
||||||
config = use_lowercase(&config);
|
.into_iter()
|
||||||
logging!(debug, Type::Core, "run builtin script verge_hy_alpn");
|
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
|
||||||
config = builtin_hy_alpn(config);
|
.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
|
config
|
||||||
@@ -627,6 +693,14 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
|
|||||||
config
|
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 {
|
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||||
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
|
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
|
||||||
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
|
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 cfg_vals = get_config_values(profile_uid).await;
|
||||||
let ConfigValues {
|
let ConfigValues {
|
||||||
clash_config,
|
clash_config,
|
||||||
|
clash_core,
|
||||||
enable_tun,
|
enable_tun,
|
||||||
enable_builtin,
|
enable_builtin,
|
||||||
socks_enabled,
|
socks_enabled,
|
||||||
@@ -854,13 +929,13 @@ pub async fn enhance(
|
|||||||
tproxy_enabled,
|
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 = use_tun(config, enable_tun);
|
||||||
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
|
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
|
||||||
|
|
||||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
|
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,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
result_map,
|
result_map,
|
||||||
@@ -871,7 +946,7 @@ pub async fn enhance(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (config, exists_keys, result_map) = process_override_items(
|
let (config, exists_keys, result_map) = process_profile_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
result_map,
|
result_map,
|
||||||
@@ -1353,7 +1428,7 @@ mod authoritative_field_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
|
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;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -1401,28 +1476,22 @@ mod tests {
|
|||||||
data: ChainType::Merge(merge.clone()),
|
data: ChainType::Merge(merge.clone()),
|
||||||
};
|
};
|
||||||
let profile_name = "test-profile".into();
|
let profile_name = "test-profile".into();
|
||||||
let (config, keys, logs) = process_override_items(
|
let (config, keys, logs) = process_global_items(
|
||||||
config,
|
config,
|
||||||
vec![],
|
vec![],
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
merge_item("global-merge"),
|
merge_item("global-merge"),
|
||||||
ChainItem {
|
ChainItem::to_script("global-script", script),
|
||||||
uid: "global-script".into(),
|
|
||||||
data: ChainType::Script(script.into()),
|
|
||||||
},
|
|
||||||
&profile_name,
|
&profile_name,
|
||||||
&authoritative,
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (config, _, logs) = process_override_items(
|
let (config, _, logs) = process_profile_items(
|
||||||
config,
|
config,
|
||||||
keys,
|
keys,
|
||||||
logs,
|
logs,
|
||||||
merge_item("profile-merge"),
|
merge_item("profile-merge"),
|
||||||
ChainItem {
|
ChainItem::to_script("profile-script", script),
|
||||||
uid: "profile-script".into(),
|
|
||||||
data: ChainType::Script(script.into()),
|
|
||||||
},
|
|
||||||
&profile_name,
|
&profile_name,
|
||||||
&authoritative,
|
&authoritative,
|
||||||
)
|
)
|
||||||
@@ -1467,19 +1536,16 @@ mod tests {
|
|||||||
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
|
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
let global_script = ChainItem {
|
let global_script = ChainItem::to_script(
|
||||||
uid: "Script".into(),
|
"Script",
|
||||||
data: ChainType::Script(
|
r#"function main(config) {
|
||||||
r#"function main(config) {
|
|
||||||
config["global-script-wins"] = "global-script";
|
config["global-script-wins"] = "global-script";
|
||||||
config["profile-merge-wins"] = "global-script";
|
config["profile-merge-wins"] = "global-script";
|
||||||
config["profile-script-wins"] = "global-script";
|
config["profile-script-wins"] = "global-script";
|
||||||
config.nested.winner = "global-script";
|
config.nested.winner = "global-script";
|
||||||
return config;
|
return config;
|
||||||
}"#
|
}"#,
|
||||||
.into(),
|
);
|
||||||
),
|
|
||||||
};
|
|
||||||
let profile_merge = ChainItem {
|
let profile_merge = ChainItem {
|
||||||
uid: "profile-merge".into(),
|
uid: "profile-merge".into(),
|
||||||
data: ChainType::Merge(mapping(
|
data: ChainType::Merge(mapping(
|
||||||
@@ -1487,21 +1553,18 @@ mod tests {
|
|||||||
nested: {winner: profile-merge}}",
|
nested: {winner: profile-merge}}",
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
let profile_script = ChainItem {
|
let profile_script = ChainItem::to_script(
|
||||||
uid: "profile-script".into(),
|
"profile-script",
|
||||||
data: ChainType::Script(
|
r#"function main(config) {
|
||||||
r#"function main(config) {
|
|
||||||
config["profile-script-wins"] = "profile-script";
|
config["profile-script-wins"] = "profile-script";
|
||||||
config.nested.winner = "profile-script";
|
config.nested.winner = "profile-script";
|
||||||
return config;
|
return config;
|
||||||
}"#
|
}"#,
|
||||||
.into(),
|
);
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let profile_name = "test-profile".into();
|
let profile_name = "test-profile".into();
|
||||||
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
|
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,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
@@ -1511,7 +1574,7 @@ mod tests {
|
|||||||
&authoritative,
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (config, exists_keys, _) = process_override_items(
|
let (config, exists_keys, _) = process_profile_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
result_map,
|
result_map,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{process::AsyncHandler, utils::tmpl};
|
use crate::{process::AsyncHandler, utils::tmpl};
|
||||||
|
|
||||||
use super::field::use_lowercase;
|
use super::field::{use_lowercase, use_lowercase_owned};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
|
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
|
||||||
use clash_verge_logging::{Type, logging};
|
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 {
|
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
|
||||||
// The template is identity-plus-lowercasing; skip the Boa round-trip.
|
// The template is identity-plus-lowercasing; skip the Boa round-trip.
|
||||||
if script == tmpl::ITEM_SCRIPT {
|
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)) {
|
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) {
|
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) => {
|
Err(err) => {
|
||||||
logging!(
|
logging!(
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("fake-ip");
|
.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, "enable", true);
|
||||||
revise!(dns_val, "ipv6", ipv6_val);
|
revise!(dns_val, "ipv6", ipv6_val);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use reqwest_dav::list_cmd::ListFile;
|
use reqwest_dav::list_cmd::ListFile;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
@@ -33,8 +33,6 @@ async fn finalize_restored_verge_config(
|
|||||||
webdav_username: Option<String>,
|
webdav_username: Option<String>,
|
||||||
webdav_password: Option<String>,
|
webdav_password: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config_write = Config::lock_config_write().await;
|
|
||||||
|
|
||||||
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
|
// 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?;
|
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
|
||||||
restored.webdav_url = webdav_url;
|
restored.webdav_url = webdav_url;
|
||||||
@@ -45,42 +43,43 @@ async fn finalize_restored_verge_config(
|
|||||||
let restored_clash = IClashTemp::new().await;
|
let restored_clash = IClashTemp::new().await;
|
||||||
let clash_draft = Config::clash().await;
|
let clash_draft = Config::clash().await;
|
||||||
clash_draft.edit_draft(|d| {
|
clash_draft.edit_draft(|d| {
|
||||||
*d = restored_clash;
|
*d = restored_clash.clone();
|
||||||
});
|
});
|
||||||
clash_draft.apply();
|
clash_draft.apply();
|
||||||
|
|
||||||
let restored_profiles = IProfiles::new().await;
|
let restored_profiles = IProfiles::new().await;
|
||||||
let profiles_draft = Config::profiles().await;
|
let profiles_draft = Config::profiles().await;
|
||||||
profiles_draft.edit_draft(|d| {
|
profiles_draft.edit_draft(|d| {
|
||||||
*d = restored_profiles;
|
*d = restored_profiles.clone();
|
||||||
});
|
});
|
||||||
profiles_draft.apply();
|
profiles_draft.apply();
|
||||||
|
|
||||||
let verge_draft = Config::verge().await;
|
let verge_draft = Config::verge().await;
|
||||||
verge_draft.edit_draft(|d| {
|
{
|
||||||
*d = restored.clone();
|
// 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.apply();
|
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.
|
// Turn it off here; a failing core side effect below would otherwise skip this step.
|
||||||
if !restored.enable_system_proxy.unwrap_or_default() {
|
if !restored.enable_system_proxy.unwrap_or_default() {
|
||||||
let result = async {
|
let result = async {
|
||||||
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
|
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
|
||||||
proxy_control::apply().await?;
|
proxy_control::apply().await?;
|
||||||
proxy_control::refresh_guard().await
|
proxy_control::refresh_guard().await
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
|
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
|
||||||
if SystemProxyStateUnknown::is_in(&err) {
|
if SystemProxyStateUnknown::is_in(&err) {
|
||||||
return Err(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.
|
// Run configuration side effects without rewriting the already-restored file.
|
||||||
if let Err(err) = super::patch_verge(&restored, true).await {
|
if let Err(err) = super::patch_verge(&restored, true).await {
|
||||||
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
|
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
|
||||||
@@ -89,6 +88,7 @@ async fn finalize_restored_verge_config(
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
Ok(())
|
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))]
|
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
|
||||||
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
|
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()
|
let backup_storage_path = app_home_dir()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
|
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
|
||||||
.join(filename.as_str());
|
.join(filename.as_str());
|
||||||
@@ -178,7 +158,12 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
|
|||||||
err
|
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;
|
let _ = backup_storage_path.remove_if_exists().await;
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
@@ -347,7 +332,22 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
|
|||||||
return Err(anyhow!("Backup file not found: {}", filename));
|
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))]
|
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
|
||||||
|
|||||||
+45
-11
@@ -3,7 +3,7 @@ use crate::core::notify::{Refresh, announce};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, MixedPort},
|
config::{Config, MixedPort},
|
||||||
core::{CoreManager, handle, tray},
|
core::{CoreManager, handle, tray},
|
||||||
feat::run_exit_cleanup_flow,
|
feat::clean_async,
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
utils,
|
utils,
|
||||||
};
|
};
|
||||||
@@ -14,8 +14,15 @@ use smartstring::alias::String;
|
|||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> =
|
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
|
||||||
LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions")));
|
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() {
|
pub async fn restart_clash_core() {
|
||||||
match CoreManager::global().restart_core().await {
|
match CoreManager::global().restart_core().await {
|
||||||
@@ -31,9 +38,38 @@ pub async fn restart_clash_core() {
|
|||||||
|
|
||||||
pub async fn restart_app() {
|
pub async fn restart_app() {
|
||||||
logging!(debug, Type::System, "启动重启应用流程");
|
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 {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +103,11 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let clash = Config::clash().await;
|
let clash = Config::clash().await;
|
||||||
let save_result = {
|
clash.edit_draft(|d| d.patch_config(&mapping));
|
||||||
let _config_write = Config::lock_config_write().await;
|
clash.apply();
|
||||||
clash.edit_draft(|d| d.patch_config(&mapping));
|
|
||||||
clash.apply();
|
let clash_data = clash.data_arc();
|
||||||
clash.data_arc().save_config().await
|
if clash_data.save_config().await.is_ok() {
|
||||||
};
|
|
||||||
if save_result.is_ok() {
|
|
||||||
announce(Refresh::Clash);
|
announce(Refresh::Clash);
|
||||||
tray::Tray::global().update_menu_and_icon().await;
|
tray::Tray::global().update_menu_and_icon().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::{
|
|||||||
utils::help::{mask_err, mask_url},
|
utils::help::{mask_err, mask_url},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
|
|
||||||
/// Toggle proxy profile
|
/// 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;
|
record_switched_node(group_name, proxy_name).await;
|
||||||
announce(Refresh::Proxies);
|
announce(Refresh::Proxies);
|
||||||
let _ = tray::Tray::global().update_menu().await;
|
let _ = tray::Tray::global().update_menu().await;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(error, Type::Tray, "切换代理失败: {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>)>> {
|
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 {
|
if should_refresh {
|
||||||
logging!(debug, Type::Config, "[订阅更新] 更新内核配置");
|
logging!(debug, Type::Config, "[订阅更新] 更新内核配置");
|
||||||
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
|
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
|
||||||
Ok(outcome) if outcome.is_valid() => 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 => {
|
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
|
||||||
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
|
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> {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ pub async fn open_or_close_dashboard() {
|
|||||||
logging!(info, Type::Window, "Window toggle result: {result:?}");
|
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();
|
handle::Handle::global().set_is_exiting();
|
||||||
|
|
||||||
Config::apply_all_and_save_file().await;
|
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(
|
handle::Handle::notice(
|
||||||
stop_failed_notice,
|
NoticeStatus::AppQuitCoreStopFailed,
|
||||||
cleanup_result.stop_error.clone().unwrap_or_default(),
|
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();
|
utils::server::shutdown_embedded_server();
|
||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
|
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
|
||||||
@@ -321,7 +316,6 @@ pub async fn hide() {
|
|||||||
{
|
{
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
handle::Handle::global().set_activation_policy_accessory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -289,9 +289,6 @@ pub fn run() -> std::process::ExitCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_window_close(api: &tauri::WindowEvent) {
|
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() {
|
if core::handle::Handle::global().is_exiting() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
|
|||||||
Value::String("*.arpa".into()),
|
Value::String("*.arpa".into()),
|
||||||
Value::String("time.*.com".into()),
|
Value::String("time.*.com".into()),
|
||||||
Value::String("ntp.*.com".into()),
|
Value::String("ntp.*.com".into()),
|
||||||
|
Value::String("time.*.com".into()),
|
||||||
Value::String("+.market.xiaomi.com".into()),
|
Value::String("+.market.xiaomi.com".into()),
|
||||||
Value::String("localhost.ptlogin2.qq.com".into()),
|
Value::String("localhost.ptlogin2.qq.com".into()),
|
||||||
Value::String("*.msftncsi.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 app_dir = dirs::app_home_dir()?;
|
||||||
let res_dir = dirs::app_resources_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() {
|
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"];
|
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
|
||||||
|
|||||||
@@ -48,17 +48,6 @@ enum TlsRootMode {
|
|||||||
StaticWebpkiRoots,
|
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;
|
pub struct NetworkManager;
|
||||||
|
|
||||||
impl NetworkManager {
|
impl NetworkManager {
|
||||||
@@ -113,7 +102,13 @@ impl NetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> {
|
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()];
|
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -160,23 +155,6 @@ impl NetworkManager {
|
|||||||
detail.contains("protocolversion") || detail.contains("protocol version")
|
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(
|
async fn get_with_tls_mode(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -286,21 +264,34 @@ impl NetworkManager {
|
|||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
accept_invalid_certs: bool,
|
accept_invalid_certs: bool,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
Self::request_with_static_webpki_fallback(!accept_invalid_certs, |tls_root_mode| {
|
let platform_result = self
|
||||||
let user_agent = user_agent.clone();
|
.get_with_tls_mode(
|
||||||
async move {
|
url,
|
||||||
self.get_with_tls_mode(
|
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,
|
url,
|
||||||
proxy_type,
|
proxy_type,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
user_agent,
|
user_agent,
|
||||||
accept_invalid_certs,
|
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")
|
||||||
.await
|
}),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_bytes_with_tls_mode(
|
async fn get_bytes_with_tls_mode(
|
||||||
@@ -349,10 +340,19 @@ impl NetworkManager {
|
|||||||
timeout_secs: Option<u64>,
|
timeout_secs: Option<u64>,
|
||||||
max_bytes: usize,
|
max_bytes: usize,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
Self::request_with_static_webpki_fallback(true, |tls_root_mode| async move {
|
let platform_result = self
|
||||||
self.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, tls_root_mode)
|
.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| {
|
||||||
.await
|
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
|
||||||
|
}),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::core::notify::NoticeStatus;
|
use crate::core::notify::NoticeStatus;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
core::{
|
core::{
|
||||||
@@ -95,6 +97,18 @@ async fn resolve_setup() {
|
|||||||
resolve_done();
|
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() {
|
fn init_scheme() {
|
||||||
logging_error!(Type::Setup, init::init_scheme());
|
logging_error!(Type::Setup, init::init_scheme());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.6.0",
|
"version": "2.5.9-rc",
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|||||||
@@ -21,27 +21,3 @@ These rules supplement the repository's root `AGENTS.md`.
|
|||||||
- **Command ownership.** `services` owns backend command invocation and transport
|
- **Command ownership.** `services` owns backend command invocation and transport
|
||||||
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
||||||
the same intent ownership as backend commands.
|
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.
|
|
||||||
|
|||||||
@@ -22,16 +22,23 @@ interface BaseSplitChipEditorProps {
|
|||||||
error?: boolean
|
error?: boolean
|
||||||
helperText?: ReactNode
|
helperText?: ReactNode
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
rows?: number
|
||||||
separator?: string
|
separator?: string
|
||||||
|
splitPattern?: RegExp
|
||||||
|
defaultMode?: BaseSplitChipEditorMode
|
||||||
|
showModeToggle?: boolean
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
|
addLabel?: ReactNode
|
||||||
|
emptyLabel?: ReactNode
|
||||||
|
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
|
||||||
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
||||||
|
|
||||||
const splitValue = (value: string) =>
|
const splitValue = (value: string, splitPattern: RegExp) =>
|
||||||
value
|
value
|
||||||
.split(DEFAULT_SPLIT_PATTERN)
|
.split(splitPattern)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
@@ -42,25 +49,35 @@ export const BaseSplitChipEditor = ({
|
|||||||
error = false,
|
error = false,
|
||||||
helperText,
|
helperText,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
rows = 4,
|
||||||
separator = ',',
|
separator = ',',
|
||||||
|
splitPattern = DEFAULT_SPLIT_PATTERN,
|
||||||
|
defaultMode = 'visual',
|
||||||
|
showModeToggle = true,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
addLabel,
|
||||||
|
emptyLabel,
|
||||||
|
modeLabels,
|
||||||
renderHeader,
|
renderHeader,
|
||||||
}: BaseSplitChipEditorProps) => {
|
}: BaseSplitChipEditorProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
|
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
const resolvedLabels = useMemo(
|
const resolvedLabels = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
visual: t('shared.editorModes.visualization'),
|
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
|
||||||
advanced: t('shared.editorModes.advanced'),
|
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
|
||||||
add: t('shared.actions.new'),
|
add: addLabel ?? t('shared.actions.new'),
|
||||||
empty: t('shared.statuses.empty'),
|
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 items = useMemo(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
@@ -75,7 +92,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
}, [values])
|
}, [values])
|
||||||
|
|
||||||
const handleAddDraft = () => {
|
const handleAddDraft = () => {
|
||||||
const nextValues = splitValue(draft)
|
const nextValues = splitValue(draft, splitPattern)
|
||||||
if (!nextValues.length) {
|
if (!nextValues.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,7 +113,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
const resolvedAriaLabel =
|
const resolvedAriaLabel =
|
||||||
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
||||||
|
|
||||||
const modeToggle = (
|
const modeToggle = showModeToggle ? (
|
||||||
<Tooltip title={toggleLabel}>
|
<Tooltip title={toggleLabel}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -111,7 +128,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
<ToggleIcon fontSize="small" />
|
<ToggleIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -185,7 +202,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size="small"
|
size="small"
|
||||||
multiline
|
multiline
|
||||||
rows={4}
|
rows={rows}
|
||||||
sx={{ width: '100%' }}
|
sx={{ width: '100%' }}
|
||||||
value={value}
|
value={value}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ export const CurrentProxyCard = () => {
|
|||||||
[getProfileStorageKey],
|
[getProfileStorageKey],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { select } = useProxySelection({
|
const { handleSelectChange } = useProxySelection({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refreshProxy()
|
refreshProxy()
|
||||||
},
|
},
|
||||||
@@ -766,9 +766,15 @@ export const CurrentProxyCard = () => {
|
|||||||
const previousProxy = selectedGroup.now
|
const previousProxy = selectedGroup.now
|
||||||
const nextName = option.member.ref.name
|
const nextName = option.member.ref.name
|
||||||
// The profile selection is the durable source across core restarts and run modes.
|
// 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(() => {
|
const goToProxies = useCallback(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
|
import { debugLog } from '@/utils/debug'
|
||||||
import parseTraffic from '@/utils/parse-traffic'
|
import parseTraffic from '@/utils/parse-traffic'
|
||||||
import {
|
import {
|
||||||
formatTrafficHourMinute,
|
formatTrafficHourMinute,
|
||||||
@@ -22,7 +23,14 @@ import {
|
|||||||
isSameTrafficData,
|
isSameTrafficData,
|
||||||
} from '@/utils/traffic-sampler'
|
} from '@/utils/traffic-sampler'
|
||||||
|
|
||||||
|
interface ITrafficItem {
|
||||||
|
up: number
|
||||||
|
down: number
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnhancedCanvasTrafficGraphRef {
|
export interface EnhancedCanvasTrafficGraphRef {
|
||||||
|
appendData: (data: ITrafficItem) => void
|
||||||
toggleStyle: () => void
|
toggleStyle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const debounceTimeoutRef = useRef<number | null>(null)
|
const debounceTimeoutRef = useRef<number | null>(null)
|
||||||
|
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
|
||||||
|
|
||||||
const colors = useMemo(
|
const colors = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -174,6 +183,8 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
lastDataTimestampRef.current = 0
|
lastDataTimestampRef.current = 0
|
||||||
dataStaleRef.current = false
|
dataStaleRef.current = false
|
||||||
|
// eslint-disable-next-line @eslint-react/set-state-in-effect
|
||||||
|
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +200,18 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
}
|
}
|
||||||
}, [displayData])
|
}, [displayData])
|
||||||
|
|
||||||
const handleFocusStateChange = useCallback((focused: boolean) => {
|
const handleFocusStateChange = useCallback(
|
||||||
isWindowFocusedRef.current = focused
|
(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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
@@ -666,13 +684,19 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(getX(0), getY(0))
|
ctx.moveTo(getX(0), getY(0))
|
||||||
|
|
||||||
for (let i = 1; i < data.length; i++) {
|
if (chartStyle === 'bezier') {
|
||||||
const currentX = getX(i)
|
for (let i = 1; i < data.length; i++) {
|
||||||
const currentY = getY(i)
|
const currentX = getX(i)
|
||||||
const nextIndex = Math.min(i + 1, lastIndex)
|
const currentY = getY(i)
|
||||||
const controlX = (currentX + getX(nextIndex)) / 2
|
const nextIndex = Math.min(i + 1, lastIndex)
|
||||||
const controlY = (currentY + getY(nextIndex)) / 2
|
const controlX = (currentX + getX(nextIndex)) / 2
|
||||||
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
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)
|
ctx.lineTo(getX(lastIndex), height - padding.bottom)
|
||||||
@@ -960,7 +984,21 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
|
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(() => {
|
const getTimeRangeText = useCallback(() => {
|
||||||
return t('home.components.traffic.patterns.minutes', {
|
return t('home.components.traffic.patterns.minutes', {
|
||||||
@@ -1104,7 +1142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
{t('home.components.traffic.diagnostics', {
|
{t('home.components.traffic.diagnostics', {
|
||||||
points: displayData.length,
|
points: displayData.length,
|
||||||
compressed: samplerStats.compressedBufferSize,
|
compressed: samplerStats.compressedBufferSize,
|
||||||
fps: GRAPH_CONFIG.targetFPS,
|
fps: currentFPS,
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import {
|
|||||||
ArrowUpwardRounded,
|
ArrowUpwardRounded,
|
||||||
CloudDownloadRounded,
|
CloudDownloadRounded,
|
||||||
CloudUploadRounded,
|
CloudUploadRounded,
|
||||||
ErrorOutlineRounded,
|
|
||||||
LinkRounded,
|
LinkRounded,
|
||||||
MemoryRounded,
|
MemoryRounded,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Grid,
|
Grid,
|
||||||
PaletteColor,
|
PaletteColor,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -19,9 +15,9 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { ReactNode, memo, useMemo, useRef } from 'react'
|
import { ReactNode, memo, useMemo, useRef } from 'react'
|
||||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
||||||
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
|
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
|
||||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||||
@@ -131,50 +127,6 @@ const CompactStatCard = memo(
|
|||||||
// 添加显示名称
|
// 添加显示名称
|
||||||
CompactStatCard.displayName = 'CompactStatCard'
|
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 = () => {
|
export const EnhancedTrafficStats = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -302,11 +254,10 @@ export const EnhancedTrafficStats = () => {
|
|||||||
}, [t, parsedData, displayMemory])
|
}, [t, parsedData, displayMemory])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<TrafficErrorBoundary
|
||||||
FallbackComponent={TrafficFallback}
|
onError={(error, errorInfo) => {
|
||||||
onError={(error, errorInfo) =>
|
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
|
||||||
console.error('[EnhancedTrafficStats]', error, errorInfo)
|
}}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
||||||
{trafficGraph && (
|
{trafficGraph && (
|
||||||
@@ -322,6 +273,6 @@ export const EnhancedTrafficStats = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</ErrorBoundary>
|
</TrafficErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,16 @@ export const SystemInfoCard = () => {
|
|||||||
}, [verge, patchVerge])
|
}, [verge, patchVerge])
|
||||||
|
|
||||||
const handleRunningModeClick = useCallback(async () => {
|
const handleRunningModeClick = useCallback(async () => {
|
||||||
if (isSidecarMode) {
|
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
|
||||||
await installServiceAndRestartCore()
|
await installServiceAndRestartCore()
|
||||||
await mutateSystemState()
|
await mutateSystemState()
|
||||||
}
|
}
|
||||||
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState])
|
}, [
|
||||||
|
isSidecarMode,
|
||||||
|
isAdminMode,
|
||||||
|
installServiceAndRestartCore,
|
||||||
|
mutateSystemState,
|
||||||
|
])
|
||||||
|
|
||||||
const onCheckUpdate = useLockFn(async () => {
|
const onCheckUpdate = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,16 +122,18 @@ export const SystemInfoCard = () => {
|
|||||||
|
|
||||||
const runningModeStyle = useMemo(
|
const runningModeStyle = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cursor: isSidecarMode ? 'pointer' : 'default',
|
cursor:
|
||||||
textDecoration: isSidecarMode ? 'underline' : 'none',
|
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
|
||||||
|
textDecoration:
|
||||||
|
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
opacity: isSidecarMode ? 0.7 : 1,
|
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[isSidecarMode],
|
[isSidecarMode, isAdminMode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getModeIcon = () => {
|
const getModeIcon = () => {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const TestCard = () => {
|
|||||||
x.uid === uid ? { ...x, ...patch } : x,
|
x.uid === uid ? { ...x, ...patch } : x,
|
||||||
)
|
)
|
||||||
|
|
||||||
mutateVerge({ ...verge, test_list: newList })
|
mutateVerge({ ...verge, test_list: newList }, false)
|
||||||
},
|
},
|
||||||
[testList, verge, mutateVerge],
|
[testList, verge, mutateVerge],
|
||||||
)
|
)
|
||||||
@@ -96,7 +96,7 @@ export const TestCard = () => {
|
|||||||
(uid: string) => {
|
(uid: string) => {
|
||||||
const newList = testList.filter((x) => x.uid !== uid)
|
const newList = testList.filter((x) => x.uid !== uid)
|
||||||
patchVerge({ test_list: newList })
|
patchVerge({ test_list: newList })
|
||||||
mutateVerge({ ...verge, test_list: newList })
|
mutateVerge({ ...verge, test_list: newList }, false)
|
||||||
},
|
},
|
||||||
[testList, verge, patchVerge, mutateVerge],
|
[testList, verge, patchVerge, mutateVerge],
|
||||||
)
|
)
|
||||||
@@ -129,7 +129,7 @@ export const TestCard = () => {
|
|||||||
newList.splice(newIndex, 0, removed)
|
newList.splice(newIndex, 0, removed)
|
||||||
|
|
||||||
// 优化:先本地更新,再异步 patch,避免UI卡死
|
// 优化:先本地更新,再异步 patch,避免UI卡死
|
||||||
mutateVerge({ ...verge, test_list: newList })
|
mutateVerge({ ...verge, test_list: newList }, false)
|
||||||
const patchFn = () => {
|
const patchFn = () => {
|
||||||
try {
|
try {
|
||||||
patchVerge({ test_list: newList })
|
patchVerge({ test_list: newList })
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
|
|||||||
|
|
||||||
const handleMenuOrderOptimisticUpdate = useCallback(
|
const handleMenuOrderOptimisticUpdate = useCallback(
|
||||||
(order: string[]) => {
|
(order: string[]) => {
|
||||||
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev))
|
mutateVerge(
|
||||||
|
(prev) => (prev ? { ...prev, menu_order: order } : prev),
|
||||||
|
false,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[mutateVerge],
|
[mutateVerge],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ArrowDownwardRounded,
|
ArrowDownwardRounded,
|
||||||
ArrowUpwardRounded,
|
ArrowUpwardRounded,
|
||||||
ErrorOutlineRounded,
|
|
||||||
MemoryRounded,
|
MemoryRounded,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
|
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { ErrorBoundary } from 'react-error-boundary'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
||||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
@@ -18,30 +17,6 @@ import parseTraffic from '@/utils/parse-traffic'
|
|||||||
|
|
||||||
import { TrafficGraph, type TrafficRef } from './traffic-graph'
|
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 = () => {
|
export const LayoutTraffic = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { verge } = useVerge()
|
const { verge } = useVerge()
|
||||||
@@ -100,12 +75,7 @@ export const LayoutTraffic = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<LightweightTrafficErrorBoundary>
|
||||||
FallbackComponent={TrafficUnavailableFallback}
|
|
||||||
onError={(error, errorInfo) =>
|
|
||||||
console.error('[LayoutTraffic]', error, errorInfo)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
{trafficGraph && pageVisible && (
|
{trafficGraph && pageVisible && (
|
||||||
<div
|
<div
|
||||||
@@ -160,6 +130,7 @@ export const LayoutTraffic = () => {
|
|||||||
cursor: 'auto',
|
cursor: 'auto',
|
||||||
}}
|
}}
|
||||||
color={'disabled'}
|
color={'disabled'}
|
||||||
|
onClick={async () => {}}
|
||||||
>
|
>
|
||||||
<MemoryRounded {...iconStyle} />
|
<MemoryRounded {...iconStyle} />
|
||||||
<Typography {...valStyle}>{inuse}</Typography>
|
<Typography {...valStyle}>{inuse}</Typography>
|
||||||
@@ -168,6 +139,6 @@ export const LayoutTraffic = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</ErrorBoundary>
|
</LightweightTrafficErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
|
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
|
||||||
import { Box, IconButton } from '@mui/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 { useWindowControls } from '@/hooks/use-window'
|
||||||
import getSystem from '@/utils/get-system'
|
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 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 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
||||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
||||||
@@ -154,4 +186,4 @@ export const WindowControls = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
|||||||
}
|
}
|
||||||
}, [handleScroll])
|
}, [handleScroll])
|
||||||
|
|
||||||
const { select } = useProxySelection({
|
const { handleProxyGroupChange } = useProxySelection({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onProxies()
|
onProxies()
|
||||||
},
|
},
|
||||||
@@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
|
|||||||
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
||||||
if (!isInteractableMember(member)) 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) => {
|
const handleLocation = useStableCallback((group: ProxyGroupView) => {
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ export const useProxyGroupHeaderLayout = () => {
|
|||||||
if (canceled || !operation.source || !operation.target) return
|
if (canceled || !operation.source || !operation.target) return
|
||||||
|
|
||||||
const position = toolsOnLeft ? 'right' : 'left'
|
const position = toolsOnLeft ? 'right' : 'left'
|
||||||
mutateVerge((prev) =>
|
mutateVerge(
|
||||||
prev ? { ...prev, proxy_group_tools_position: position } : prev,
|
(prev) =>
|
||||||
|
prev ? { ...prev, proxy_group_tools_position: position } : prev,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
await patchVerge({ proxy_group_tools_position: position })
|
await patchVerge({ proxy_group_tools_position: position })
|
||||||
|
|||||||
@@ -206,20 +206,17 @@ export const useRenderList = (
|
|||||||
verge?.default_latency_timeout,
|
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.
|
// Every group this list draws, so a test settling in any of them re-sorts that group.
|
||||||
const renderedGroupNames = useMemo(() => {
|
const renderedGroupNames = useMemo(() => {
|
||||||
if (!proxyView) return []
|
if (!proxyView) return []
|
||||||
if (isChainMode)
|
if (isChainMode)
|
||||||
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
|
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
|
||||||
return renderGroups.map(({ name }) => name)
|
return mode === 'rule' || mode === 'global'
|
||||||
}, [isChainMode, proxyView, renderGroups, selectedGroup])
|
? proxyView.groups.map(({ name }) => name)
|
||||||
|
: proxyView.global
|
||||||
|
? [proxyView.global.name]
|
||||||
|
: []
|
||||||
|
}, [isChainMode, mode, proxyView, selectedGroup])
|
||||||
const groupDelays = useGroupsDelays(renderedGroupNames)
|
const groupDelays = useGroupsDelays(renderedGroupNames)
|
||||||
|
|
||||||
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
|
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
|
||||||
@@ -261,7 +258,13 @@ export const useRenderList = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useRule = mode === 'rule' || mode === 'global'
|
const useRule = mode === 'rule' || mode === 'global'
|
||||||
|
const renderGroups = useRule
|
||||||
|
? proxyView.groups
|
||||||
|
: proxyView.global === null
|
||||||
|
? []
|
||||||
|
: [proxyView.global]
|
||||||
const cache = groupCacheRef.current
|
const cache = groupCacheRef.current
|
||||||
|
let anyChanged = false
|
||||||
|
|
||||||
const retList = renderGroups.flatMap((group) => {
|
const retList = renderGroups.flatMap((group) => {
|
||||||
const headState = headStates[group.name] || DEFAULT_STATE
|
const headState = headStates[group.name] || DEFAULT_STATE
|
||||||
@@ -278,6 +281,7 @@ export const useRenderList = (
|
|||||||
return cached.items
|
return cached.items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
anyChanged = true
|
||||||
const ret: IRenderItem[] = [
|
const ret: IRenderItem[] = [
|
||||||
{
|
{
|
||||||
type: 0,
|
type: 0,
|
||||||
@@ -346,10 +350,7 @@ export const useRenderList = (
|
|||||||
const filtered = !useRule
|
const filtered = !useRule
|
||||||
? retList.slice(1)
|
? retList.slice(1)
|
||||||
: retList.filter((item) => !item.group.hidden)
|
: retList.filter((item) => !item.group.hidden)
|
||||||
if (
|
if (!anyChanged && prevListRef.current.length === filtered.length) {
|
||||||
prevListRef.current.length === filtered.length &&
|
|
||||||
filtered.every((item, index) => item === prevListRef.current[index])
|
|
||||||
) {
|
|
||||||
return prevListRef.current
|
return prevListRef.current
|
||||||
}
|
}
|
||||||
prevListRef.current = filtered
|
prevListRef.current = filtered
|
||||||
@@ -363,7 +364,6 @@ export const useRenderList = (
|
|||||||
latencyTimeout,
|
latencyTimeout,
|
||||||
mode,
|
mode,
|
||||||
proxyView,
|
proxyView,
|
||||||
renderGroups,
|
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -114,15 +114,17 @@ export const BackupConfigViewer = memo(
|
|||||||
errorNotice: false,
|
errorNotice: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await mutateVerge((current) =>
|
await mutateVerge(
|
||||||
current
|
(current) =>
|
||||||
? {
|
current
|
||||||
...current,
|
? {
|
||||||
webdav_url: trimmedUrl,
|
...current,
|
||||||
webdav_username: trimmedUsername,
|
webdav_url: trimmedUrl,
|
||||||
webdav_password: data.password,
|
webdav_username: trimmedUsername,
|
||||||
}
|
webdav_password: data.password,
|
||||||
: current,
|
}
|
||||||
|
: current,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
setWebdavStatus(signature, 'unknown')
|
setWebdavStatus(signature, 'unknown')
|
||||||
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
|
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
|||||||
showNotice.error(err)
|
showNotice.error(err)
|
||||||
}
|
}
|
||||||
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
||||||
mutateVerge({ ...verge, ...patch })
|
mutateVerge({ ...verge, ...patch }, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,33 +15,44 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
|
|||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
import { showNotice } from '@/services/notice-service'
|
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) => {
|
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { verge, patchVerge } = useVerge()
|
const { verge, patchVerge } = useVerge()
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setOpen(true)
|
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),
|
close: () => setOpen(false),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -87,17 +87,6 @@ const splitBypass = (value?: string) =>
|
|||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.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) => {
|
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const systemName = getSystem()
|
const systemName = getSystem()
|
||||||
@@ -127,7 +116,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
proxy_host,
|
proxy_host,
|
||||||
} = verge ?? {}
|
} = 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>(
|
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
@@ -194,7 +192,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setOpen(true)
|
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()
|
fetchNetworkInterfaces()
|
||||||
},
|
},
|
||||||
close: () => setOpen(false),
|
close: () => setOpen(false),
|
||||||
@@ -332,7 +339,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
Promise.resolve().then(async () => {
|
Promise.resolve().then(async () => {
|
||||||
try {
|
try {
|
||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
mutateVerge({ ...verge, ...patch })
|
mutateVerge({ ...verge, ...patch }, false)
|
||||||
}
|
}
|
||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
await patchVerge(patch)
|
await patchVerge(patch)
|
||||||
|
|||||||
@@ -76,28 +76,34 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
|
|||||||
member.kind === 'node' ? member.node.recordId : member.ref.name
|
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) => {
|
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { clash, mutateClash, patchClash } = useClash()
|
const { clash, mutateClash, patchClash } = useClash()
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [expanded, setExpanded] = 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[]>([])
|
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setValues(buildInitialValues())
|
setValues(() => ({
|
||||||
|
localAddr: '',
|
||||||
|
localPort: '',
|
||||||
|
targetAddr: '',
|
||||||
|
targetPort: '',
|
||||||
|
network: 'tcp+udp',
|
||||||
|
group: '',
|
||||||
|
proxy: null,
|
||||||
|
}))
|
||||||
setDraftTunnels(() => clash?.tunnels ?? [])
|
setDraftTunnels(() => clash?.tunnels ?? [])
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
// 如果没有隧道,则自动展开
|
// 如果没有隧道,则自动展开
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { alpha, Box, Button, LinearProgress } from '@mui/material'
|
import { alpha, Box, Button, LinearProgress } from '@mui/material'
|
||||||
import type { Update } from '@tauri-apps/plugin-updater'
|
|
||||||
import { useLockFn } from 'ahooks'
|
import { useLockFn } from 'ahooks'
|
||||||
import type { Ref } from 'react'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import type { Options as ReactMarkdownOptions } from 'react-markdown'
|
import type { Options as ReactMarkdownOptions } from 'react-markdown'
|
||||||
|
|
||||||
@@ -174,16 +180,12 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
|||||||
const updateState = useUpdateState()
|
const updateState = useUpdateState()
|
||||||
const setUpdateState = useSetUpdateState()
|
const setUpdateState = useSetUpdateState()
|
||||||
|
|
||||||
const { updateInfo: detectedUpdate, checkUpdate } = useUpdate()
|
const { updateInfo } = useUpdate()
|
||||||
const [refreshedUpdate, setRefreshedUpdate] = useState<Update | null>()
|
|
||||||
const updateInfo =
|
|
||||||
refreshedUpdate === undefined ? detectedUpdate : refreshedUpdate
|
|
||||||
|
|
||||||
const [download, setDownload] = useState({
|
const [downloaded, setDownloaded] = useState(0)
|
||||||
downloaded: 0,
|
const [total, setTotal] = useState(0)
|
||||||
total: 0,
|
const downloadedRef = useRef(0)
|
||||||
})
|
const totalRef = useRef(0)
|
||||||
const { downloaded, total } = download
|
|
||||||
|
|
||||||
const openUrlWithNotice = (url: string) => {
|
const openUrlWithNotice = (url: string) => {
|
||||||
void openExternalUrl(url).catch(showNotice.error)
|
void openExternalUrl(url).catch(showNotice.error)
|
||||||
@@ -195,10 +197,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
|||||||
}, [downloaded, total])
|
}, [downloaded, total])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => setOpen(true),
|
||||||
setRefreshedUpdate(undefined)
|
|
||||||
setOpen(true)
|
|
||||||
},
|
|
||||||
close: () => setOpen(false),
|
close: () => setOpen(false),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -243,21 +242,32 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
|||||||
}
|
}
|
||||||
if (updateState) return
|
if (updateState) return
|
||||||
setUpdateState(true)
|
setUpdateState(true)
|
||||||
setDownload({ downloaded: 0, total: 0 })
|
setDownloaded(0)
|
||||||
|
setTotal(0)
|
||||||
|
downloadedRef.current = 0
|
||||||
|
totalRef.current = 0
|
||||||
|
|
||||||
const onDownloadEvent = (event: DownloadEvent) => {
|
const onDownloadEvent = (event: DownloadEvent) => {
|
||||||
if (event.event === 'Started') {
|
if (event.event === 'Started') {
|
||||||
setDownload({ downloaded: 0, total: event.data.contentLength ?? 0 })
|
const contentLength = event.data.contentLength ?? 0
|
||||||
} else if (event.event === 'Progress') {
|
totalRef.current = contentLength
|
||||||
setDownload((prev) => ({
|
setTotal(contentLength)
|
||||||
...prev,
|
setDownloaded(0)
|
||||||
downloaded: prev.downloaded + event.data.chunkLength,
|
downloadedRef.current = 0
|
||||||
}))
|
return
|
||||||
} else if (event.event === 'Finished') {
|
}
|
||||||
setDownload((prev) => ({
|
|
||||||
...prev,
|
if (event.event === 'Progress') {
|
||||||
total: prev.total || prev.downloaded,
|
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,
|
errorNotice: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (result.ok && result.value === 'update_changed') {
|
if (result.ok && result.value) {
|
||||||
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') {
|
|
||||||
await mutate(() => restartApp(), {
|
await mutate(() => restartApp(), {
|
||||||
id: 'restart-app',
|
id: 'restart-app',
|
||||||
errorNotice: false,
|
errorNotice: false,
|
||||||
@@ -290,7 +289,10 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
|||||||
showNotice.error(err)
|
showNotice.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateState(false)
|
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 handleAdd = useLockFn(async (value: string) => {
|
||||||
const newList = [...webUIList, value]
|
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 })
|
await patchVerge({ web_ui_list: newList })
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleChange = useLockFn(async (index: number, value?: string) => {
|
const handleChange = useLockFn(async (index: number, value?: string) => {
|
||||||
const newList = [...webUIList]
|
const newList = [...webUIList]
|
||||||
newList[index] = value ?? ''
|
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 })
|
await patchVerge({ web_ui_list: newList })
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = useLockFn(async (index: number) => {
|
const handleDelete = useLockFn(async (index: number) => {
|
||||||
const newList = [...webUIList]
|
const newList = [...webUIList]
|
||||||
newList.splice(index, 1)
|
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 })
|
await patchVerge({ web_ui_list: newList })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react'
|
import React, { useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 ProxyControlSwitches from '@/components/shared/proxy-control-switches'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
|
|
||||||
import { GuardState } from './mods/guard-state'
|
import { GuardState } from './mods/guard-state'
|
||||||
import { SettingList, SettingItem } from './mods/setting-comp'
|
import { SettingList, SettingItem } from './mods/setting-comp'
|
||||||
|
import { SysproxyViewer } from './mods/sysproxy-viewer'
|
||||||
|
import { TunViewer } from './mods/tun-viewer'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onError?: (err: Error) => void
|
onError?: (err: Error) => void
|
||||||
@@ -19,16 +21,22 @@ const SettingSystem = ({ onError }: Props) => {
|
|||||||
|
|
||||||
const { enable_auto_launch, enable_silent_start } = verge ?? {}
|
const { enable_auto_launch, enable_silent_start } = verge ?? {}
|
||||||
|
|
||||||
|
const sysproxyRef = useRef<DialogRef>(null)
|
||||||
|
const tunRef = useRef<DialogRef>(null)
|
||||||
|
|
||||||
const onSwitchFormat = (
|
const onSwitchFormat = (
|
||||||
_e: React.ChangeEvent<HTMLInputElement>,
|
_e: React.ChangeEvent<HTMLInputElement>,
|
||||||
value: boolean,
|
value: boolean,
|
||||||
) => value
|
) => value
|
||||||
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
||||||
mutateVerge({ ...verge, ...patch })
|
mutateVerge({ ...verge, ...patch }, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingList title={t('settings.sections.system.title')}>
|
<SettingList title={t('settings.sections.system.title')}>
|
||||||
|
<SysproxyViewer ref={sysproxyRef} />
|
||||||
|
<TunViewer ref={tunRef} />
|
||||||
|
|
||||||
<ProxyControlSwitches
|
<ProxyControlSwitches
|
||||||
label={t('settings.sections.system.toggles.tunMode')}
|
label={t('settings.sections.system.toggles.tunMode')}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
@@ -50,6 +58,8 @@ const SettingSystem = ({ onError }: Props) => {
|
|||||||
}}
|
}}
|
||||||
onGuard={async (e) => {
|
onGuard={async (e) => {
|
||||||
try {
|
try {
|
||||||
|
// 先触发UI更新立即看到反馈
|
||||||
|
onChangeData({ enable_auto_launch: e })
|
||||||
await patchVerge({ enable_auto_launch: e })
|
await patchVerge({ enable_auto_launch: e })
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
|
|||||||
const backupRef = useRef<DialogRef>(null)
|
const backupRef = useRef<DialogRef>(null)
|
||||||
|
|
||||||
const onChangeData = (patch: any) => {
|
const onChangeData = (patch: any) => {
|
||||||
mutateVerge({ ...verge, ...patch })
|
mutateVerge({ ...verge, ...patch }, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCopyClashEnv = useCallback(async () => {
|
const onCopyClashEnv = useCallback(async () => {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const ProxyControlSwitches = ({
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
mutateVerge({ ...verge, enable_tun_mode: value })
|
mutateVerge({ ...verge, enable_tun_mode: value }, false)
|
||||||
await patchVerge({ enable_tun_mode: value })
|
await patchVerge({ enable_tun_mode: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +229,8 @@ const ProxyControlSwitches = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />}
|
<SysproxyViewer ref={sysproxyRef} />
|
||||||
{isTunMode && <TunViewer ref={tunRef} />}
|
<TunViewer ref={tunRef} />
|
||||||
</Box>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { downloadIconCache } from '@/services/cmds'
|
import { downloadIconCache } from '@/services/cmds'
|
||||||
import { useQuery } from '@/services/query-client'
|
import { useQuery } from '@/services/query-client'
|
||||||
@@ -22,8 +23,9 @@ export const useIconCache = ({
|
|||||||
const iconValue = icon?.trim() ?? ''
|
const iconValue = icon?.trim() ?? ''
|
||||||
const cacheKeyValue = cacheKey?.trim() ?? ''
|
const cacheKeyValue = cacheKey?.trim() ?? ''
|
||||||
|
|
||||||
const isEnabled =
|
const isEnabled = useMemo(() => {
|
||||||
enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
|
return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
|
||||||
|
}, [enabled, iconValue, cacheKeyValue])
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['icon-cache', iconValue, cacheKeyValue],
|
queryKey: ['icon-cache', iconValue, cacheKeyValue],
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export const useLogData = () => {
|
|||||||
const hasLoadedInitialLogsRef = useRef(false)
|
const hasLoadedInitialLogsRef = useRef(false)
|
||||||
|
|
||||||
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
|
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
|
||||||
subscriptionKey: enableLog ? 'getClashLog' : null,
|
storageKey: 'mihomo_logs_date',
|
||||||
|
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
|
||||||
fallbackData: [],
|
fallbackData: [],
|
||||||
connect: () => MihomoWebSocket.connect_logs(logLevel),
|
connect: () => MihomoWebSocket.connect_logs(logLevel),
|
||||||
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
|
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
|
||||||
@@ -76,13 +77,14 @@ export const useLogData = () => {
|
|||||||
}
|
}
|
||||||
const pendingLogs = buffer.splice(0, buffer.length)
|
const pendingLogs = buffer.splice(0, buffer.length)
|
||||||
flushTimeStr = null
|
flushTimeStr = null
|
||||||
next((current) => appendLogs(current, pendingLogs))
|
next(null, (current) => appendLogs(current, pendingLogs))
|
||||||
flushTimer = null
|
flushTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleMessage: (data) => {
|
handleMessage: (data) => {
|
||||||
if (data.startsWith('Websocket error')) {
|
if (data.startsWith('Websocket error')) {
|
||||||
|
next(data)
|
||||||
void scheduleReconnect()
|
void scheduleReconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,9 @@ export const useLogData = () => {
|
|||||||
if (!flushTimer) {
|
if (!flushTimer) {
|
||||||
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
|
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async onConnected() {
|
async onConnected() {
|
||||||
if (hasLoadedInitialLogsRef.current) {
|
if (hasLoadedInitialLogsRef.current) {
|
||||||
@@ -115,7 +119,7 @@ export const useLogData = () => {
|
|||||||
const logs = await getClashLogs()
|
const logs = await getClashLogs()
|
||||||
hasLoadedInitialLogsRef.current = true
|
hasLoadedInitialLogsRef.current = true
|
||||||
if (isMounted()) {
|
if (isMounted()) {
|
||||||
next((current) => {
|
next(null, (current) => {
|
||||||
if (!current || current.length === 0) {
|
if (!current || current.length === 0) {
|
||||||
return clampLogs(filterLogsByLevel(logs, allowedTypes))
|
return clampLogs(filterLogsByLevel(logs, allowedTypes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
|
|||||||
const enabled = options?.enabled ?? true
|
const enabled = options?.enabled ?? true
|
||||||
|
|
||||||
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
|
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
|
||||||
subscriptionKey: enabled ? 'getClashMemory' : null,
|
storageKey: 'mihomo_memory_date',
|
||||||
|
buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null),
|
||||||
fallbackData: FALLBACK_MEMORY_USAGE,
|
fallbackData: FALLBACK_MEMORY_USAGE,
|
||||||
connect: () => MihomoWebSocket.connect_memory(),
|
connect: () => MihomoWebSocket.connect_memory(),
|
||||||
throttleMs: 500,
|
throttleMs: 500,
|
||||||
setupHandlers: ({ next, scheduleReconnect }) => ({
|
setupHandlers: ({ next, scheduleReconnect }) => ({
|
||||||
handleMessage: (data) => {
|
handleMessage: (data) => {
|
||||||
if (data.startsWith('Websocket error')) {
|
if (data.startsWith('Websocket error')) {
|
||||||
|
next(data, FALLBACK_MEMORY_USAGE)
|
||||||
void scheduleReconnect()
|
void scheduleReconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -49,8 +51,10 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
|
|||||||
if (shouldSkipDuplicateMemory(parsed)) {
|
if (shouldSkipDuplicateMemory(parsed)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(parsed)
|
next(null, parsed)
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
next(error, FALLBACK_MEMORY_USAGE)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { 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
|
const RECONNECT_DELAY_MS = 1000
|
||||||
|
|
||||||
@@ -26,6 +31,7 @@ interface SharedSubscriptionEntry {
|
|||||||
|
|
||||||
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
|
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
|
||||||
const subscriptionSnapshots = new Map<string, unknown>()
|
const subscriptionSnapshots = new Map<string, unknown>()
|
||||||
|
const initialSubscriptionDate = Date.now()
|
||||||
|
|
||||||
const getSubscriptionSnapshot = <T>(key: string) =>
|
const getSubscriptionSnapshot = <T>(key: string) =>
|
||||||
subscriptionSnapshots.get(key) as T | undefined
|
subscriptionSnapshots.get(key) as T | undefined
|
||||||
@@ -130,13 +136,15 @@ const createSharedSubscriptionEntry = (
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
type Updater<T> = (current?: T) => T
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pass either a plain value or a functional updater; the updater is resolved
|
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
|
||||||
* against the current subscription snapshot before the cache is written.
|
* 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> {
|
interface HandlerContext<T> {
|
||||||
next: NextFn<T>
|
next: NextFn<T>
|
||||||
@@ -151,7 +159,8 @@ interface HandlerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UseMihomoWsSubscriptionOptions<T> {
|
interface UseMihomoWsSubscriptionOptions<T> {
|
||||||
subscriptionKey: string | null
|
storageKey: string
|
||||||
|
buildSubscriptKey: (date: number) => string | null
|
||||||
fallbackData: T
|
fallbackData: T
|
||||||
connect: () => Promise<MihomoWebSocket>
|
connect: () => Promise<MihomoWebSocket>
|
||||||
/**
|
/**
|
||||||
@@ -169,12 +178,18 @@ interface UseMihomoWsSubscriptionOptions<T> {
|
|||||||
export const useMihomoWsSubscription = <T>(
|
export const useMihomoWsSubscription = <T>(
|
||||||
options: UseMihomoWsSubscriptionOptions<T>,
|
options: UseMihomoWsSubscriptionOptions<T>,
|
||||||
) => {
|
) => {
|
||||||
const { subscriptionKey, fallbackData, connect, throttleMs, setupHandlers } =
|
const {
|
||||||
options
|
storageKey,
|
||||||
|
buildSubscriptKey,
|
||||||
|
fallbackData,
|
||||||
|
connect,
|
||||||
|
throttleMs,
|
||||||
|
setupHandlers,
|
||||||
|
} = options
|
||||||
|
|
||||||
const subscriptionCacheKey = subscriptionKey
|
const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate)
|
||||||
? `$sub$${subscriptionKey}`
|
const subscriptKey = buildSubscriptKey(date)
|
||||||
: null
|
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
|
||||||
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
|
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
|
||||||
if (subscriptionCacheKey) {
|
if (subscriptionCacheKey) {
|
||||||
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
|
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
|
||||||
@@ -182,9 +197,20 @@ export const useMihomoWsSubscription = <T>(
|
|||||||
const responseCacheKey =
|
const responseCacheKey =
|
||||||
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
|
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
|
||||||
|
|
||||||
// Bumping the epoch re-runs the subscription effect, rebuilding the shared
|
const resolveNextData = useCallback(
|
||||||
// entry (and its captured `connect`/handler closures) under the same key.
|
(
|
||||||
const [refreshEpoch, setRefreshEpoch] = useState(0)
|
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>({
|
const response = useQuery<T>({
|
||||||
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
|
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
|
||||||
@@ -212,35 +238,44 @@ export const useMihomoWsSubscription = <T>(
|
|||||||
let throttleCleanup: (() => void) | undefined
|
let throttleCleanup: (() => void) | undefined
|
||||||
let wrappedNext: NextFn<T>
|
let wrappedNext: NextFn<T>
|
||||||
|
|
||||||
const baseNext: NextFn<T> = (data) => {
|
const baseNext: NextFn<T> = (error, data) => {
|
||||||
const resolved =
|
if (error !== undefined && error !== null) {
|
||||||
typeof data === 'function'
|
return
|
||||||
? (data as Updater<T>)(
|
}
|
||||||
getSubscriptionSnapshot<T>(subscriptionCacheKey),
|
if (data === undefined) return
|
||||||
)
|
const resolved = resolveNextData(data, subscriptionCacheKey)
|
||||||
: (data as T)
|
|
||||||
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
|
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throttleMs && throttleMs > 0) {
|
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
|
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
timerId = null
|
timerId = null
|
||||||
if (pendingData !== undefined) {
|
if (hasPending) {
|
||||||
const data = pendingData
|
const data = pendingData
|
||||||
pendingData = undefined
|
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) {
|
if (!timerId) {
|
||||||
baseNext(data)
|
baseNext(undefined, data)
|
||||||
timerId = setTimeout(flush, throttleMs)
|
timerId = setTimeout(flush, throttleMs)
|
||||||
} else {
|
} else {
|
||||||
pendingData = data
|
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-compiler/react-compiler
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
|
||||||
}, [subscriptionCacheKey, refreshEpoch])
|
}, [subscriptionCacheKey])
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
if (subscriptionCacheKey) {
|
if (subscriptionCacheKey) {
|
||||||
writeSubscriptionSnapshot(subscriptionCacheKey, fallbackData)
|
subscriptionSnapshots.delete(subscriptionCacheKey)
|
||||||
|
void removeCacheData([subscriptionCacheKey])
|
||||||
}
|
}
|
||||||
setRefreshEpoch((epoch) => epoch + 1)
|
setDate(Date.now())
|
||||||
}, [subscriptionCacheKey, fallbackData])
|
}, [subscriptionCacheKey, setDate])
|
||||||
|
|
||||||
const setData = useCallback(
|
const setData = useCallback(
|
||||||
(data: T) => {
|
(data: T) => {
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ it('applies and records independent group selections while another group is pend
|
|||||||
const secondSuccess = vi.fn()
|
const secondSuccess = vi.fn()
|
||||||
const first = useProxySelection({ onSuccess: firstSuccess })
|
const first = useProxySelection({ onSuccess: firstSuccess })
|
||||||
const second = useProxySelection({ onSuccess: secondSuccess })
|
const second = useProxySelection({ onSuccess: secondSuccess })
|
||||||
first.select('Group A', 'Node A')
|
first.changeProxy('Group A', 'Node A')
|
||||||
second.select('Group B', 'Node B')
|
second.changeProxy('Group B', 'Node B')
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
|
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const cleanupConnections = async (previousProxy: string) => {
|
|||||||
interface ProxySelectionOptions {
|
interface ProxySelectionOptions {
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
onError?: (error: any) => void
|
onError?: (error: any) => void
|
||||||
|
enableConnectionCleanup?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxyChangeRequest {
|
interface ProxyChangeRequest {
|
||||||
@@ -52,7 +53,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
|
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
|
||||||
const isProcessingRef = useRef(false)
|
const isProcessingRef = useRef(false)
|
||||||
|
|
||||||
const { onSuccess, onError } = options
|
const { onSuccess, onError, enableConnectionCleanup = true } = options
|
||||||
|
|
||||||
const autoCloseConnection = verge?.auto_close_connection ?? false
|
const autoCloseConnection = verge?.auto_close_connection ?? false
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoCloseConnection && previousProxy) {
|
if (enableConnectionCleanup && autoCloseConnection && previousProxy) {
|
||||||
void cleanupConnections(previousProxy)
|
void cleanupConnections(previousProxy)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -111,6 +112,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
[
|
[
|
||||||
autoCloseConnection,
|
autoCloseConnection,
|
||||||
forgetSelection,
|
forgetSelection,
|
||||||
|
enableConnectionCleanup,
|
||||||
onError,
|
onError,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
recordSelection,
|
recordSelection,
|
||||||
@@ -136,7 +138,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
}
|
}
|
||||||
}, [executeChange])
|
}, [executeChange])
|
||||||
|
|
||||||
const select = useCallback(
|
const changeProxy = useCallback(
|
||||||
(
|
(
|
||||||
groupName: string,
|
groupName: string,
|
||||||
proxyName: string,
|
proxyName: string,
|
||||||
@@ -154,5 +156,27 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
[flushChangeQueue],
|
[flushChangeQueue],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { select }
|
const handleSelectChange = useCallback(
|
||||||
|
(groupName: string, previousProxy?: string, fixed?: string) =>
|
||||||
|
(event: { target: { value: string } }) => {
|
||||||
|
changeProxy(groupName, event.target.value, previousProxy, fixed)
|
||||||
|
},
|
||||||
|
[changeProxy],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleProxyGroupChange = useCallback(
|
||||||
|
(
|
||||||
|
group: { name: string; now?: string | null; fixed?: string | null },
|
||||||
|
proxy: { name: string },
|
||||||
|
) => {
|
||||||
|
changeProxy(group.name, proxy.name, group.now!, group.fixed!)
|
||||||
|
},
|
||||||
|
[changeProxy],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeProxy,
|
||||||
|
handleSelectChange,
|
||||||
|
handleProxyGroupChange,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user