Compare commits

...

61 Commits

Author SHA1 Message Date
Tunglies
c57a962109 refactor: replace useSWR with custom hooks for update and network interfaces (#6195) 2026-01-27 12:52:20 +00:00
Tunglies
36926df26c refactor: remove SWR_REALTIME configuration and simplify SWR usage in AppDataProvider 2026-01-27 20:07:48 +08:00
renovate[bot]
9d81a13c58 chore(deps): update dependency @actions/github to v8 (#6184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 01:51:06 +00:00
Tunglies
511fab9a9d Revert "perf: improve config processing (#6091)"
This reverts commit bf189bb144.
2026-01-26 23:36:57 +08:00
Tunglies
88529af8c8 fix(Linux): use PKEXEC_UID #6159 (#6160)
* fix(Linux): add GID environment variable for Linux service installation #6159

* chore: bump clash_verge_service_ipc to 2.1.2

* chore: remove CLASH_VERGE_SERVICE_GID for linux

---------

Co-authored-by: Sline <realakayuki@gmail.com>
2026-01-26 12:41:45 +00:00
renovate[bot]
425096e8af chore(deps): lock file maintenance cargo dependencies (#6167)
* chore(deps): lock file maintenance cargo dependencies

* chore: run cargo upgrade and cargo update

* chore: fix clippy

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-26 07:45:36 +00:00
renovate[bot]
8a4e2327c1 chore(deps): lock file maintenance npm dependencies (#6168)
* chore(deps): lock file maintenance npm dependencies

* chore: pnpm update

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-26 07:33:53 +00:00
Tunglies
74b1687be9 feat: implement git-hook using cargo make and add Makefile.toml (#5498)
* feat: implement pre-push checks using cargo make and add Makefile.toml for task management

* feat: enhance Makefile.toml with condition checks for tasks and improve clippy args

* fix: update file patterns for format-check task in Makefile.toml

* feat: update file patterns for eslint and typecheck tasks in Makefile.toml

* feat: refactor Makefile.toml to consolidate Rust tasks and update pre-commit checks

* feat: update Makefile.toml to add i18n-check and lint-staged tasks; modify pre-commit script

* feat: update Makefile.toml to add i18n-check and lint-staged tasks; modify pre-commit script

* refactor: simplify Makefile.toml by removing unused conditions and consolidating dependencies

* feat: update Makefile.toml to define Rust and frontend tasks for pre-commit and pre-push checks

* chore: remove unnecessary tasks

* chore: add windows override

* chore: remove format and format-check

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-26 07:21:02 +00:00
Tunglies
6477dd61c3 perf: reduce various timeout and retry intervals for improved responsiveness to fetch proxy infomation (#6072) 2026-01-25 07:31:34 +00:00
Tunglies
6ded9bdcde doc: changelog 2026-01-25 15:40:58 +08:00
Tunglies
13dc3feb9f perf: migrate fs method to async (#6071)
* perf(profiles): migrate file handling to async and improve error handling

* refactor(profiles): simplify cleanup_orphaned_files and adjust CleanupResult structure
2026-01-25 07:20:12 +00:00
Tunglies
c7462716e5 refactor: reduce duplicated separately useSWR (#6153)
* refactor: reduce duplicated seperatlly useSWR

* refactor: streamline useSWR integration and improve error handling
2026-01-25 07:14:45 +00:00
Tunglies
bf189bb144 perf: improve config processing (#6091)
* perf: improve config processing

* perf: enhance profile reordering logic and adjust logging level

* perf: add PartialEq derive to PrfSelected and PrfExtra structs for improved comparison

* perf: refactor PrfOption merge logic and streamline update_item method in IProfiles

* perf: simplify current_mapping and profiles_preview methods in IProfiles for improved readability

* perf: optimize filename matching logic in IProfiles by using a static regex
2026-01-25 07:13:38 +00:00
Tunglies
0c6631ebb0 fix(ip-info-card): handle offline state and clashConfig absence in IP info fetching (#6085)
* fix(ip-info-card): handle offline state and clashConfig absence in IP info fetching

* fix: eslint errors
2026-01-25 07:12:17 +00:00
Sline
93e7ac1bce feat(webdav): cache connection status and adjust auto-refresh behavior (#6129) 2026-01-25 06:49:12 +00:00
Sline
b921098182 refactor(connections): switch manager table to TanStack column accessors and IConnectionsItem rows (#6083)
* refactor(connection-table): drive column order/visibility/sorting by TanStack Table state

* refactor(connection-table): simplify table data flow and align with built-in API

* refactor(connection-table): let column manager consume TanStack Table columns directly
2026-01-25 06:49:10 +00:00
Sline
440f95f617 feat(misc-viewer): optional delay check interval (#6145)
Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2026-01-25 06:48:16 +00:00
Tunglies
b9667ad349 chore: bump version to 2.4.6 2026-01-25 14:22:22 +08:00
Tunglies
4e7cdbfcc0 Release: 2.4.5 2026-01-25 14:05:57 +08:00
Tunglies
966fd68087 fix(unix): update clash_verge_service_ipc to 2.1.1 to fix directory permissions 2026-01-25 13:35:18 +08:00
Tunglies
334cec3bde fix: update tauri-plugin-mihomo version, improve error handling #6149 2026-01-24 09:19:52 +08:00
Tunglies
6e16133393 ci(Mergify): configuration update (#6152)
Signed-off-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-01-23 14:35:57 +00:00
Tunglies
5e976c2fe1 chore: inline crate clash-verge-types to module for better maintenance (#6142) 2026-01-23 14:00:51 +00:00
DikozImpact
d81aa5f233 Ru language fix (#6143)
* Ru language fix

* Update proxies.json

* Update home.json
2026-01-23 07:42:31 +08:00
Tunglies
e5fc0de39a ci: downgrade Ubuntu version in autobuild workflow 2026-01-22 22:08:19 +08:00
Tunglies
6c62350cc3 Release: bump version to 2.4.5-rc.2 2026-01-21 21:26:11 +08:00
Tunglies
d1649e3017 fix: update service to 2.1.0 and improve service installation for Unix systems (#6114)
* fix: update service to 2.1.0 and improve service installation for Unix systems

* fix: set GID environment variable during service installation on Linux

* Revert "fix: set GID environment variable during service installation on Linux"

This reverts commit 373aec579b.
2026-01-19 14:02:25 +08:00
Slinetrac
2869a35f1e chore(i18n): update backend i18n keys
translated by GPT-5.2.
2026-01-19 12:35:30 +08:00
renovate[bot]
98f12a9c72 chore(deps): lock file maintenance npm dependencies (#6119)
* chore(deps): lock file maintenance npm dependencies

* chore: run pnpm update

* fix(components): satisfy ESLint destructuring and narrow unknown errors

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-19 12:05:48 +08:00
renovate[bot]
6dc8a2f232 chore(deps): lock file maintenance cargo dependencies (#6118)
* chore(deps): lock file maintenance cargo dependencies

* chore: update Cargo.toml

* refactor: use reqwest directly

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-19 12:05:36 +08:00
Tunglies
6511f3868e fix: log IPC path issues conditionally based on tun mode setting 2026-01-18 09:13:22 +08:00
Tunglies
7da5a804f9 chore: bump service top 2.0.30, reduce memory footpoint 2026-01-18 08:43:39 +08:00
wonfen
20ed7a3abe chore: temporarily remove promotion 2026-01-17 03:33:10 +08:00
Sline
fd98caccd2 revert: use-app-data (#6088)
* Revert "refactor(app-data): split monolithic context into focused SWR hooks (#5576)"

This reverts commit 8e8182f707.

# Conflicts:
#	src/components/home/clash-info-card.tsx
#	src/components/home/clash-mode-card.tsx
#	src/components/home/current-proxy-card.tsx
#	src/components/home/home-profile-card.tsx
#	src/components/proxy/provider-button.tsx
#	src/components/proxy/proxy-chain.tsx
#	src/components/proxy/proxy-groups.tsx
#	src/components/proxy/use-render-list.ts
#	src/components/rule/provider-button.tsx
#	src/components/setting/mods/sysproxy-viewer.tsx
#	src/hooks/use-clash-data.ts
#	src/hooks/use-current-proxy.ts
#	src/hooks/use-shared-swr-poller.ts
#	src/hooks/use-system-proxy-state.ts
#	src/pages/rules.tsx

* docs: Changelog.md
2026-01-16 18:32:31 +08:00
Tunglies
a5f494bda2 fix: ensure external control source settings take effect immediately #6103 2026-01-16 18:27:21 +08:00
Tunglies
d4d8ef3849 chore: update Mihomo(Meta) kernel version to v1.19.19 2026-01-16 12:43:12 +08:00
Slinetrac
b16cbd5379 feat(backup): restore starts automatically with loading overlay without closing dialog 2026-01-16 12:32:36 +08:00
Tunglies
9e6689ef08 bump: sysproxy-rs version to 0.4.3 2026-01-15 21:25:10 +08:00
Tunglies
e0c35c5ee3 fix: unexpected port in use error when change ports 2026-01-15 17:40:06 +08:00
Slinetrac
670055aba1 docs: simplify Linux wording 2026-01-15 14:09:34 +08:00
Slinetrac
a780e44e69 docs: add warning to Changelog.md 2026-01-15 13:31:46 +08:00
Tunglies
5c9b46f031 chore: bump version to prerelease 2.4.5-rc.1 2026-01-14 16:55:27 +08:00
renovate[bot]
f5e75d5287 chore(deps): update dependency node to v24.13.0 (#6087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 14:40:47 +08:00
Slinetrac
c2d8277a1a fix(connections): allow full-width header sorting without triggering on resize 2026-01-14 11:23:50 +08:00
Tunglies
66e98518a7 chore(ci): update autobuild setup for ARM architecture support 2026-01-13 18:42:41 +08:00
Tunglies
089b73bbfd chore(deps): update clash_verge_service_ipc to version 2.0.29 (#6073) 2026-01-13 18:30:54 +08:00
Slinetrac
d2c52d09e1 chore(renovate): disable lockfile maintenance automerge 2026-01-12 15:10:52 +08:00
Slinetrac
84143ec761 chore(deps): bump npm deps 2026-01-12 14:50:54 +08:00
renovate[bot]
f451a26f8c chore(deps): lock file maintenance (#6063)
* chore(deps): lock file maintenance

* chore(deps): update Cargo.toml

* chore(deps): use git repo until the next release

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-12 14:45:33 +08:00
renovate[bot]
e1220a189b chore(deps): lock file maintenance npm dependencies (#6064)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 05:12:01 +00:00
Tunglies
57d4149807 fix(config): improve runtime config fallback handling 2026-01-11 15:27:54 +08:00
Slinetrac
86c3b241b1 docs: Changelog.md 2026-01-10 11:04:55 +08:00
Sline
a49000712d feat(tun-viewer): route-exclude-address GUI support (#6053) 2026-01-10 10:50:44 +08:00
歳納七夏
35b2066d4c build(tauri): add libayatana-appindicator3 dependency for linux packages (#6051) 2026-01-10 08:42:57 +08:00
renovate[bot]
92e0762fc4 chore(deps): update dependency @actions/github to v7 (#6042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 00:27:00 +08:00
Slinetrac
6b8630d357 docs: Changelog.md 2026-01-08 22:44:11 +08:00
Slinetrac
a1e77070f0 chore(deps): bump clash-verge-service-ipc to 2.0.29 2026-01-08 22:29:10 +08:00
Slinetrac
6926744ca2 docs: Changelog.md 2026-01-08 14:12:48 +08:00
Slinetrac
13855b9bc2 perf(tun-viewer): run enhanceProfiles in background to avoid save blocking 2026-01-08 14:03:00 +08:00
Slinetrac
1889f18183 feat(notice): override context menu to copy error details 2026-01-07 13:17:56 +08:00
Slinetrac
a981be80ef refactor(base): expand barrel exports and standardize imports 2026-01-06 15:02:10 +08:00
134 changed files with 3828 additions and 4033 deletions

View File

@@ -307,7 +307,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -377,7 +377,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -505,7 +505,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm

View File

@@ -46,7 +46,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -196,7 +196,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
@@ -253,10 +253,12 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
# It should be ubuntu-22.04 to match the cross-compilation environment
# ortherwise it is hard to resolve the dependencies
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu
arch: arm64
- os: ubuntu-24.04
- os: ubuntu-22.04
target: armv7-unknown-linux-gnueabihf
arch: armhf
runs-on: ${{ matrix.os }}
@@ -292,7 +294,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
@@ -311,33 +313,30 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
- name: Setup for linux
run: |
- name: "Setup for linux"
run: |-
sudo ls -lR /etc/apt/
sudo rm -f /etc/apt/sources.list.d/ubuntu.sources
sudo tee /etc/apt/sources.list << EOF
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble main restricted universe multiverse
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-security main restricted universe multiverse
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-updates main restricted universe multiverse
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-backports main restricted universe multiverse
cat > /tmp/sources.list << EOF
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
EOF
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
sudo mv /tmp/sources.list /etc/apt/sources.list
sudo dpkg --add-architecture ${{ matrix.arch }}
sudo apt-get update -y
sudo apt-get -f install -y
sudo apt update
sudo apt-get install -y \
linux-libc-dev:${{ matrix.arch }} \
libc6-dev:${{ matrix.arch }}
sudo apt-get install -y \
libxslt1-dev:${{ matrix.arch }} \
sudo apt install -y \
libxslt1.1:${{ matrix.arch }} \
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
libayatana-appindicator3-dev:${{ matrix.arch }} \
libssl-dev:${{ matrix.arch }} \
@@ -440,7 +439,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
@@ -542,7 +541,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4.2.0
name: Install pnpm

View File

@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm

View File

@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache

View File

@@ -47,7 +47,7 @@ jobs:
- uses: actions/setup-node@v6
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Restore pnpm cache

View File

@@ -197,7 +197,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -281,7 +281,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -420,7 +420,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -505,7 +505,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -593,7 +593,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm

View File

@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm

View File

@@ -1,51 +1,14 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
if ! command -v "cargo-make" >/dev/null 2>&1; then
echo "❌ cargo-make is required for pre-commit checks."
cargo install --force cargo-make
fi
if ! command -v pnpm >/dev/null 2>&1; then
echo "❌ pnpm is required for pre-commit checks."
exit 1
fi
LOCALE_DIFF="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src/locales/' || true)"
if [ -n "$LOCALE_DIFF" ]; then
echo "[pre-commit] Locale changes detected. Regenerating i18n types..."
pnpm i18n:types
if [ -d src/types/generated ]; then
echo "[pre-commit] Staging regenerated i18n type artifacts..."
git add src/types/generated
fi
fi
echo "[pre-commit] Running pnpm format before lint..."
pnpm format
echo "[pre-commit] Running lint-staged for JS/TS files..."
pnpm exec lint-staged
RUST_FILES="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src-tauri/.*\.rs$' || true)"
if [ -n "$RUST_FILES" ]; then
echo "[pre-commit] Formatting Rust changes with cargo fmt..."
cargo fmt
while IFS= read -r file; do
[ -n "$file" ] && git add "$file"
done <<< "$RUST_FILES"
echo "[pre-commit] Linting Rust changes with cargo clippy..."
cargo clippy-all
if ! command -v clash-verge-logging-check >/dev/null 2>&1; then
echo "[pre-commit] Installing clash-verge-logging-check..."
cargo install --git https://github.com/clash-verge-rev/clash-verge-logging-check.git
fi
clash-verge-logging-check
fi
TS_FILES="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx)$' || true)"
if [ -n "$TS_FILES" ]; then
echo "[pre-commit] Running TypeScript type check..."
pnpm typecheck
fi
echo "[pre-commit] All checks completed successfully."
cargo make pre-commit

View File

@@ -1,36 +1,9 @@
#!/bin/bash
set -euo pipefail
remote_name="${1:-origin}"
remote_url="${2:-unknown}"
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
if ! command -v pnpm >/dev/null 2>&1; then
echo "❌ pnpm is required for pre-push checks."
exit 1
if ! command -v "cargo-make" >/dev/null 2>&1; then
echo "❌ cargo-make is required for pre-push checks."
cargo install --force cargo-make
fi
echo "[pre-push] Preparing to push to '$remote_name' ($remote_url). Running full validation..."
echo "[pre-push] Checking Prettier formatting..."
pnpm format:check
echo "[pre-push] Running ESLint..."
pnpm lint
echo "[pre-push] Running TypeScript type checking..."
pnpm typecheck
if command -v cargo >/dev/null 2>&1; then
echo "[pre-push] Verifying Rust formatting..."
cargo fmt --check
echo "[pre-push] Running cargo clippy..."
cargo clippy-all
else
echo "[pre-push] ⚠️ cargo not found; skipping Rust checks."
fi
echo "[pre-push] All checks passed."
cargo make pre-push

5
.mergify.yml Normal file
View File

@@ -0,0 +1,5 @@
queue_rules:
- name: LetMeMergeForYou
batch_size: 3
allow_queue_branch_edit: true
queue_conditions: []

1250
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ members = [
"crates/clash-verge-logging",
"crates/clash-verge-signal",
"crates/tauri-plugin-clash-verge-sysinfo",
"crates/clash-verge-types",
"crates/clash-verge-i18n",
]
resolver = "2"
@@ -44,7 +43,6 @@ strip = false
clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-logging = { path = "crates/clash-verge-logging" }
clash-verge-signal = { path = "crates/clash-verge-signal" }
clash-verge-types = { path = "crates/clash-verge-types" }
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
@@ -59,14 +57,14 @@ tokio = { version = "1.49.0", features = [
"time",
"sync",
] }
flexi_logger = "0.31.7"
flexi_logger = "0.31.8"
log = "0.4.29"
smartstring = { version = "1.0.1" }
compact_str = { version = "0.9.0", features = ["serde"] }
serde = { version = "1.0.228" }
serde_json = { version = "1.0.148" }
serde_json = { version = "1.0.149" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.10.0" }

View File

@@ -1,44 +1,23 @@
## v2.4.5
- **Mihomo(Meta) 内核升级至 v1.19.18**
## v(2.4.6)
### 🐞 修复问题
- 修复 macOS 有线网络 DNS 劫持失败
- 修复 Monaco 编辑器内右键菜单显示异常
- 修复设置代理端口时检查端口占用
- 修复 Monaco 编辑器初始化卡 Loading
- 修复恢复备份时 `config.yaml` / `profiles.yaml` 文件内字段未正确恢复
- 修复 Windows 下系统主题同步问题
- 修复 URL Schemes 无法正常导入
- 修复首次启动时代理信息刷新缓慢
- 修复无网络时无限请求 IP 归属查询
- 修复 WebDAV 页面重试逻辑
- 修复 Linux 通过 GUI 安装服务模式权限不符合预期
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 允许代理页面允许高级过滤搜索
- 备份设置页面新增导入备份按钮
- 允许修改通知弹窗位置
- 支持收起导航栏(导航栏右键菜单 / 界面设置)
- 允许将出站模式显示在托盘一级菜单
- 允许禁用在托盘中显示代理组
- 支持在「编辑节点」中直接导入 AnyTLS URI 配置
- 支持关闭「验证代理绕过格式」
- 新增系统代理绕过的可视化编辑器
- 支持订阅设置自动延时监测间隔
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 应用内更新日志支持解析并渲染 HTML 标签
- 性能优化前后端在渲染流量图时的资源
- 在 Linux NVIDIA 显卡环境下尝试禁用 WebKit DMABUF 渲染以规避潜在问题
- Windows 下自启动改为计划任务实现
- 改进托盘和窗口操作频率限制实现
- 使用「编辑节点」添加节点时,自动将节点添加到第一个 `select` 类型的代理组的第一位
- 隐藏侧边导航栏和悬浮跳转导航的滚动条
- 完善对 AnyTLS / Mieru / Sudoku 的 GUI 支持
- macOS 和 Linux 对服务 IPC 权限进一步限制
- 移除 Windows 自启动计划任务中冗余的 3 秒延时
- 后端性能优化
- 前端性能优化
</details>

86
Makefile.toml Normal file
View File

@@ -0,0 +1,86 @@
[config]
skip_core_tasks = true
skip_git_env_info = true
skip_rust_env_info = true
skip_crate_env_info = true
# --- Backend ---
[tasks.rust-format]
install_crate = "rustfmt"
command = "cargo"
args = ["fmt", "--", "--emit=files"]
[tasks.rust-clippy]
description = "Run cargo clippy to lint the code"
command = "cargo"
args = ["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"]
# --- Frontend ---
[tasks.eslint]
description = "Run ESLint to lint the code"
command = "pnpm"
args = ["lint"]
[tasks.eslint.windows]
command = "pnpm.cmd"
[tasks.typecheck]
description = "Run type checks"
command = "pnpm"
args = ["typecheck"]
[tasks.typecheck.windows]
command = "pnpm.cmd"
[tasks.lint-staged]
description = "Run lint-staged for staged files"
command = "pnpm"
args = ["exec", "lint-staged"]
[tasks.lint-staged.windows]
command = "pnpm.cmd"
# --- Jobs ---
# Rust format (for pre-commit)
[tasks.rust-format-check]
description = "Check Rust code formatting"
dependencies = ["rust-format"]
[tasks.rust-format-check.condition]
files_modified.input = [
"./src-tauri/**/*.rs",
"./crates/**/*.rs",
"**/Cargo.toml",
]
files_modified.output = ["./target/debug/*", "./target/release/*"]
# Rust lint (for pre-push)
[tasks.rust-lint]
description = "Run Rust linting"
dependencies = ["rust-clippy"]
[tasks.rust-lint.condition]
files_modified.input = [
"./src-tauri/**/*.rs",
"./crates/**/*.rs",
"**/Cargo.toml",
]
files_modified.output = ["./target/debug/*", "./target/release/*"]
# Frontend format (for pre-commit)
[tasks.frontend-format]
description = "Frontend format checks"
dependencies = ["lint-staged"]
# Frontend lint (for pre-push)
[tasks.frontend-lint]
description = "Frontend linting and type checking"
dependencies = ["eslint", "typecheck"]
# --- Git Hooks ---
[tasks.pre-commit]
description = "Pre-commit checks: format only"
dependencies = ["rust-format-check", "frontend-format"]
[tasks.pre-push]
description = "Pre-push checks: lint and typecheck"
dependencies = ["rust-lint", "frontend-lint"]

View File

@@ -66,19 +66,6 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
🌐 官网:👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### 本项目的构建与发布环境由 [YXVM](https://yxvm.com/aff.php?aff=827) 独立服务器全力支持,
感谢提供 独享资源、高性能、高速网络 的强大后端环境。如果你觉得下载够快、使用够爽,那是因为我们用了好服务器!
🧩 YXVM 独立服务器优势:
- 🌎 优质网络,回程优化,下载快到飞起
- 🔧 物理机独享资源非VPS可比性能拉满
- 🧠 适合跑代理、搭建 WEB 站 CDN 站 、搞 CI/CD 或任何高负载应用
- 💡 支持即开即用多机房选择CN2 / IEPL 可选
- 📦 本项目使用配置已在售,欢迎同款入手!
- 🎯 想要同款构建体验?[立即下单 YXVM 独立服务器!](https://yxvm.com/aff.php?aff=827)
## Features
- 基于性能强劲的 Rust 和 Tauri 2 框架

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: لوحة التحكم
body: تم تحديث حالة عرض لوحة التحكم.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: تبديل الوضع
body: تم التبديل إلى {mode}.
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: وكيل النظام
body: تم تحديث حالة وكيل النظام.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: وضع TUN
body: تم تحديث حالة وضع TUN.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: الوضع الخفيف
body: تم الدخول إلى الوضع الخفيف.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: الملفات التعريفية
body: تمت إعادة تفعيل الملف التعريفي.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: على وشك الخروج
body: Clash Verge على وشك الخروج.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: تم إخفاء التطبيق
body: Clash Verge يعمل في الخلفية.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
rule: Rule
direct: Direct
global: Global
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
dashboard: لوحة التحكم
ruleMode: وضع القواعد
globalMode: الوضع العام
directMode: الوضع المباشر
outboundModes: أوضاع الخروج
rule: قاعدة
direct: مباشر
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
lightweightMode: الوضع الخفيف
copyEnv: نسخ متغيرات البيئة
confDir: دليل الإعدادات
coreDir: دليل النواة
logsDir: دليل السجلات
openDir: فتح الدليل
appLog: سجل التطبيق
coreLog: سجل النواة
restartClash: إعادة تشغيل نواة Clash
restartApp: إعادة تشغيل التطبيق
vergeVersion: إصدار Verge
more: المزيد
exit: خروج
tooltip:
systemProxy: System Proxy
systemProxy: وكيل النظام
tun: TUN
profile: Profile
profile: ملف تعريفي

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: Übersicht
body: Die Sichtbarkeit der Übersicht wurde aktualisiert.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: Moduswechsel
body: Auf {mode} umgeschaltet.
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: Systemproxy
body: Der Status des Systemproxys wurde aktualisiert.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: TUN-Modus
body: Der Status des TUN-Modus wurde aktualisiert.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: Leichtmodus
body: Leichtmodus aktiviert.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: Profile
body: Profil reaktiviert.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: Beenden steht bevor
body: Clash Verge wird gleich beendet.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: Anwendung ausgeblendet
body: Clash Verge läuft im Hintergrund.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
dashboard: Übersicht
ruleMode: Regelmodus
globalMode: Globaler Modus
directMode: Direktmodus
outboundModes: Ausgangsmodi
rule: Regel
direct: Direkt
global: Global
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
profiles: Profile
proxies: Proxy
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
lightweightMode: Leichtmodus
copyEnv: Umgebungsvariablen kopieren
confDir: Konfigurationsverzeichnis
coreDir: Core-Verzeichnis
logsDir: Log-Verzeichnis
openDir: Verzeichnis öffnen
appLog: Anwendungslog
coreLog: Core-Log
restartClash: Clash-Core neu starten
restartApp: Anwendung neu starten
vergeVersion: Verge-Version
more: Mehr
exit: Beenden
tooltip:
systemProxy: System Proxy
systemProxy: Systemproxy
tun: TUN
profile: Profile
profile: Profil

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: Panel
body: La visibilidad del panel se ha actualizado.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: Cambio de modo
body: Cambiado a {mode}.
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: Proxy del sistema
body: El estado del proxy del sistema se ha actualizado.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: Modo TUN
body: El estado del modo TUN se ha actualizado.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: Modo ligero
body: Se ha entrado en el modo ligero.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: Perfiles
body: Perfil reactivado.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: A punto de salir
body: Clash Verge está a punto de salir.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: Aplicación oculta
body: Clash Verge se está ejecutando en segundo plano.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
dashboard: Panel
ruleMode: Modo de reglas
globalMode: Modo global
directMode: Modo directo
outboundModes: Modos de salida
rule: Regla
direct: Directo
global: Global
profiles: Profiles
profiles: Perfiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
lightweightMode: Modo ligero
copyEnv: Copiar variables de entorno
confDir: Directorio de configuración
coreDir: Directorio del núcleo
logsDir: Directorio de registros
openDir: Abrir directorio
appLog: Registro de la aplicación
coreLog: Registro del núcleo
restartClash: Reiniciar el núcleo de Clash
restartApp: Reiniciar aplicación
vergeVersion: Versión de Verge
more: Más
exit: Salir
tooltip:
systemProxy: System Proxy
systemProxy: Proxy del sistema
tun: TUN
profile: Profile
profile: Perfil

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: داشبورد
body: وضعیت نمایش داشبورد به‌روزرسانی شد.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: تغییر حالت
body: به {mode} تغییر کرد.
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: پروکسی سیستم
body: وضعیت پروکسی سیستم به‌روزرسانی شد.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: حالت TUN
body: وضعیت حالت TUN به‌روزرسانی شد.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: حالت سبک
body: به حالت سبک وارد شد.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: پروفایل‌ها
body: پروفایل دوباره فعال شد.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: در آستانه خروج
body: Clash Verge در آستانه خروج است.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: برنامه پنهان شد
body: Clash Verge در پس‌زمینه در حال اجراست.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
rule: Rule
direct: Direct
global: Global
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
dashboard: داشبورد
ruleMode: حالت قوانین
globalMode: حالت سراسری
directMode: حالت مستقیم
outboundModes: حالت‌های خروجی
rule: قانون
direct: مستقیم
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
lightweightMode: حالت سبک
copyEnv: کپی متغیرهای محیطی
confDir: پوشه پیکربندی
coreDir: پوشه هسته
logsDir: پوشه گزارش‌ها
openDir: باز کردن پوشه
appLog: گزارش برنامه
coreLog: گزارش هسته
restartClash: راه‌اندازی مجدد هسته Clash
restartApp: راه‌اندازی مجدد برنامه
vergeVersion: نسخه Verge
more: بیشتر
exit: خروج
tooltip:
systemProxy: System Proxy
systemProxy: پروکسی سیستم
tun: TUN
profile: Profile
profile: پروفایل

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: Dasbor
body: Visibilitas dasbor telah diperbarui.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: Peralihan Mode
body: Beralih ke {mode}.
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: Proksi Sistem
body: Status proksi sistem telah diperbarui.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: Mode TUN
body: Status mode TUN telah diperbarui.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: Mode Ringan
body: Masuk ke mode ringan.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: Profil
body: Profil diaktifkan kembali.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: Akan Keluar
body: Clash Verge akan keluar.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: Aplikasi Disembunyikan
body: Clash Verge berjalan di latar belakang.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
dashboard: Dasbor
ruleMode: Mode Aturan
globalMode: Mode Global
directMode: Mode Langsung
outboundModes: Mode Keluar
rule: Aturan
direct: Langsung
global: Global
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
profiles: Profil
proxies: Proksi
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
lightweightMode: Mode Ringan
copyEnv: Salin Variabel Lingkungan
confDir: Direktori Konfigurasi
coreDir: Direktori Core
logsDir: Direktori Log
openDir: Buka Direktori
appLog: Log Aplikasi
coreLog: Log Core
restartClash: Mulai Ulang Core Clash
restartApp: Mulai Ulang Aplikasi
vergeVersion: Versi Verge
more: Lainnya
exit: Keluar
tooltip:
systemProxy: System Proxy
systemProxy: Proksi Sistem
tun: TUN
profile: Profile
profile: Profil

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: ダッシュボード
body: ダッシュボードの表示状態が更新されました。
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: モード切り替え
body: "{mode} に切り替えました。"
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: システムプロキシ
body: システムプロキシの状態が更新されました。
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: TUN モード
body: TUN モードの状態が更新されました。
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: 軽量モード
body: 軽量モードに入りました。
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: プロファイル
body: プロファイルが再有効化されました。
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: 終了間近
body: Clash Verge はまもなく終了します。
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: アプリが非表示
body: Clash Verge はバックグラウンドで実行中です。
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
dashboard: ダッシュボード
ruleMode: ルールモード
globalMode: グローバルモード
directMode: ダイレクトモード
outboundModes: アウトバウンドモード
rule: ルール
direct: ダイレクト
global: グローバル
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
profiles: プロファイル
proxies: プロキシ
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
lightweightMode: 軽量モード
copyEnv: 環境変数をコピー
confDir: 設定ディレクトリ
coreDir: コアディレクトリ
logsDir: ログディレクトリ
openDir: ディレクトリを開く
appLog: アプリケーションログ
coreLog: コアログ
restartClash: Clash コアを再起動
restartApp: アプリケーションを再起動
vergeVersion: Verge バージョン
more: その他
exit: 終了
tooltip:
systemProxy: System Proxy
systemProxy: システムプロキシ
tun: TUN
profile: Profile
profile: プロファイル

View File

@@ -16,8 +16,8 @@ notifications:
title: 경량 모드
body: 경량 모드에 진입했습니다.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: 프로필
body: 프로필이 다시 활성화되었습니다.
appQuit:
title: 곧 종료
body: Clash Verge가 곧 종료됩니다.
@@ -25,14 +25,14 @@ notifications:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
tray:
dashboard: 대시보드
ruleMode: 규칙 모드
globalMode: 전역 모드
directMode: 직접 모드
outboundModes: Outbound Modes
outboundModes: 아웃바운드 모드
rule: 규칙
direct: 직접
global: 글로벌

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: Панель
body: Видимость панели обновлена.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: Смена режима
body: Переключено на {mode}.
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: Системный прокси
body: Статус системного прокси обновлен.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: Режим TUN
body: Статус режима TUN обновлен.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: Легкий режим
body: Включен легкий режим.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: Профили
body: Профиль повторно активирован.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: Скорый выход
body: Clash Verge скоро завершит работу.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: Приложение скрыто
body: Clash Verge работает в фоновом режиме.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
dashboard: Панель
ruleMode: Режим правил
globalMode: Глобальный режим
directMode: Прямой режим
outboundModes: Исходящие режимы
rule: Правило
direct: Прямой
global: Глобальный
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
profiles: Профили
proxies: Прокси
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
lightweightMode: Легкий режим
copyEnv: Копировать переменные среды
confDir: Каталог конфигурации
coreDir: Каталог ядра
logsDir: Каталог журналов
openDir: Открыть каталог
appLog: Журнал приложения
coreLog: Журнал ядра
restartClash: Перезапустить ядро Clash
restartApp: Перезапустить приложение
vergeVersion: Версия Verge
more: Еще
exit: Выход
tooltip:
systemProxy: System Proxy
systemProxy: Системный прокси
tun: TUN
profile: Profile
profile: Профиль

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: Gösterge Paneli
body: Gösterge panelinin görünürlüğü güncellendi.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: Mod Değişimi
body: "{mode} moduna geçildi."
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: Sistem Vekil'i
body: Sistem vekil'i durumu güncellendi.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: TUN Modu
body: TUN modu durumu güncellendi.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: Hafif Mod
body: Hafif moda geçildi.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: Profiller
body: Profil yeniden etkinleştirildi.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: Çıkış Yapılmak Üzere
body: Clash Verge kapanmak üzere.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
dashboard: Gösterge Paneli
ruleMode: Kural Modu
globalMode: Küresel Mod
directMode: Doğrudan Mod
outboundModes: Giden Modlar
rule: Kural
direct: Doğrudan
global: Küresel
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
profiles: Profiller
proxies: Vekil'ler
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
lightweightMode: Hafif Mod
copyEnv: Ortam Değişkenlerini Kopyala
confDir: Yapılandırma Dizini
coreDir: Çekirdek Dizini
logsDir: Günlük Dizini
openDir: Dizini Aç
appLog: Uygulama Günlüğü
coreLog: Çekirdek Günlüğü
restartClash: Clash Çekirdeğini Yeniden Başlat
restartApp: Uygulamayı Yeniden Başlat
vergeVersion: Verge Sürümü
more: Daha Fazla
exit: Çıkış
tooltip:
systemProxy: System Proxy
systemProxy: Sistem Vekil'i
tun: TUN
profile: Profile
profile: Profil

View File

@@ -1,60 +1,60 @@
_version: 1
notifications:
dashboardToggled:
title: Dashboard
body: Dashboard visibility has been updated.
title: Идарә панеле
body: Идарә панеленең күренеше яңартылды.
clashModeChanged:
title: Mode Switch
body: Switched to {mode}.
title: Режим алыштыру
body: "{mode} режимына күчтел."
systemProxyToggled:
title: System Proxy
body: System proxy status has been updated.
title: Системалы прокси
body: Системалы прокси хәле яңартылды.
tunModeToggled:
title: TUN Mode
body: TUN mode status has been updated.
title: TUN режимы
body: TUN режимы хәле яңартылды.
lightweightModeEntered:
title: Lightweight Mode
body: Entered lightweight mode.
title: Җиңел режим
body: Җиңел режимга күчелде.
profilesReactivated:
title: Profiles
body: Profile Reactivated.
title: Профильләр
body: Профиль яңадан активлаштырылды.
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
title: Чыгар алдыннан
body: Clash Verge чыгарга җыена.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
title: Кушымта яшерелде
body: Clash Verge фон режимында эшли.
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
globalMode: Global Mode
directMode: Direct Mode
outboundModes: Outbound Modes
rule: Rule
direct: Direct
global: Global
profiles: Profiles
proxies: Proxies
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
lightweightMode: Lightweight Mode
copyEnv: Copy Environment Variables
confDir: Configuration Directory
coreDir: Core Directory
logsDir: Log Directory
openDir: Open Directory
appLog: Application Log
coreLog: Core Log
restartClash: Restart Clash Core
restartApp: Restart Application
vergeVersion: Verge Version
more: More
exit: Exit
dashboard: Идарә панеле
ruleMode: Кагыйдә режимы
globalMode: Глобаль режим
directMode: Турыдан-туры режим
outboundModes: Чыгыш режимнары
rule: Кагыйдә
direct: Турыдан-туры
global: Глобаль
profiles: Профильләр
proxies: Проксилар
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
lightweightMode: Җиңел режим
copyEnv: Мохит үзгәрүчәннәрен күчерү
confDir: Конфигурация каталогы
coreDir: Ядро каталогы
logsDir: Журнал каталогы
openDir: Каталогны ачу
appLog: Кушымта журналы
coreLog: Ядро журналы
restartClash: Clash ядрәсен кабат җибәрү
restartApp: Кушымтаны кабат җибәрү
vergeVersion: Verge версиясе
more: Күбрәк
exit: Чыгу
tooltip:
systemProxy: System Proxy
systemProxy: Системалы прокси
tun: TUN
profile: Profile
profile: Профиль

View File

@@ -1,13 +0,0 @@
[package]
name = "clash-verge-types"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
[dependencies]
serde = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true }
[lints]
workspace = true

View File

@@ -1 +0,0 @@
pub mod runtime;

View File

@@ -8,10 +8,10 @@ rust-version = "1.91"
tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true }
sysinfo = { version = "0.37.2", features = ["network", "system"] }
sysinfo = { version = "0.38.0", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.179"
libc = "0.2.180"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }

View File

@@ -120,6 +120,12 @@ fn is_binary_admin() -> bool {
.unwrap_or(false)
}
#[inline]
#[cfg(unix)]
pub fn current_gid() -> u32 {
unsafe { libc::getgid() }
}
#[inline]
pub fn list_network_interfaces() -> Vec<String> {
let mut networks = Networks::new();

View File

@@ -1,3 +1,57 @@
## v2.4.5
- **Mihomo(Meta) 内核升级至 v1.19.19**
### 🐞 修复问题
- 修复 macOS 有线网络 DNS 劫持失败
- 修复 Monaco 编辑器内右键菜单显示异常
- 修复设置代理端口时检查端口占用
- 修复 Monaco 编辑器初始化卡 Loading
- 修复恢复备份时 `config.yaml` / `profiles.yaml` 文件内字段未正确恢复
- 修复 Windows 下系统主题同步问题
- 修复 URL Schemes 无法正常导入
- 修复 Linux 下无法安装 TUN 服务
- 修复可能的端口被占用误报
- 修复设置允许外部控制来源不能立即生效
- 修复前端性能回归问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 允许代理页面允许高级过滤搜索
- 备份设置页面新增导入备份按钮
- 允许修改通知弹窗位置
- 支持收起导航栏(导航栏右键菜单 / 界面设置)
- 允许将出站模式显示在托盘一级菜单
- 允许禁用在托盘中显示代理组
- 支持在「编辑节点」中直接导入 AnyTLS URI 配置
- 支持关闭「验证代理绕过格式」
- 新增系统代理绕过和 TUN 排除自定义网段的可视化编辑器
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 应用内更新日志支持解析并渲染 HTML 标签
- 性能优化前后端在渲染流量图时的资源
- 在 Linux NVIDIA 显卡环境下尝试禁用 WebKit DMABUF 渲染以规避潜在问题
- Windows 下自启动改为计划任务实现
- 改进托盘和窗口操作频率限制实现
- 使用「编辑节点」添加节点时,自动将节点添加到第一个 `select` 类型的代理组的第一位
- 隐藏侧边导航栏和悬浮跳转导航的滚动条
- 完善对 AnyTLS / Mieru / Sudoku 的 GUI 支持
- macOS 和 Linux 对服务 IPC 权限进一步限制
- 移除 Windows 自启动计划任务中冗余的 3 秒延时
- 右键错误通知可复制错误详情
- 保存 TUN 设置时优化执行流程,避免界面卡顿
- 补充 `deb` / `rpm` 依赖 `libayatana-appindicator`
- 「连接」表格标题的排序点击区域扩展到整列宽度
- 备份恢复时显示加载覆盖层,恢复过程无需再手动关闭对话框
</details>
## v2.4.4
- **Mihomo(Meta) 内核升级至 v1.19.17**

View File

@@ -69,19 +69,6 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### Build Infrastructure Sponsor — [YXVM Dedicated Servers](https://yxvm.com/aff.php?aff=827)
Our builds and releases run on YXVM dedicated servers that deliver premium resources, strong performance, and high-speed networking. If downloads feel fast and usage feels snappy, it is thanks to robust hardware.
🧩 Highlights of YXVM Dedicated Servers:
- 🌎 Optimized global routes for dramatically faster downloads
- 🔧 Bare-metal resources instead of shared VPS capacity for maximum performance
- 🧠 Great for proxy workloads, hosting web/CDN services, CI/CD pipelines, or any high-load tasks
- 💡 Ready to use instantly with multiple datacenter options, including CN2 and IEPL
- 📦 The configuration used by this project is on sale—feel free to get the same setup
- 🎯 Want the same build environment? [Order a YXVM server today](https://yxvm.com/aff.php?aff=827)
## Features
- Built on high-performance Rust with the Tauri 2 framework

View File

@@ -63,19 +63,6 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
- Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### Patrocinador de la infraestructura de compilación — [Servidores dedicados YXVM](https://yxvm.com/aff.php?aff=827)
Las compilaciones y lanzamientos del proyecto se ejecutan en servidores dedicados de YXVM, que proporcionan recursos premium, alto rendimiento y redes de alta velocidad. Si las descargas son rápidas y el uso es fluido, es gracias a este hardware robusto.
🧩 Ventajas de los servidores dedicados YXVM:
- 🌎 Rutas globales optimizadas para descargas significativamente más rápidas
- 🔧 Recursos bare-metal, en lugar de VPS compartidos, para obtener el máximo rendimiento
- 🧠 Ideales para proxys, alojamiento de sitios web/CDN, pipelines de CI/CD o cualquier carga elevada
- 💡 Listos para usar al instante, con múltiples centros de datos disponibles (incluidos CN2 e IEPL)
- 📦 La misma configuración utilizada por este proyecto está disponible para su compra
- 🎯 ¿Quieres el mismo entorno de compilación? [Solicita un servidor YXVM hoy](https://yxvm.com/aff.php?aff=827)
## Funciones
- Basado en Rust de alto rendimiento y en el framework Tauri 2

View File

@@ -62,18 +62,6 @@
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### حامی زیرساخت ساخت — [سرورهای اختصاصی YXVM](https://yxvm.com/aff.php?aff=827)
بیلدها و نسخه‌های ما روی سرورهای اختصاصی YXVM اجرا می‌شوند که منابع ممتاز، عملکرد قوی و شبکه پرسرعت را ارائه می‌دهند. اگر دانلودها سریع و استفاده از آن سریع به نظر می‌رسد، به لطف سخت‌افزار قوی است.
🧩 نکات برجسته سرورهای اختصاصی YXVM:
- 🌎 مسیرهای جهانی بهینه شده برای دانلودهای بسیار سریعتر
- 🔧 منابع فیزیکی به جای ظرفیت VPS مشترک برای حداکثر کارایی
- 🧠 عالی برای بارهای کاری پروکسی، میزبانی سرویس‌های وب/CDN، خطوط لوله CI/CD یا هرگونه کار با بار بالا
- 💡 آماده استفاده فوری با گزینه‌های متعدد مرکز داده، از جمله CN2 و IEPL
- 📦 پیکربندی مورد استفاده در این پروژه در حال فروش است - می‌توانید همان تنظیمات را تهیه کنید.
- 🎯 آیا محیط ساخت مشابهی می‌خواهید؟ [همین امروز یک سرور YXVM سفارش دهید](https://yxvm.com/aff.php?aff=827)
## ویژگی‌ها
- ساخته شده بر اساس Rust با کارایی بالا و فریم‌ورک Tauri 2

View File

@@ -63,19 +63,6 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
- ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### ビルド環境スポンサー — [YXVM 専用サーバー](https://yxvm.com/aff.php?aff=827)
本プロジェクトのビルドとリリースは、YXVM の専用サーバーによって支えられています。高速ダウンロードや快適な操作性は、強力なハードウェアがあってこそです。
🧩 YXVM 専用サーバーの特長:
- 🌎 最適化されたグローバル回線で圧倒的なダウンロード速度
- 🔧 VPS とは異なるベアメタル資源で最高性能を発揮
- 🧠 プロキシ運用、Web/CDN ホスティング、CI/CD など高負荷ワークロードに最適
- 💡 複数データセンターから即時利用可能。CN2 や IEPL も選択可
- 📦 本プロジェクトが使用している構成も販売中。同じ環境を入手できます
- 🎯 同じビルド体験をしたい方は [今すぐ YXVM サーバーを注文](https://yxvm.com/aff.php?aff=827)
## 機能
- 高性能な Rust と Tauri 2 フレームワークに基づくデスクトップアプリ

View File

@@ -63,19 +63,6 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
- 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### 빌드 인프라 스폰서 — [YXVM 전용 서버](https://yxvm.com/aff.php?aff=827)
본 프로젝트의 빌드 및 릴리스는 YXVM 전용 서버에서 구동됩니다. 빠른 다운로드와 경쾌한 사용감은 탄탄한 하드웨어 덕분입니다.
🧩 YXVM 전용 서버 하이라이트:
- 🌎 최적화된 글로벌 라우팅으로 대폭 빨라진 다운로드
- 🔧 공유 VPS가 아닌 베어메탈 자원으로 최대 성능 제공
- 🧠 프록시 워크로드, Web/CDN 호스팅, CI/CD, 고부하 작업에 적합
- 💡 CN2 / IEPL 등 다양한 데이터센터 옵션, 즉시 사용 가능
- 📦 본 프로젝트가 사용하는 구성도 판매 중 — 동일한 환경을 사용할 수 있습니다
- 🎯 동일한 빌드 환경이 필요하다면 [지금 YXVM 서버 주문](https://yxvm.com/aff.php?aff=827)
## 기능
- 고성능 Rust와 Tauri 2 프레임워크 기반 데스크톱 앱

View File

@@ -59,19 +59,6 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### Среда сборки и публикации этого проекта полностью поддерживается выделенным сервером [YXVM](https://yxvm.com/aff.php?aff=827)
Благодарим вас за предоставление надежной бэкэнд-среды с эксклюзивными ресурсами, высокой производительностью и высокоскоростной сетью. Если вы считаете, что загрузка файлов происходит достаточно быстро, а использование — достаточно плавно, то это потому, что мы используем серверы высшего уровня!
🧩 Преимущества выделенного сервера YXVM:
- 🌎 Премиум-сеть с оптимизацией обратного пути для молниеносной скорости загрузки
- 🔧 Выделенные физические серверные ресурсы, не имеющие аналогов среди VPS, обеспечивающие максимальную производительность
- 🧠 Идеально подходит для прокси, хостинга веб-сайтов/CDN-сайтов, рабочих процессов CI/CD или любых приложений с высокой нагрузкой
- 💡 Поддержка использования сразу после включения, выбор нескольких дата-центров, CN2 / IEPL на выбор
- 📦 Эта конфигурация в настоящее время доступна для покупки — не стесняйтесь заказывать ту же модель!
- 🎯 Хотите попробовать такую же сборку? [Закажите выделенный сервер YXVM прямо сейчас!](https://yxvm.com/aff.php?aff=827)
## Фичи
- Основан на произвоительном Rust и фреймворке Tauri 2

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.4.5",
"version": "2.4.6",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -41,36 +41,36 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.6",
"@mui/icons-material": "^7.3.7",
"@mui/lab": "7.0.0-beta.17",
"@mui/material": "^7.3.6",
"@mui/material": "^7.3.7",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.16",
"@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-http": "~2.5.4",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.6",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "2.3.3",
"@tauri-apps/plugin-shell": "2.3.4",
"@tauri-apps/plugin-updater": "2.9.0",
"ahooks": "^3.9.6",
"axios": "^1.13.2",
"axios": "^1.13.3",
"dayjs": "1.11.19",
"foxact": "^0.2.49",
"i18next": "^25.7.3",
"foxact": "^0.2.52",
"i18next": "^25.8.0",
"js-yaml": "^4.1.1",
"lodash-es": "^4.17.22",
"lodash-es": "^4.17.23",
"monaco-editor": "^0.55.1",
"monaco-yaml": "^5.4.0",
"nanoid": "^5.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-error-boundary": "6.0.2",
"react-hook-form": "^7.70.0",
"react-i18next": "16.5.1",
"react-error-boundary": "6.1.0",
"react-hook-form": "^7.71.1",
"react-i18next": "16.5.3",
"react-markdown": "10.1.0",
"react-router": "^7.11.0",
"react-router": "^7.13.0",
"react-virtuoso": "^4.18.1",
"rehype-raw": "^7.0.0",
"swr": "^2.3.8",
@@ -78,14 +78,14 @@
"types-pac": "^1.0.3"
},
"devDependencies": {
"@actions/github": "^6.0.1",
"@eslint-react/eslint-plugin": "^2.5.1",
"@actions/github": "^8.0.0",
"@eslint-react/eslint-plugin": "^2.7.4",
"@eslint/js": "^9.39.2",
"@tauri-apps/cli": "2.9.6",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.4",
"@types/react": "19.2.7",
"@types/node": "^24.10.9",
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-react-swc": "^4.2.2",
@@ -97,25 +97,25 @@
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-unused-imports": "^4.3.0",
"glob": "^13.0.0",
"globals": "^17.0.0",
"globals": "^17.1.0",
"https-proxy-agent": "^7.0.6",
"husky": "^9.1.7",
"jiti": "^2.6.1",
"lint-staged": "^16.2.7",
"node-fetch": "^3.3.2",
"prettier": "^3.7.4",
"sass": "^1.97.1",
"tar": "^7.5.2",
"terser": "^5.44.1",
"prettier": "^3.8.1",
"sass": "^1.97.3",
"tar": "^7.5.6",
"terser": "^5.46.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.51.0",
"vite": "^7.3.0",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1",
"vite-plugin-svgr": "^4.5.0"
},
"lint-staged": {
@@ -128,7 +128,7 @@
]
},
"type": "module",
"packageManager": "pnpm@10.27.0",
"packageManager": "pnpm@10.28.0",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",

2681
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,8 +49,7 @@
"ignoreDeps": ["criterion"],
"lockFileMaintenance": {
"enabled": true,
"description": "Force update Cargo.lock to track latest commits of git dependencies",
"automerge": true,
"description": "Force update lockfile to track latest commits of git dependencies",
"schedule": ["before 5am on monday"]
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.4.5"
version = "2.4.6"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -34,7 +34,6 @@ tauri-build = { version = "2.5.3", features = [] }
clash-verge-draft = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-types = { workspace = true }
clash-verge-i18n = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
@@ -60,7 +59,7 @@ warp = { version = "0.4.2", features = ["server"] }
open = "5.3.3"
dunce = "1.0.5"
nanoid = "0.4"
chrono = "0.4.42"
chrono = "0.4.43"
boa_engine = "0.21.0"
once_cell = { version = "1.21.3", features = ["parking_lot"] }
delay_timer = "0.11.6"
@@ -72,18 +71,18 @@ reqwest = { version = "0.13.1", features = [
"form",
] }
regex = "1.12.2"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "0.4.3", features = [
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.3"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.4"
tauri-plugin-shell = "2.3.4"
tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.5"
tauri-plugin-deep-link = "2.4.6"
tauri-plugin-window-state = "2.4.1"
zip = "7.0.0"
reqwest_dav = "0.2.2"
zip = "7.2.0"
reqwest_dav = "0.3.1"
aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1"
getrandom = "0.3.4"
@@ -93,18 +92,19 @@ scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.18"
backoff = { version = "0.4.0", features = ["tokio"] }
tauri-plugin-http = "2.5.4"
tauri-plugin-http = "2.5.6"
console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.0.1" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" }
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.89"
clash_verge_service_ipc = { version = "2.0.28", features = [
clash_verge_service_ipc = { version = "2.1.2", features = [
"client",
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
arc-swap = "1.8.0"
rust_iso3166 = "0.1.14"
dark-light = "2.0.0"
# Use the git repo until the next release after v2.0.0.
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
governor = "0.10.4"
[target.'cfg(windows)'.dependencies]

View File

@@ -1,6 +1,6 @@
use super::{IClashTemp, IProfiles, IVerge};
use crate::{
config::{PrfItem, profiles_append_item_safe},
config::{PrfItem, profiles_append_item_safe, runtime::IRuntime},
constants::{files, timing},
core::{
CoreManager,
@@ -16,7 +16,6 @@ use anyhow::{Result, anyhow};
use backoff::{Error as BackoffError, ExponentialBackoff};
use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_types::runtime::IRuntime;
use smartstring::alias::String;
use std::path::PathBuf;
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
@@ -174,11 +173,14 @@ impl Config {
};
let runtime = Self::runtime().await;
let runtime_arc = runtime.latest_arc();
let config = runtime_arc
let runtime_lastest = runtime.latest_arc();
// Fall back to committed config if runtime config is missing
let runtime_data = runtime.data_arc();
let config = runtime_lastest
.config
.as_ref()
.ok_or_else(|| anyhow!("failed to get runtime config"))?;
.or_else(|| runtime_data.config.as_ref())
.ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?;
help::save_yaml(&path, config, Some("# Generated by Clash Verge")).await?;
Ok(path)

View File

@@ -4,6 +4,7 @@ mod config;
mod encrypt;
mod prfitem;
pub mod profiles;
pub mod runtime;
mod verge;
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};

View File

@@ -31,8 +31,8 @@ pub struct IProfilePreview<'a> {
#[derive(Debug, Clone)]
pub struct CleanupResult {
pub total_files: usize,
pub deleted_files: Vec<String>,
pub failed_deletions: Vec<String>,
pub deleted_files: usize,
pub failed_deletions: usize,
}
macro_rules! patch {
@@ -365,15 +365,11 @@ impl IProfiles {
}
/// 以 app 中的 profile 列表为准,删除不再需要的文件
pub async fn cleanup_orphaned_files(&self) -> Result<CleanupResult> {
pub async fn cleanup_orphaned_files(&self) -> Result<()> {
let profiles_dir = dirs::app_profiles_dir()?;
if !profiles_dir.exists() {
return Ok(CleanupResult {
total_files: 0,
deleted_files: vec![],
failed_deletions: vec![],
});
return Ok(());
}
// 获取所有 active profile 的文件名集合
@@ -384,11 +380,11 @@ impl IProfiles {
// 扫描 profiles 目录下的所有文件
let mut total_files = 0;
let mut deleted_files = vec![];
let mut failed_deletions = vec![];
let mut deleted_files = 0;
let mut failed_deletions = 0;
for entry in std::fs::read_dir(&profiles_dir)? {
let entry = entry?;
let mut dir_entries = tokio::fs::read_dir(&profiles_dir).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if !path.is_file() {
@@ -410,11 +406,11 @@ impl IProfiles {
if !active_files.contains(file_name) {
match path.to_path_buf().remove_if_exists().await {
Ok(_) => {
deleted_files.push(file_name.into());
deleted_files += 1;
logging!(debug, Type::Config, "已清理冗余文件: {file_name}");
}
Err(e) => {
failed_deletions.push(format!("{file_name}: {e}").into());
failed_deletions += 1;
logging!(warn, Type::Config, "Warning: 清理文件失败: {file_name} - {e}");
}
}
@@ -433,11 +429,11 @@ impl IProfiles {
Type::Config,
"Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}",
result.total_files,
result.deleted_files.len(),
result.failed_deletions.len()
result.deleted_files,
result.failed_deletions
);
Ok(result)
Ok(())
}
/// 不删除全局扩展配置

View File

@@ -2,6 +2,8 @@ use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
use crate::enhance::field::use_keys;
const PATCH_CONFIG_INNER: [&str; 4] = ["allow-lan", "ipv6", "log-level", "unified-delay"];
#[derive(Default, Clone)]
@@ -136,13 +138,3 @@ impl IRuntime {
}
}
}
// TODO 完整迁移 enhance 行为后移除
#[inline]
fn use_keys<'a>(config: &'a Mapping) -> impl Iterator<Item = String> + 'a {
config.iter().filter_map(|(key, _)| key.as_str()).map(|s: &str| {
let mut s: String = s.into();
s.make_ascii_lowercase();
s
})
}

View File

@@ -155,6 +155,9 @@ pub struct IVerge {
/// 是否自动检测当前节点延迟
pub enable_auto_delay_detection: Option<bool>,
/// 自动检测当前节点延迟的间隔(分钟)
pub auto_delay_detection_interval_minutes: Option<u64>,
/// 是否使用内部的脚本支持,默认为真
pub enable_builtin_enhanced: Option<bool>,
@@ -523,6 +526,7 @@ impl IVerge {
patch!(default_latency_test);
patch!(default_latency_timeout);
patch!(enable_auto_delay_detection);
patch!(auto_delay_detection_interval_minutes);
patch!(enable_builtin_enhanced);
patch!(proxy_layout_column);
patch!(test_list);

View File

@@ -14,7 +14,6 @@ use std::{
sync::Arc,
time::Duration,
};
use tauri_plugin_http::reqwest as tauri_reqwest;
use tokio::{fs, time::timeout};
use zip::write::SimpleFileOptions;
@@ -111,12 +110,12 @@ impl WebDavClient {
// 创建新的客户端
let client = reqwest_dav::ClientBuilder::new()
.set_agent(
tauri_reqwest::Client::builder()
reqwest::Client::builder()
.use_rustls_tls()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(op.timeout()))
.user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
.redirect(tauri_reqwest::redirect::Policy::custom(|attempt| {
.redirect(reqwest::redirect::Policy::custom(|attempt| {
// 允许所有请求类型的重定向包括PUT
if attempt.previous().len() >= 5 {
attempt.error("重定向次数过多")

View File

@@ -1,13 +1,12 @@
use super::CoreManager;
use crate::{
config::{Config, ConfigType},
config::{Config, ConfigType, runtime::IRuntime},
constants::timing,
core::{handle, validate::CoreConfigValidator},
utils::{dirs, help},
};
use anyhow::{Result, anyhow};
use clash_verge_logging::{Type, logging};
use clash_verge_types::runtime::IRuntime;
use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf, time::Instant};
use tauri_plugin_mihomo::Error as MihomoError;

View File

@@ -64,7 +64,7 @@ impl CoreManager {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = CompactString::from(String::from_utf8_lossy(&line).as_ref());
let message = CompactString::from(&*String::from_utf8_lossy(&line));
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message).await;
}

View File

@@ -256,9 +256,11 @@ fn install_service() -> Result<()> {
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
let command =
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
let command = format!(
r#"do shell script "sudo CLASH_VERGE_SERVICE_GID={gid} '{install_shell}'" with administrator privileges with prompt "{prompt}""#
);
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
@@ -381,7 +383,12 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
/// 检查服务是否正在运行
pub async fn is_service_available() -> Result<()> {
if let Err(e) = Path::metadata(clash_verge_service_ipc::IPC_PATH.as_ref()) {
logging!(warn, Type::Service, "Some issue with service IPC Path: {}", e);
let verge = Config::verge().await;
let verge_last = verge.latest_arc();
let is_enable = verge_last.enable_tun_mode.unwrap_or(false);
if is_enable {
logging!(warn, Type::Service, "Some issue with service IPC Path: {}", e);
}
return Err(e.into());
}
clash_verge_service_ipc::connect().await?;

View File

@@ -61,14 +61,11 @@ pub fn use_sort(config: Mapping) -> Mapping {
ret
}
pub fn use_keys(config: &Mapping) -> Vec<String> {
config
.iter()
.filter_map(|(key, _)| key.as_str())
.map(|s: &str| {
let mut s: String = s.into();
s.make_ascii_lowercase();
s
})
.collect()
#[inline]
pub fn use_keys<'a>(config: &'a Mapping) -> impl Iterator<Item = String> + 'a {
config.iter().filter_map(|(key, _)| key.as_str()).map(|s: &str| {
let mut s: String = s.into();
s.make_ascii_lowercase();
s
})
}

View File

@@ -310,7 +310,7 @@ fn process_global_items(
profile_name: &String,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
let mut result_map = HashMap::new();
let mut exists_keys = use_keys(&config);
let mut exists_keys = use_keys(&config).collect::<Vec<_>>();
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));

View File

@@ -19,7 +19,6 @@ use crate::{
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use std::time::Duration;
use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")]
use tauri_plugin_autostart::MacosLauncher;
@@ -61,11 +60,11 @@ mod app_init {
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
.pool_config(
tauri_plugin_mihomo::IpcPoolConfigBuilder::new()
.min_connections(1)
.min_connections(3)
.max_connections(32)
.idle_timeout(std::time::Duration::from_secs(60))
.health_check_interval(std::time::Duration::from_secs(60))
.reject_policy(RejectPolicy::Timeout(Duration::from_secs(3)))
.reject_policy(RejectPolicy::Wait)
.build(),
)
.build(),

View File

@@ -1,5 +1,5 @@
{
"version": "2.4.5",
"version": "2.4.6",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,

View File

@@ -5,7 +5,7 @@
"targets": ["deb", "rpm"],
"linux": {
"deb": {
"depends": ["openssl"],
"depends": ["openssl", "libayatana-appindicator3-1"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],
@@ -14,7 +14,7 @@
"preRemoveScript": "./packages/linux/pre-remove.sh"
},
"rpm": {
"depends": ["openssl"],
"depends": ["openssl", "libayatana-appindicator-gtk3"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],

View File

@@ -2,15 +2,18 @@ import { ReactNode } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
function ErrorFallback({ error }: FallbackProps) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
return (
<div role="alert" style={{ padding: 16 }}>
<h4>Something went wrong:(</h4>
<pre>{error.message}</pre>
<pre>{errorMessage}</pre>
<details title="Error Stack">
<summary>Error Stack</summary>
<pre>{error.stack}</pre>
<pre>{errorStack}</pre>
</details>
</div>
);

View File

@@ -1,8 +1,16 @@
export { BaseDialog, type DialogRef } from "./base-dialog";
export { BasePage } from "./base-page";
export { BaseEmpty } from "./base-empty";
export { BaseLoading } from "./base-loading";
export { BaseErrorBoundary } from "./base-error-boundary";
export { BaseSplitChipEditor } from "./base-split-chip-editor";
export { Switch } from "./base-switch";
export { BaseFieldset } from "./base-fieldset";
export { BaseLoading } from "./base-loading";
export { BaseLoadingOverlay } from "./base-loading-overlay";
export { BasePage } from "./base-page";
export { BaseSearchBox, type SearchState } from "./base-search-box";
export {
BaseSplitChipEditor,
type BaseSplitChipEditorMode,
} from "./base-split-chip-editor";
export { BaseStyledSelect } from "./base-styled-select";
export { BaseStyledTextField } from "./base-styled-text-field";
export { Switch } from "./base-switch";
export { TooltipIcon } from "./base-tooltip-icon";

View File

@@ -21,20 +21,14 @@ import {
ListItem,
ListItemText,
} from "@mui/material";
import type { Column } from "@tanstack/react-table";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
interface ColumnOption {
field: string;
label: string;
visible: boolean;
}
interface Props {
open: boolean;
columns: ColumnOption[];
columns: Column<IConnectionsItem, unknown>[];
onClose: () => void;
onToggle: (field: string, visible: boolean) => void;
onOrderChange: (order: string[]) => void;
onReset: () => void;
}
@@ -43,7 +37,6 @@ export const ConnectionColumnManager = ({
open,
columns,
onClose,
onToggle,
onOrderChange,
onReset,
}: Props) => {
@@ -54,9 +47,9 @@ export const ConnectionColumnManager = ({
);
const { t } = useTranslation();
const items = useMemo(() => columns.map((column) => column.field), [columns]);
const items = useMemo(() => columns.map((column) => column.id), [columns]);
const visibleCount = useMemo(
() => columns.filter((column) => column.visible).length,
() => columns.filter((column) => column.getIsVisible()).length,
[columns],
);
@@ -65,7 +58,7 @@ export const ConnectionColumnManager = ({
const { active, over } = event;
if (!over || active.id === over.id) return;
const order = columns.map((column) => column.field);
const order = columns.map((column) => column.id);
const oldIndex = order.indexOf(active.id as string);
const newIndex = order.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
@@ -94,13 +87,16 @@ export const ConnectionColumnManager = ({
>
{columns.map((column) => (
<SortableColumnItem
key={column.field}
key={column.id}
column={column}
onToggle={onToggle}
label={getColumnLabel(column)}
dragHandleLabel={t(
"connections.components.columnManager.dragHandle",
)}
disableToggle={column.visible && visibleCount <= 1}
disableToggle={
!column.getCanHide() ||
(column.getIsVisible() && visibleCount <= 1)
}
/>
))}
</List>
@@ -120,15 +116,15 @@ export const ConnectionColumnManager = ({
};
interface SortableColumnItemProps {
column: ColumnOption;
onToggle: (field: string, visible: boolean) => void;
column: Column<IConnectionsItem, unknown>;
label: string;
dragHandleLabel: string;
disableToggle?: boolean;
}
const SortableColumnItem = ({
column,
onToggle,
label,
dragHandleLabel,
disableToggle = false,
}: SortableColumnItemProps) => {
@@ -139,7 +135,7 @@ const SortableColumnItem = ({
transform,
transition,
isDragging,
} = useSortable({ id: column.field });
} = useSortable({ id: column.id });
const style = useMemo(
() => ({
@@ -167,12 +163,12 @@ const SortableColumnItem = ({
>
<Checkbox
edge="start"
checked={column.visible}
checked={column.getIsVisible()}
disabled={disableToggle}
onChange={(event) => onToggle(column.field, event.target.checked)}
onChange={(event) => column.toggleVisibility(event.target.checked)}
/>
<ListItemText
primary={column.label}
primary={label}
slotProps={{ primary: { variant: "body2" } }}
sx={{ mr: 1 }}
/>
@@ -189,3 +185,11 @@ const SortableColumnItem = ({
</ListItem>
);
};
const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => {
const meta = column.columnDef.meta as { label?: string } | undefined;
if (meta?.label) return meta.label;
const header = column.columnDef.header;
return typeof header === "string" ? header : column.id;
};

View File

@@ -2,6 +2,7 @@ import { ViewColumnRounded } from "@mui/icons-material";
import { Box, IconButton, Tooltip } from "@mui/material";
import {
ColumnDef,
ColumnOrderState,
ColumnSizingState,
flexRender,
getCoreRowModel,
@@ -43,50 +44,57 @@ const reconcileColumnOrder = (
return [...filtered, ...missing];
};
const createConnectionRow = (each: IConnectionsItem) => {
type ColumnField =
| "host"
| "download"
| "upload"
| "dlSpeed"
| "ulSpeed"
| "chains"
| "rule"
| "process"
| "time"
| "source"
| "remoteDestination"
| "type";
const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const destination = metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
return {
id: each.id,
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath),
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: destination,
type: `${metadata.type}(${metadata.network})`,
connectionData: each,
};
switch (field) {
case "host":
return metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
case "download":
return each.download;
case "upload":
return each.upload;
case "dlSpeed":
return each.curDownload;
case "ulSpeed":
return each.curUpload;
case "chains":
return [...each.chains].reverse().join(" / ");
case "rule":
return rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
case "process":
return truncateStr(metadata.process || metadata.processPath);
case "time":
return each.start;
case "source":
return `${metadata.sourceIP}:${metadata.sourcePort}`;
case "remoteDestination":
return metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
case "type":
return `${metadata.type}(${metadata.network})`;
default:
return "";
}
};
type ConnectionRow = ReturnType<typeof createConnectionRow>;
const areRowsEqual = (a: ConnectionRow, b: ConnectionRow) =>
a.host === b.host &&
a.download === b.download &&
a.upload === b.upload &&
a.dlSpeed === b.dlSpeed &&
a.ulSpeed === b.ulSpeed &&
a.chains === b.chains &&
a.rule === b.rule &&
a.process === b.process &&
a.time === b.time &&
a.source === b.source &&
a.remoteDestination === b.remoteDestination &&
a.type === b.type;
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
@@ -104,33 +112,30 @@ export const ConnectionTable = (props: Props) => {
onCloseColumnManager,
} = props;
const { t } = useTranslation();
const [columnWidths, setColumnWidths] = useLocalStorage<
Record<string, number>
>(
const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
"connection-table-widths",
// server-side value, this is the default value used by server-side rendering (if any)
// Do not omit (otherwise a Suspense boundary will be triggered)
{},
);
const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage<
Partial<Record<string, boolean>>
>(
"connection-table-visibility",
{},
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") return parsed;
} catch (err) {
console.warn("Failed to parse connection-table-visibility", err);
}
return {};
const [columnVisibilityModel, setColumnVisibilityModel] =
useLocalStorage<VisibilityState>(
"connection-table-visibility",
{},
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") return parsed;
} catch (err) {
console.warn("Failed to parse connection-table-visibility", err);
}
return {};
},
},
},
);
);
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
"connection-table-order",
@@ -149,15 +154,13 @@ export const ConnectionTable = (props: Props) => {
},
);
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
interface BaseColumn {
field: ColumnField;
headerName: string;
width?: number;
minWidth?: number;
align?: "left" | "right";
cell?: (row: ConnectionRow) => ReactNode;
cell?: (row: IConnectionsItem) => ReactNode;
}
const baseColumns = useMemo<BaseColumn[]>(() => {
@@ -190,7 +193,7 @@ export const ConnectionTable = (props: Props) => {
width: 76,
minWidth: 60,
align: "right",
cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`,
cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`,
},
{
field: "ulSpeed",
@@ -198,7 +201,7 @@ export const ConnectionTable = (props: Props) => {
width: 76,
minWidth: 60,
align: "right",
cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`,
cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`,
},
{
field: "chains",
@@ -262,177 +265,76 @@ export const ConnectionTable = (props: Props) => {
});
}, [baseColumns, setColumnOrder]);
const columns = useMemo<BaseColumn[]>(() => {
const order = Array.isArray(columnOrder) ? columnOrder : [];
const orderMap = new Map(order.map((field, index) => [field, index]));
return [...baseColumns].sort((a, b) => {
const aIndex = orderMap.has(a.field)
? (orderMap.get(a.field) as number)
: Number.MAX_SAFE_INTEGER;
const bIndex = orderMap.has(b.field)
? (orderMap.get(b.field) as number)
: Number.MAX_SAFE_INTEGER;
if (aIndex === bIndex) {
return order.indexOf(a.field) - order.indexOf(b.field);
}
return aIndex - bIndex;
});
}, [baseColumns, columnOrder]);
const visibleColumnsCount = useMemo(() => {
return columns.reduce((count, column) => {
return (columnVisibilityModel?.[column.field] ?? true) !== false
? count + 1
: count;
}, 0);
}, [columns, columnVisibilityModel]);
const handleToggleColumn = useCallback(
(field: string, visible: boolean) => {
if (!visible && visibleColumnsCount <= 1) {
return;
}
const handleColumnVisibilityChange = useCallback(
(update: Updater<VisibilityState>) => {
setColumnVisibilityModel((prev) => {
const next = { ...(prev ?? {}) };
if (visible) {
delete next[field];
} else {
next[field] = false;
const current = prev ?? {};
const nextState =
typeof update === "function" ? update(current) : update;
const visibleCount = baseColumns.reduce((count, column) => {
const isVisible = (nextState[column.field] ?? true) !== false;
return count + (isVisible ? 1 : 0);
}, 0);
if (visibleCount === 0) {
return current;
}
return next;
const sanitized: VisibilityState = {};
baseColumns.forEach((column) => {
if (nextState[column.field] === false) {
sanitized[column.field] = false;
}
});
return sanitized;
});
},
[setColumnVisibilityModel, visibleColumnsCount],
[baseColumns, setColumnVisibilityModel],
);
const handleManagerOrderChange = useCallback(
(order: string[]) => {
setColumnOrder(() => {
const handleColumnOrderChange = useCallback(
(update: Updater<ColumnOrderState>) => {
setColumnOrder((prev) => {
const current = Array.isArray(prev) ? prev : [];
const nextState =
typeof update === "function" ? update(current) : update;
const baseFields = baseColumns.map((col) => col.field);
return reconcileColumnOrder(order, baseFields);
return reconcileColumnOrder(nextState, baseFields);
});
},
[baseColumns, setColumnOrder],
);
const handleResetColumns = useCallback(() => {
setColumnVisibilityModel({});
setColumnOrder(baseColumns.map((col) => col.field));
}, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
const handleColumnVisibilityChange = useCallback(
(update: Updater<VisibilityState>) => {
setColumnVisibilityModel((prev) => {
const current = prev ?? {};
const baseState: VisibilityState = {};
columns.forEach((column) => {
baseState[column.field] = (current[column.field] ?? true) !== false;
});
const mergedState =
typeof update === "function"
? update(baseState)
: { ...baseState, ...update };
const hiddenFields = columns
.filter((column) => mergedState[column.field] === false)
.map((column) => column.field);
if (columns.length - hiddenFields.length === 0) {
return current;
}
const sanitized: Partial<Record<string, boolean>> = {};
hiddenFields.forEach((field) => {
sanitized[field] = false;
});
return sanitized;
});
},
[columns, setColumnVisibilityModel],
);
const columnVisibilityState = useMemo<VisibilityState>(() => {
const result: VisibilityState = {};
if (!columnVisibilityModel) {
columns.forEach((column) => {
result[column.field] = true;
});
return result;
}
columns.forEach((column) => {
result[column.field] =
(columnVisibilityModel?.[column.field] ?? true) !== false;
});
return result;
}, [columnVisibilityModel, columns]);
const columnOptions = useMemo(() => {
return columns.map((column) => ({
field: column.field,
label: column.headerName ?? column.field,
visible: (columnVisibilityModel?.[column.field] ?? true) !== false,
}));
}, [columns, columnVisibilityModel]);
const prevRowsRef = useRef<Map<string, ConnectionRow>>(new Map());
const connRows = useMemo<ConnectionRow[]>(() => {
const prevMap = prevRowsRef.current;
const nextMap = new Map<string, ConnectionRow>();
const nextRows = connections.map((each) => {
const nextRow = createConnectionRow(each);
const prevRow = prevMap.get(each.id);
if (prevRow && areRowsEqual(prevRow, nextRow)) {
nextMap.set(each.id, prevRow);
return prevRow;
}
nextMap.set(each.id, nextRow);
return nextRow;
});
prevRowsRef.current = nextMap;
return nextRows;
}, [connections]);
const [sorting, setSorting] = useState<SortingState>([]);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
return columns.map((column) => {
const baseCell: ColumnDef<ConnectionRow>["cell"] = column.cell
const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
return baseColumns.map((column) => {
const baseCell: ColumnDef<IConnectionsItem>["cell"] = column.cell
? (ctx) => column.cell?.(ctx.row.original)
: (ctx) => ctx.getValue() as ReactNode;
const cell: ColumnDef<ConnectionRow>["cell"] =
const cell: ColumnDef<IConnectionsItem>["cell"] =
column.field === "time"
? (ctx) => dayjs(ctx.row.original.time).from(relativeNow)
? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow)
: baseCell;
return {
id: column.field,
accessorKey: column.field,
accessorFn: (row) => getConnectionCellValue(column.field, row),
header: column.headerName,
size: column.width,
minSize: column.minWidth ?? 80,
enableResizing: true,
minSize: column.minWidth,
meta: {
align: column.align ?? "left",
field: column.field,
label: column.headerName,
},
cell,
} satisfies ColumnDef<ConnectionRow>;
} satisfies ColumnDef<IConnectionsItem>;
});
}, [columns, relativeNow]);
}, [baseColumns, relativeNow]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
@@ -450,7 +352,7 @@ export const ConnectionTable = (props: Props) => {
const prevState = prev ?? {};
const nextState =
typeof updater === "function" ? updater(prevState) : updater;
const sanitized: Record<string, number> = {};
const sanitized: ColumnSizingState = {};
Object.entries(nextState).forEach(([key, size]) => {
if (typeof size === "number" && Number.isFinite(size)) {
sanitized[key] = size;
@@ -463,22 +365,45 @@ export const ConnectionTable = (props: Props) => {
);
const table = useReactTable({
data: connRows,
data: connections,
state: {
columnVisibility: columnVisibilityState,
columnVisibility: columnVisibilityModel ?? {},
columnSizing: columnWidths,
columnOrder,
sorting,
},
initialState: {
columnOrder: baseColumns.map((col) => col.field),
},
defaultColumn: {
minSize: 80,
enableResizing: true,
},
columnResizeMode: "onChange",
enableSortingRemoval: true,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
onSortingChange: setSorting,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: handleColumnVisibilityChange,
onColumnOrderChange: handleColumnOrderChange,
columns: columnDefs,
});
const handleManagerOrderChange = useCallback(
(order: string[]) => {
const baseFields = baseColumns.map((col) => col.field);
table.setColumnOrder(reconcileColumnOrder(order, baseFields));
},
[baseColumns, table],
);
const handleResetColumns = useCallback(() => {
table.resetColumnVisibility();
table.resetColumnOrder();
}, [table]);
const rows = table.getRowModel().rows;
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({
@@ -491,6 +416,7 @@ export const ConnectionTable = (props: Props) => {
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const tableWidth = table.getTotalSize();
const managerColumns = table.getAllLeafColumns();
return (
<>
@@ -582,15 +508,10 @@ export const ConnectionTable = (props: Props) => {
alignItems: "center",
position: "relative",
boxSizing: "border-box",
px: 1,
py: 1,
fontSize: 13,
fontWeight: 600,
color: "text.secondary",
userSelect: "none",
justifyContent:
meta?.align === "right" ? "flex-end" : "flex-start",
gap: 0.25,
"&:hover": {
backgroundColor: (theme) =>
theme.palette.action.hover,
@@ -599,15 +520,26 @@ export const ConnectionTable = (props: Props) => {
>
<Box
component="span"
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
sx={{
display: "inline-flex",
flex: 1,
display: "flex",
alignItems: "center",
justifyContent:
meta?.align === "right"
? "flex-end"
: "flex-start",
gap: 0.5,
px: 1,
py: 1,
cursor: header.column.getCanSort()
? "pointer"
: "default",
}}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
@@ -620,8 +552,15 @@ export const ConnectionTable = (props: Props) => {
</Box>
{header.column.getCanResize() && (
<Box
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => {
event.stopPropagation();
header.getResizeHandler()(event);
}}
onTouchStart={(event) => {
event.stopPropagation();
header.getResizeHandler()(event);
}}
sx={{
cursor: "col-resize",
position: "absolute",
@@ -656,7 +595,7 @@ export const ConnectionTable = (props: Props) => {
return (
<Box
key={row.id}
onClick={() => onShowDetail(row.original.connectionData)}
onClick={() => onShowDetail(row.original)}
sx={{
display: "flex",
position: "absolute",
@@ -713,9 +652,8 @@ export const ConnectionTable = (props: Props) => {
</Box>
<ConnectionColumnManager
open={columnManagerOpen}
columns={columnOptions}
columns={managerColumns}
onClose={onCloseColumnManager}
onToggle={handleToggleColumn}
onOrderChange={handleManagerOrderChange}
onReset={handleResetColumns}
/>

View File

@@ -4,13 +4,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useClash } from "@/hooks/use-clash";
import {
useAppUptime,
useClashConfig,
useRulesData,
useSystemProxyAddress,
useSystemProxyData,
} from "@/hooks/use-clash-data";
import { useAppData } from "@/providers/app-data-context";
import { EnhancedCard } from "./enhanced-card";
@@ -25,14 +19,7 @@ const formatUptime = (uptimeMs: number) => {
export const ClashInfoCard = () => {
const { t } = useTranslation();
const { version: clashVersion } = useClash();
const { clashConfig } = useClashConfig();
const { sysproxy } = useSystemProxyData();
const { rules } = useRulesData();
const { uptime } = useAppUptime();
const systemProxyAddress = useSystemProxyAddress({
clashConfig,
sysproxy,
});
const { clashConfig, rules, uptime, systemProxyAddress } = useAppData();
// 使用useMemo缓存格式化后的uptime避免频繁计算
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);

View File

@@ -9,8 +9,8 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { closeAllConnections } from "tauri-plugin-mihomo-api";
import { useClashConfig } from "@/hooks/use-clash-data";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import { patchClashMode } from "@/services/cmds";
import type { TranslationKey } from "@/types/generated/i18n-keys";
@@ -41,7 +41,7 @@ const MODE_META: Record<
export const ClashModeCard = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { clashConfig, refreshClashConfig } = useClashConfig();
const { clashConfig, refreshClashConfig } = useAppData();
// 支持的模式列表
const modeList = CLASH_MODES;

View File

@@ -27,21 +27,22 @@ import {
useTheme,
} from "@mui/material";
import { useLockFn } from "ahooks";
import React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
import { EnhancedCard } from "@/components/home/enhanced-card";
import {
useClashConfig,
useProxiesData,
useRulesData,
} from "@/hooks/use-clash-data";
import { useProfiles } from "@/hooks/use-profiles";
import { useProxySelection } from "@/hooks/use-proxy-selection";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug";
@@ -50,8 +51,8 @@ const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
const AUTO_CHECK_INITIAL_DELAY_MS = 1500;
const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000;
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5;
const AUTO_CHECK_INITIAL_DELAY_MS = 100;
// 代理节点信息接口
interface ProxyOption {
@@ -105,13 +106,19 @@ export const CurrentProxyCard = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const { proxies, refreshProxy } = useProxiesData();
const { clashConfig } = useClashConfig();
const { rules } = useRulesData();
const { proxies, clashConfig, refreshProxy, rules } = useAppData();
const { verge } = useVerge();
const { current: currentProfile } = useProfiles();
const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false;
const defaultLatencyTimeout = verge?.default_latency_timeout;
const autoDelayIntervalMs = useMemo(() => {
const rawInterval = verge?.auto_delay_detection_interval_minutes;
const intervalMinutes =
typeof rawInterval === "number" && rawInterval > 0
? rawInterval
: AUTO_CHECK_DEFAULT_INTERVAL_MINUTES;
return Math.max(1, Math.round(intervalMinutes)) * 60 * 1000;
}, [verge?.auto_delay_detection_interval_minutes]);
const currentProfileId = currentProfile?.uid || null;
const getProfileStorageKey = useCallback(
@@ -598,13 +605,13 @@ export const CurrentProxyCard = () => {
if (disposed) return;
await checkCurrentProxyDelay();
if (disposed) return;
intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS);
intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs);
};
initialTimer = setTimeout(async () => {
await checkCurrentProxyDelay();
if (disposed) return;
intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS);
intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs);
}, AUTO_CHECK_INITIAL_DELAY_MS);
return () => {
@@ -614,6 +621,7 @@ export const CurrentProxyCard = () => {
};
}, [
checkCurrentProxyDelay,
autoDelayIntervalMs,
isDirectMode,
state.selection.group,
state.selection.proxy,

View File

@@ -24,7 +24,7 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useRefreshAll } from "@/hooks/use-clash-data";
import { useAppData } from "@/providers/app-data-context";
import { openWebUrl, updateProfile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import parseTraffic from "@/utils/parse-traffic";
@@ -281,7 +281,7 @@ export const HomeProfileCard = ({
}: HomeProfileCardProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const refreshAll = useRefreshAll();
const { refreshAll } = useAppData();
// 更新当前订阅
const [updating, setUpdating] = useState(false);

View File

@@ -8,6 +8,7 @@ import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppData } from "@/providers/app-data-context";
import { getIpInfo } from "@/services/api";
import { EnhancedCard } from "./enhanced-card";
@@ -55,6 +56,7 @@ const getCountryFlag = (countryCode: string) => {
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation();
const { clashConfig } = useAppData();
const [ipInfo, setIpInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -90,6 +92,20 @@ export const IpInfoCard = () => {
console.warn("Failed to read IP info from sessionStorage:", e);
}
if (typeof navigator !== "undefined" && !navigator.onLine) {
setLoading(false);
lastFetchRef.current = Date.now();
setCountdown(IP_REFRESH_SECONDS);
return;
}
if (!clashConfig) {
setLoading(false);
lastFetchRef.current = Date.now();
setCountdown(IP_REFRESH_SECONDS);
return;
}
try {
setLoading(true);
const data = await getIpInfo();
@@ -113,11 +129,13 @@ export const IpInfoCard = () => {
? err.message
: t("home.components.ipInfo.errors.load"),
);
lastFetchRef.current = Date.now();
setCountdown(IP_REFRESH_SECONDS);
} finally {
setLoading(false);
}
},
[t],
[t, clashConfig],
);
// 组件加载时获取IP信息并启动基于上次请求时间的倒计时

View File

@@ -10,14 +10,13 @@ import { useLockFn } from "ahooks";
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import useSWR from "swr";
import { useServiceInstaller } from "@/hooks/use-service-installer";
import { useSystemState } from "@/hooks/use-system-state";
import { useUpdate } from "@/hooks/use-update";
import { useVerge } from "@/hooks/use-verge";
import { getSystemInfo } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { checkUpdateSafe as checkUpdate } from "@/services/update";
import { version as appVersion } from "@root/package.json";
import { EnhancedCard } from "./enhanced-card";
@@ -52,6 +51,18 @@ export const SystemInfoCard = () => {
const { isAdminMode, isSidecarMode } = useSystemState();
const { installServiceAndRestartCore } = useServiceInstaller();
// 自动检查更新逻辑
const { checkUpdate: triggerCheckUpdate } = useUpdate(true, {
onSuccess: () => {
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
dispatchSystemState({
type: "set-last-check-update",
payload: new Date(now).toLocaleString(),
});
},
});
// 系统信息状态
const [systemState, dispatchSystemState] = useReducer(systemStateReducer, {
osInfo: "",
@@ -109,7 +120,7 @@ export const SystemInfoCard = () => {
timeoutId = window.setTimeout(() => {
if (verge?.auto_check_update) {
checkUpdate().catch(console.error);
triggerCheckUpdate().catch(console.error);
}
}, 5000);
}
@@ -118,26 +129,7 @@ export const SystemInfoCard = () => {
window.clearTimeout(timeoutId);
}
};
}, [verge?.auto_check_update, dispatchSystemState]);
// 自动检查更新逻辑
useSWR(
verge?.auto_check_update ? "checkUpdate" : null,
async () => {
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
dispatchSystemState({
type: "set-last-check-update",
payload: new Date(now).toLocaleString(),
});
return await checkUpdate();
},
{
revalidateOnFocus: false,
refreshInterval: 24 * 60 * 60 * 1000, // 每天检查一次
dedupingInterval: 60 * 60 * 1000, // 1小时内不重复检查
},
);
}, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]);
// 导航到设置页面
const goToSettings = useCallback(() => {
@@ -164,7 +156,7 @@ export const SystemInfoCard = () => {
// 检查更新
const onCheckUpdate = useLockFn(async () => {
try {
const info = await checkUpdate();
const info = await triggerCheckUpdate();
if (!info?.available) {
showNotice.success(
"settings.components.verge.advanced.notifications.latestVersion",

View File

@@ -6,13 +6,14 @@ import {
Box,
type SnackbarOrigin,
} from "@mui/material";
import React, { useMemo, useSyncExternalStore } from "react";
import React, { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
subscribeNotices,
hideNotice,
getSnapshotNotices,
showNotice,
} from "@/services/notice-service";
import type { TranslationKey } from "@/types/generated/i18n-keys";
@@ -85,6 +86,45 @@ const resolveNoticeMessage = (
});
};
const extractNoticeCopyText = (input: unknown): string | undefined => {
if (input === null || input === undefined) return undefined;
if (typeof input === "string") return input;
if (typeof input === "number" || typeof input === "boolean") {
return String(input);
}
if (input instanceof Error) {
return input.message || input.name;
}
if (React.isValidElement(input)) return undefined;
if (typeof input === "object") {
const maybeMessage = (input as { message?: unknown }).message;
if (typeof maybeMessage === "string") return maybeMessage;
}
try {
return JSON.stringify(input);
} catch {
return String(input);
}
};
const resolveNoticeCopyText = (
notice: NoticeItem,
t: TranslationFn,
): string | undefined => {
if (
notice.i18n?.key === "shared.feedback.notices.prefixedRaw" ||
notice.i18n?.key === "shared.feedback.notices.raw"
) {
const rawText = extractNoticeCopyText(notice.i18n?.params?.message);
if (rawText) return rawText;
}
return (
extractNoticeCopyText(resolveNoticeMessage(notice, t)) ??
extractNoticeCopyText(notice.message)
);
};
interface NoticeManagerProps {
position?: NoticePosition | null;
}
@@ -105,6 +145,23 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
hideNotice(id);
};
const handleNoticeCopy = useCallback(
async (notice: NoticeItem) => {
const text = resolveNoticeCopyText(notice, t);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
showNotice.success(
"shared.feedback.notifications.common.copySuccess",
1000,
);
} catch (error) {
console.warn("[NoticeManager] copy to clipboard failed:", error);
}
},
[t],
);
return (
<Box
sx={{
@@ -139,6 +196,11 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
severity={notice.type}
variant="filled"
sx={{ width: "100%" }}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
void handleNoticeCopy(notice);
}}
action={
<IconButton
size="small"

View File

@@ -1,11 +1,9 @@
import { Button } from "@mui/material";
import { useRef } from "react";
import useSWR from "swr";
import { useVerge } from "@/hooks/use-verge";
import { checkUpdateSafe } from "@/services/update";
import { DialogRef } from "@/components/base";
import { useUpdate } from "@/hooks/use-update";
import { DialogRef } from "../base";
import { UpdateViewer } from "../setting/mods/update-viewer";
interface Props {
@@ -14,20 +12,9 @@ interface Props {
export const UpdateButton = (props: Props) => {
const { className } = props;
const { verge } = useVerge();
const { auto_check_update } = verge || {};
const viewerRef = useRef<DialogRef>(null);
const { data: updateInfo } = useSWR(
auto_check_update || auto_check_update === null ? "checkUpdate" : null,
checkUpdateSafe,
{
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
},
);
const { updateInfo } = useUpdate();
if (!updateInfo?.available) return null;

View File

@@ -1,7 +1,7 @@
import { styled, Box } from "@mui/material";
import type { ReactNode } from "react";
import { SearchState } from "@/components/base/base-search-box";
import type { SearchState } from "@/components/base";
const Item = styled(Box)(({ theme: { palette, typography } }) => ({
padding: "8px 0",

View File

@@ -48,7 +48,7 @@ import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import { Switch } from "@/components/base";
import { BaseSearchBox, Switch } from "@/components/base";
import { GroupItem } from "@/components/profile/group-item";
import {
getNetworkInterfaces,
@@ -60,8 +60,6 @@ import { useThemeMode } from "@/services/states";
import type { TranslationKey } from "@/types/generated/i18n-keys";
import getSystem from "@/utils/get-system";
import { BaseSearchBox } from "../base/base-search-box";
interface Props {
proxiesUid: string;
mergeUid: string;

View File

@@ -40,6 +40,7 @@ import {
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import { BaseSearchBox } from "@/components/base";
import { ProxyItem } from "@/components/profile/proxy-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
@@ -47,8 +48,6 @@ import { useThemeMode } from "@/services/states";
import getSystem from "@/utils/get-system";
import parseUri from "@/utils/uri-parser";
import { BaseSearchBox } from "../base/base-search-box";
interface Props {
profileUid: string;
property: string;

View File

@@ -42,7 +42,7 @@ import {
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import { Switch } from "@/components/base";
import { BaseSearchBox, Switch } from "@/components/base";
import { RuleItem } from "@/components/profile/rule-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
@@ -50,8 +50,6 @@ import { useThemeMode } from "@/services/states";
import type { TranslationKey } from "@/types/generated/i18n-keys";
import getSystem from "@/utils/get-system";
import { BaseSearchBox } from "../base/base-search-box";
interface Props {
groupsUid: string;
mergeUid: string;

View File

@@ -22,7 +22,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateProxyProvider } from "tauri-plugin-mihomo-api";
import { useProxiesData, useProxyProvidersData } from "@/hooks/use-clash-data";
import { useAppData } from "@/providers/app-data-context";
import { showNotice } from "@/services/notice-service";
import parseTraffic from "@/utils/parse-traffic";
@@ -48,8 +48,7 @@ const parseExpire = (expire?: number) => {
export const ProviderButton = () => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { proxyProviders, refreshProxyProviders } = useProxyProvidersData();
const { refreshProxy } = useProxiesData();
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
// 检查是否有提供者
@@ -176,8 +175,8 @@ export const ProviderButton = () => {
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(proxyProviders || {})
.sort()
.map(([key, provider]) => {
if (!provider) return null;
.map(([key, item]) => {
const provider = item;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];

View File

@@ -38,7 +38,7 @@ import {
selectNodeForGroup,
} from "tauri-plugin-mihomo-api";
import { useProxiesData } from "@/hooks/use-clash-data";
import { useAppData } from "@/providers/app-data-context";
import { updateProxyChainConfigInRuntime } from "@/services/cmds";
import { debugLog } from "@/utils/debug";
@@ -199,7 +199,7 @@ export const ProxyChain = ({
}: ProxyChainProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { proxies, refreshProxy } = useProxiesData();
const { proxies, refreshProxy } = useAppData();
const [isConnecting, setIsConnecting] = useState(false);
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.();
@@ -221,7 +221,7 @@ export const ProxyChain = ({
}
const proxyChainGroup = proxies.groups.find(
(group) => group.name === selectedGroup,
(group: { name: string }) => group.name === selectedGroup,
);
return proxyChainGroup?.now === lastNode.name;

View File

@@ -15,14 +15,14 @@ import { useTranslation } from "react-i18next";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
import { useProxiesData } from "@/hooks/use-clash-data";
import { BaseEmpty } from "@/components/base";
import { useProxySelection } from "@/hooks/use-proxy-selection";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import { updateProxyChainConfigInRuntime } from "@/services/cmds";
import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug";
import { BaseEmpty } from "../base";
import { ScrollTopButton } from "../layout/scroll-top-button";
import { ProxyChain } from "./proxy-chain";
@@ -80,7 +80,7 @@ export const ProxyGroups = (props: Props) => {
}>({ open: false, message: "" });
const { verge } = useVerge();
const { proxies: proxiesData } = useProxiesData();
const { proxies: proxiesData } = useAppData();
const groups = proxiesData?.groups;
const availableGroups = useMemo(() => {
if (!groups) return [];

View File

@@ -15,7 +15,7 @@ import { Box, IconButton, TextField, SxProps } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { BaseSearchBox } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug";

View File

@@ -1,9 +1,8 @@
import { useEffect, useMemo } from "react";
import useSWR from "swr";
import { useProxiesData } from "@/hooks/use-clash-data";
import { useRuntimeConfig } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { getRuntimeConfig } from "@/services/cmds";
import { useAppData } from "@/providers/app-data-context";
import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug";
@@ -34,8 +33,24 @@ interface IProxyItem {
}
// 代理组类型
type ProxyGroup = IProxyGroupItem & {
now?: string;
type ProxyGroup = {
name: string;
type: string;
udp: boolean;
xudp: boolean;
tfo: boolean;
mptcp: boolean;
smux: boolean;
history: {
time: string;
delay: number;
}[];
now: string;
all: IProxyItem[];
hidden?: boolean;
icon?: string;
testUrl?: string;
provider?: string;
};
export interface IRenderItem {
@@ -84,21 +99,14 @@ export const useRenderList = (
selectedGroup?: string | null,
) => {
// 使用全局数据提供者
const { proxies: proxiesData, refreshProxy } = useProxiesData();
const { proxies: proxiesData, refreshProxy } = useAppData();
const { verge } = useVerge();
const { width } = useWindowWidth();
const [headStates, setHeadState] = useHeadStateNew();
const latencyTimeout = verge?.default_latency_timeout;
// 获取运行时配置用于链式代理模式
const { data: runtimeConfig } = useSWR(
isChainMode ? "getRuntimeConfig" : null,
getRuntimeConfig,
{
revalidateOnFocus: false,
revalidateIfStale: true,
},
);
const { data: runtimeConfig } = useRuntimeConfig(!!isChainMode);
// 计算列数
const col = useMemo(

View File

@@ -21,10 +21,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateRuleProvider } from "tauri-plugin-mihomo-api";
import type {
useRuleProvidersData,
useRulesData,
} from "@/hooks/use-clash-data";
import { useAppData } from "@/providers/app-data-context";
import { showNotice } from "@/services/notice-service";
// 辅助组件 - 类型框
@@ -40,22 +37,10 @@ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
lineHeight: 1.25,
}));
type RuleProvidersHook = ReturnType<typeof useRuleProvidersData>;
type RulesHook = ReturnType<typeof useRulesData>;
interface ProviderButtonProps {
ruleProviders: RuleProvidersHook["ruleProviders"];
refreshRuleProviders: RuleProvidersHook["refreshRuleProviders"];
refreshRules: RulesHook["refreshRules"];
}
export const ProviderButton = ({
ruleProviders,
refreshRuleProviders,
refreshRules,
}: ProviderButtonProps) => {
export const ProviderButton = () => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { ruleProviders, refreshRules, refreshRuleProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
// 检查是否有提供者
@@ -178,8 +163,8 @@ export const ProviderButton = ({
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(ruleProviders || {})
.sort()
.map(([key, provider]) => {
if (!provider) return null;
.map(([key, item]) => {
const provider = item;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];

View File

@@ -16,11 +16,16 @@ import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge";
import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import {
buildWebdavSignature,
getWebdavStatus,
setWebdavStatus,
} from "@/services/webdav-status";
import { isValidUrl } from "@/utils/helper";
interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;
onSaveSuccess: () => Promise<void>;
onSaveSuccess: (signature?: string) => Promise<void>;
onRefresh: () => Promise<void>;
onInit: () => Promise<void>;
setLoading: (loading: boolean) => void;
@@ -35,7 +40,7 @@ export const BackupConfigViewer = memo(
setLoading,
}: BackupConfigViewerProps) => {
const { t } = useTranslation();
const { verge } = useVerge();
const { verge, mutateVerge } = useVerge();
const { webdav_url, webdav_username, webdav_password } = verge || {};
const [showPassword, setShowPassword] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null);
@@ -58,6 +63,10 @@ export const BackupConfigViewer = memo(
webdav_username !== username ||
webdav_password !== password;
const webdavSignature = buildWebdavSignature(verge);
const webdavStatus = getWebdavStatus(webdavSignature);
const shouldAutoInit = webdavStatus !== "failed";
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev);
};
@@ -66,8 +75,11 @@ export const BackupConfigViewer = memo(
if (!webdav_url || !webdav_username || !webdav_password) {
return;
}
if (!shouldAutoInit) {
return;
}
void onInit();
}, [webdav_url, webdav_username, webdav_password, onInit]);
}, [webdav_url, webdav_username, webdav_password, onInit, shouldAutoInit]);
const checkForm = () => {
const username = usernameRef.current?.value;
@@ -97,18 +109,32 @@ export const BackupConfigViewer = memo(
const save = useLockFn(async (data: IWebDavConfig) => {
checkForm();
const signature = buildWebdavSignature({
webdav_url: data.url,
webdav_username: data.username,
webdav_password: data.password,
});
const trimmedUrl = data.url.trim();
const trimmedUsername = data.username.trim();
try {
setLoading(true);
await saveWebdavConfig(
data.url.trim(),
data.username.trim(),
data.password,
).then(() => {
showNotice.success(
"settings.modals.backup.messages.webdavConfigSaved",
);
onSaveSuccess();
});
await saveWebdavConfig(trimmedUrl, trimmedUsername, data.password);
await mutateVerge(
(current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
false,
);
setWebdavStatus(signature, "unknown");
showNotice.success("settings.modals.backup.messages.webdavConfigSaved");
await onSaveSuccess(signature);
} catch (error) {
showNotice.error(
"settings.modals.backup.messages.webdavConfigSaveFailed",
@@ -122,16 +148,24 @@ export const BackupConfigViewer = memo(
const handleBackup = useLockFn(async () => {
checkForm();
const signature = buildWebdavSignature({
webdav_url: url,
webdav_username: username,
webdav_password: password,
});
try {
setLoading(true);
await createWebdavBackup().then(async () => {
showNotice.success("settings.modals.backup.messages.backupCreated");
await onBackupSuccess();
});
setWebdavStatus(signature, "ready");
} catch (error) {
showNotice.error("settings.modals.backup.messages.backupFailed", {
error,
});
setWebdavStatus(signature, "failed");
} finally {
setLoading(false);
}

View File

@@ -36,6 +36,11 @@ import {
restoreWebDavBackup,
} from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import {
buildWebdavSignature,
getWebdavStatus,
setWebdavStatus,
} from "@/services/webdav-status";
dayjs.extend(customParseFormat);
dayjs.extend(relativeTime);
@@ -79,14 +84,17 @@ export const BackupHistoryViewer = ({
const { verge } = useVerge();
const [rows, setRows] = useState<BackupRow[]>([]);
const [loading, setLoading] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const isLocal = source === "local";
const isWebDavConfigured = Boolean(
verge?.webdav_url && verge?.webdav_username && verge?.webdav_password,
);
const webdavSignature = buildWebdavSignature(verge);
const webdavStatus = getWebdavStatus(webdavSignature);
const shouldSkipWebDav = !isLocal && !isWebDavConfigured;
const pageSize = 8;
const isBusy = loading || isRestarting;
const isBusy = loading || isRestoring || isRestarting;
const buildRow = useCallback(
(item: ILocalBackupFile | IWebDavFile): BackupRow | null => {
@@ -127,33 +135,49 @@ export const BackupHistoryViewer = ({
[t],
);
const fetchRows = useCallback(async () => {
if (!open) return;
if (shouldSkipWebDav) {
setRows([]);
return;
}
setLoading(true);
try {
const list = isLocal ? await listLocalBackup() : await listWebDavBackup();
setRows(
list
.map((item) => buildRow(item))
.filter((item): item is BackupRow => item !== null)
.sort((a, b) =>
a.sort_value === b.sort_value
? b.filename.localeCompare(a.filename)
: b.sort_value - a.sort_value,
),
);
} catch (error) {
console.error(error);
setRows([]);
showNotice.error(error);
} finally {
setLoading(false);
}
}, [buildRow, isLocal, open, shouldSkipWebDav]);
const fetchRows = useCallback(
async (options?: { force?: boolean }) => {
if (!open) return;
if (shouldSkipWebDav) {
setRows([]);
return;
}
if (!isLocal && webdavStatus === "failed" && !options?.force) {
setRows([]);
return;
}
setLoading(true);
try {
const list = isLocal
? await listLocalBackup()
: await listWebDavBackup();
if (!isLocal) {
setWebdavStatus(webdavSignature, "ready");
}
setRows(
list
.map((item) => buildRow(item))
.filter((item): item is BackupRow => item !== null)
.sort((a, b) =>
a.sort_value === b.sort_value
? b.filename.localeCompare(a.filename)
: b.sort_value - a.sort_value,
),
);
} catch (error) {
if (!isLocal) {
setWebdavStatus(webdavSignature, "failed");
}
console.error(error);
setRows([]);
showNotice.error(error);
} finally {
setLoading(false);
}
},
[buildRow, isLocal, open, shouldSkipWebDav, webdavSignature, webdavStatus],
);
useEffect(() => {
void fetchRows();
@@ -168,7 +192,7 @@ export const BackupHistoryViewer = ({
);
const summary = useMemo(() => {
if (shouldSkipWebDav) {
if (shouldSkipWebDav || (!isLocal && webdavStatus === "failed")) {
return t("settings.modals.backup.manual.webdav");
}
if (!total) return t("settings.modals.backup.history.empty");
@@ -178,7 +202,7 @@ export const BackupHistoryViewer = ({
count: total,
recent,
});
}, [rows, shouldSkipWebDav, t, total]);
}, [isLocal, rows, shouldSkipWebDav, t, total, webdavStatus]);
const handleDelete = useLockFn(async (filename: string) => {
if (isRestarting) return;
@@ -195,24 +219,32 @@ export const BackupHistoryViewer = ({
});
const handleRestore = useLockFn(async (filename: string) => {
if (isRestarting) return;
if (isRestoring || isRestarting) return;
if (
!(await confirmAsync(t("settings.modals.backup.messages.confirmRestore")))
)
return;
if (isLocal) {
await restoreLocalBackup(filename);
} else {
await restoreWebDavBackup(filename);
setIsRestoring(true);
try {
if (isLocal) {
await restoreLocalBackup(filename);
} else {
await restoreWebDavBackup(filename);
}
showNotice.success("settings.modals.backup.messages.restoreSuccess");
setIsRestarting(true);
window.setTimeout(() => {
void restartApp().catch((err: unknown) => {
setIsRestarting(false);
showNotice.error(err);
});
}, 1000);
} catch (error) {
console.error(error);
showNotice.error(error);
} finally {
setIsRestoring(false);
}
showNotice.success("settings.modals.backup.messages.restoreSuccess");
setIsRestarting(true);
window.setTimeout(() => {
void restartApp().catch((err: unknown) => {
setIsRestarting(false);
showNotice.error(err);
});
}, 1000);
});
const handleExport = useLockFn(async (filename: string) => {
@@ -232,7 +264,7 @@ export const BackupHistoryViewer = ({
const handleRefresh = () => {
if (isRestarting) return;
void fetchRows();
void fetchRows({ force: true });
};
return (

View File

@@ -14,12 +14,17 @@ import { useCallback, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import {
createLocalBackup,
createWebdavBackup,
importLocalBackup,
} from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import {
buildWebdavSignature,
setWebdavStatus,
} from "@/services/webdav-status";
import { AutoBackupSettings } from "./auto-backup-settings";
import { BackupHistoryViewer } from "./backup-history-viewer";
@@ -29,6 +34,7 @@ type BackupSource = "local" | "webdav";
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
const { verge } = useVerge();
const [open, setOpen] = useState(false);
const [busyAction, setBusyAction] = useState<BackupSource | null>(null);
const [localImporting, setLocalImporting] = useState(false);
@@ -36,6 +42,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [historySource, setHistorySource] = useState<BackupSource>("local");
const [historyPage, setHistoryPage] = useState(0);
const [webdavDialogOpen, setWebdavDialogOpen] = useState(false);
const webdavSignature = buildWebdavSignature(verge);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
@@ -59,6 +66,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
} else {
await createWebdavBackup();
showNotice.success("settings.modals.backup.messages.backupCreated");
setWebdavStatus(webdavSignature, "ready");
}
} catch (error) {
console.error(error);
@@ -68,6 +76,9 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
: "settings.modals.backup.messages.backupFailed",
target === "local" ? undefined : { error },
);
if (target === "webdav") {
setWebdavStatus(webdavSignature, "failed");
}
} finally {
setBusyAction(null);
}

View File

@@ -3,8 +3,13 @@ import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, BaseLoadingOverlay } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import { listWebDavBackup } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import {
buildWebdavSignature,
setWebdavStatus,
} from "@/services/webdav-status";
import { BackupConfigViewer } from "./backup-config-viewer";
@@ -22,7 +27,9 @@ export const BackupWebdavDialog = ({
setBusy,
}: BackupWebdavDialogProps) => {
const { t } = useTranslation();
const { verge } = useVerge();
const [loading, setLoading] = useState(false);
const webdavSignature = buildWebdavSignature(verge);
const handleLoading = useCallback(
(value: boolean) => {
@@ -33,16 +40,19 @@ export const BackupWebdavDialog = ({
);
const refreshWebdav = useCallback(
async (options?: { silent?: boolean }) => {
async (options?: { silent?: boolean; signature?: string }) => {
const signature = options?.signature ?? webdavSignature;
handleLoading(true);
try {
await listWebDavBackup();
setWebdavStatus(signature, "ready");
if (!options?.silent) {
showNotice.success(
"settings.modals.backup.messages.webdavRefreshSuccess",
);
}
} catch (error) {
setWebdavStatus(signature, "failed");
showNotice.error(
"settings.modals.backup.messages.webdavRefreshFailed",
{ error },
@@ -51,11 +61,11 @@ export const BackupWebdavDialog = ({
handleLoading(false);
}
},
[handleLoading],
[handleLoading, webdavSignature],
);
const refreshSilently = useCallback(
() => refreshWebdav({ silent: true }),
(signature?: string) => refreshWebdav({ silent: true, signature }),
[refreshWebdav],
);

View File

@@ -112,6 +112,7 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
close: () => setOpen(false),
}));
// TODO 减少代码复杂度,性能开支
const onSave = useLockFn(async () => {
// 端口冲突检测
const portList = [
@@ -140,14 +141,26 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
return;
}
for (const port of portList) {
const original = originalPortsRef.current;
const changedPorts: number[] = [];
if (mixedPort !== original?.mixedPort) changedPorts.push(mixedPort);
if (socksEnabled && socksPort !== original?.socksPort)
changedPorts.push(socksPort);
if (httpEnabled && httpPort !== original?.httpPort)
changedPorts.push(httpPort);
if (redirEnabled && redirPort !== original?.redirPort)
changedPorts.push(redirPort);
if (tproxyEnabled && tproxyPort !== original?.tproxyPort)
changedPorts.push(tproxyPort);
for (const port of changedPorts) {
try {
const inUse = await isPortInUse(port);
if (inUse) {
showNotice.error("settings.modals.clashPort.messages.portInUse", {
port,
});
const original = originalPortsRef.current;
if (original) {
setMixedPort(original.mixedPort);
setSocksPort(original.socksPort);
@@ -201,7 +214,7 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
};
// 提交保存请求
await saveSettings({ clashConfig, vergeConfig });
saveSettings({ clashConfig, vergeConfig });
});
return (

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { BaseDialog, Switch } from "@/components/base";
import { useClash } from "@/hooks/use-clash";
import { restartCore } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
// 定义开发环境的URL列表
@@ -134,6 +135,7 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
),
},
});
await restartCore();
await mutateClash();
},
{

View File

@@ -17,8 +17,7 @@ import { exists } from "@tauri-apps/plugin-fs";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { BaseDialog, DialogRef, Switch, TooltipIcon } from "@/components/base";
import { DEFAULT_HOVER_DELAY } from "@/components/proxy/proxy-group-navigator";
import { useVerge } from "@/hooks/use-verge";
import { useWindowDecorations } from "@/hooks/use-window";

View File

@@ -11,8 +11,7 @@ import type { Ref } from "react";
import { useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { BaseDialog, DialogRef, Switch, TooltipIcon } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import { entry_lightweight_mode } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";

View File

@@ -11,8 +11,7 @@ import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { BaseDialog, DialogRef, Switch, TooltipIcon } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import { showNotice } from "@/services/notice-service";
@@ -30,6 +29,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
enableBuiltinEnhanced: true,
proxyLayoutColumn: 6,
enableAutoDelayDetection: false,
autoDelayDetectionIntervalMinutes: 5,
defaultLatencyTest: "",
autoLogClean: 2,
defaultLatencyTimeout: 10000,
@@ -47,6 +47,8 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
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,
@@ -66,6 +68,8 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
enable_builtin_enhanced: values.enableBuiltinEnhanced,
proxy_layout_column: values.proxyLayoutColumn,
enable_auto_delay_detection: values.enableAutoDelayDetection,
auto_delay_detection_interval_minutes:
values.autoDelayDetectionIntervalMinutes,
default_latency_test: values.defaultLatencyTest,
default_latency_timeout: values.defaultLatencyTimeout,
auto_log_clean: values.autoLogClean as any,
@@ -324,6 +328,44 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t(
"settings.modals.misc.fields.autoDelayDetectionInterval",
)}
sx={{ maxWidth: "fit-content" }}
/>
<TextField
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 160, marginLeft: "auto" }}
value={values.autoDelayDetectionIntervalMinutes}
disabled={!values.enableAutoDelayDetection}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
const intervalMinutes =
Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
setValues((v) => ({
...v,
autoDelayDetectionIntervalMinutes: intervalMinutes,
}));
}}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("shared.units.minutes")}
</InputAdornment>
),
},
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("settings.modals.misc.fields.defaultLatencyTest")}

View File

@@ -4,10 +4,9 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import type { Ref } from "react";
import { useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { BaseDialog, DialogRef } from "@/components/base";
import { getNetworkInterfacesInfo } from "@/services/cmds";
import { useNetworkInterfaces } from "@/hooks/use-network";
import { showNotice } from "@/services/notice-service";
export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) {
@@ -22,13 +21,7 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) {
close: () => setOpen(false),
}));
const { data: networkInterfaces } = useSWR(
"clash-verge-rev-internal://network-interfaces",
getNetworkInterfacesInfo,
{
fallbackData: [], // default data before fetch
},
);
const { networkInterfaces } = useNetworkInterfaces();
return (
<BaseDialog

View File

@@ -26,19 +26,15 @@ import { mutate } from "swr";
import {
BaseDialog,
BaseFieldset,
BaseSplitChipEditor,
DialogRef,
Switch,
TooltipIcon,
} from "@/components/base";
import { BaseFieldset } from "@/components/base/base-fieldset";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { EditorViewer } from "@/components/profile/editor-viewer";
import {
useClashConfig,
useSystemProxyAddress,
useSystemProxyData,
} from "@/hooks/use-clash-data";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import {
getAutotemProxy,
getNetworkInterfacesInfo,
@@ -110,9 +106,14 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { verge, patchVerge, mutateVerge } = useVerge();
const [hostOptions, setHostOptions] = useState<string[]>([]);
type SysProxy = Awaited<ReturnType<typeof getSystemProxy>>;
const [sysproxy, setSysproxy] = useState<SysProxy>();
type AutoProxy = Awaited<ReturnType<typeof getAutotemProxy>>;
const [autoproxy, setAutoproxy] = useState<AutoProxy>();
const { clashConfig } = useAppData();
const {
enable_system_proxy: enabled,
proxy_auto_config,
@@ -148,9 +149,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
return "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 { clashConfig } = useClashConfig();
const { sysproxy, refreshSysproxy } = useSystemProxyData();
const prevMixedPortRef = useRef(clashConfig?.mixedPort);
useEffect(() => {
@@ -183,10 +181,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
updateProxy();
}, [clashConfig?.mixedPort, value.pac]);
const systemProxyAddress = useSystemProxyAddress({
clashConfig,
sysproxy,
});
const { systemProxyAddress } = useAppData();
// 为当前状态计算系统代理地址
const getSystemProxyAddress = useMemo(() => {
@@ -236,7 +231,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? "127.0.0.1",
});
void refreshSysproxy();
getSystemProxy().then((p) => setSysproxy(p));
getAutotemProxy().then((p) => setAutoproxy(p));
fetchNetworkInterfaces();
},

View File

@@ -12,8 +12,13 @@ import type { Ref } from "react";
import { useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import {
BaseDialog,
BaseSplitChipEditor,
TooltipIcon,
DialogRef,
Switch,
} from "@/components/base";
import { useClash } from "@/hooks/use-clash";
import { enhanceProfiles } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
@@ -23,6 +28,12 @@ import { StackModeSwitch } from "./stack-mode-switch";
const OS = getSystem();
const splitRouteExcludeAddress = (value: string) =>
value
.split(/[,\n;\r]+/)
.map((item) => item.trim())
.filter(Boolean);
export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
@@ -33,6 +44,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
stack: "mixed",
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
routeExcludeAddress: "",
autoRedirect: false,
autoDetectInterface: true,
dnsHijack: ["any:53"],
@@ -51,6 +63,9 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
stack: clash?.tun.stack ?? "gvisor",
device: clash?.tun.device ?? (OS === "macos" ? "utun1024" : "Mihomo"),
autoRoute: nextAutoRoute,
routeExcludeAddress: (clash?.tun["route-exclude-address"] ?? []).join(
",",
),
autoRedirect: computedAutoRedirect,
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
dnsHijack: clash?.tun["dns-hijack"] ?? ["any:53"],
@@ -63,6 +78,9 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
const onSave = useLockFn(async () => {
try {
const routeExcludeAddress = splitRouteExcludeAddress(
values.routeExcludeAddress,
);
const tun: IConfigData["tun"] = {
stack: values.stack,
device:
@@ -72,6 +90,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
: "Mihomo"
: values.device,
"auto-route": values.autoRoute,
"route-exclude-address": routeExcludeAddress,
...(OS === "linux"
? {
"auto-redirect": values.autoRedirect,
@@ -90,13 +109,11 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
}),
false,
);
try {
await enhanceProfiles();
showNotice.success("settings.modals.tun.messages.applied");
} catch (err: any) {
showNotice.error(err);
}
setOpen(false);
showNotice.success("settings.modals.tun.messages.applied");
void enhanceProfiles().catch((err: any) => {
showNotice.error(err);
});
} catch (err: any) {
showNotice.error(err);
}
@@ -123,6 +140,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
: {}),
"auto-detect-interface": true,
"dns-hijack": ["any:53"],
"route-exclude-address": [],
"strict-route": false,
mtu: 1500,
};
@@ -130,6 +148,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
stack: "gvisor",
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
routeExcludeAddress: "",
autoRedirect: false,
autoDetectInterface: true,
dnsHijack: ["any:53"],
@@ -287,6 +306,26 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
/>
</ListItem>
<BaseSplitChipEditor
value={values.routeExcludeAddress}
placeholder="192.168.0.0/16"
ariaLabel={t("settings.modals.tun.fields.routeExcludeAddress")}
disabled={!values.autoRoute}
onChange={(nextValue) =>
setValues((v) => ({ ...v, routeExcludeAddress: nextValue }))
}
renderHeader={(modeToggle) => (
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("settings.modals.tun.fields.routeExcludeAddress")}
/>
{modeToggle ? (
<Box sx={{ marginLeft: "auto" }}>{modeToggle}</Box>
) : null}
</ListItem>
)}
/>
</List>
</BaseDialog>
);

View File

@@ -8,13 +8,12 @@ import { useImperativeHandle, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import useSWR from "swr";
import { BaseDialog, DialogRef } from "@/components/base";
import { useUpdate } from "@/hooks/use-update";
import { portableFlag } from "@/pages/_layout";
import { showNotice } from "@/services/notice-service";
import { useSetUpdateState, useUpdateState } from "@/services/states";
import { checkUpdateSafe as checkUpdate } from "@/services/update";
export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
@@ -23,11 +22,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const updateState = useUpdateState();
const setUpdateState = useSetUpdateState();
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
});
const { updateInfo } = useUpdate();
const [downloaded, setDownloaded] = useState(0);
const [total, setTotal] = useState(0);

View File

@@ -6,8 +6,7 @@ import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { updateGeo } from "tauri-plugin-mihomo-api";
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { DialogRef, Switch, TooltipIcon } from "@/components/base";
import { useClash } from "@/hooks/use-clash";
import { useClashLog } from "@/hooks/use-clash-log";
import { useVerge } from "@/hooks/use-verge";

View File

@@ -2,8 +2,7 @@ import React, { useRef } from "react";
import { useTranslation } from "react-i18next";
import { mutate } from "swr";
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { DialogRef, Switch, TooltipIcon } from "@/components/base";
import ProxyControlSwitches from "@/components/shared/proxy-control-switches";
import { useVerge } from "@/hooks/use-verge";

View File

@@ -3,8 +3,7 @@ import { Typography } from "@mui/material";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { DialogRef } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { DialogRef, TooltipIcon } from "@/components/base";
import {
exitApp,
exportDiagnosticInfo,

View File

@@ -4,8 +4,7 @@ import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { DialogRef } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { DialogRef, TooltipIcon } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import { navItems } from "@/pages/_routers";
import { copyClashEnv } from "@/services/cmds";

View File

@@ -11,8 +11,7 @@ import { useLockFn } from "ahooks";
import React, { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { DialogRef, Switch, TooltipIcon } from "@/components/base";
import { GuardState } from "@/components/setting/mods/guard-state";
import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer";
import { TunViewer } from "@/components/setting/mods/tun-viewer";

View File

@@ -19,157 +19,162 @@ export interface TestViewerRef {
}
// create or edit the test item
export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
const { verge, patchVerge } = useVerge();
const testList = verge?.test_list ?? [];
const { control, ...formIns } = useForm<IVergeTestItem>({
defaultValues: {
name: "",
icon: "",
url: "",
},
});
const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => {
const newList = testList.map((x) => {
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
export const TestViewer = forwardRef<TestViewerRef, Props>(
({ onChange }, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
const { verge, patchVerge } = useVerge();
const testList = verge?.test_list ?? [];
const { control, ...formIns } = useForm<IVergeTestItem>({
defaultValues: {
name: "",
icon: "",
url: "",
},
});
await patchVerge({ test_list: newList });
};
useImperativeHandle(ref, () => ({
create: () => {
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpenType("edit");
setOpen(true);
},
}));
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
setLoading(true);
try {
if (!form.name) throw new Error("`Name` should not be null");
if (!form.url) throw new Error("`Url` should not be null");
let newList;
let uid;
if (form.icon && form.icon.startsWith("<svg")) {
// 移除 icon 中的注释
if (form.icon) {
form.icon = form.icon.replace(/<!--[\s\S]*?-->/g, "");
}
const doc = new DOMParser().parseFromString(
form.icon,
"image/svg+xml",
);
if (doc.querySelector("parsererror")) {
throw new Error("`Icon`svg format error");
}
const patchTestList = async (
uid: string,
patch: Partial<IVergeTestItem>,
) => {
const newList = testList.map((x) => {
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
});
await patchVerge({ test_list: newList });
};
if (openType === "new") {
uid = nanoid();
const item = { ...form, uid };
newList = [...testList, item];
await patchVerge({ test_list: newList });
props.onChange(uid);
} else {
if (!form.uid) throw new Error("UID not found");
uid = form.uid;
await patchTestList(uid, form);
props.onChange(uid, form);
useImperativeHandle(ref, () => ({
create: () => {
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpen(false);
setLoading(false);
setTimeout(() => formIns.reset(), 500);
} catch (err: any) {
showNotice.error(err);
setLoading(false);
}
}),
);
setOpenType("edit");
setOpen(true);
},
}));
const handleClose = () => {
setOpen(false);
setTimeout(() => formIns.reset(), 500);
};
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
setLoading(true);
try {
if (!form.name) throw new Error("`Name` should not be null");
if (!form.url) throw new Error("`Url` should not be null");
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
let newList;
let uid;
return (
<BaseDialog
open={open}
title={
openType === "new"
? t("tests.modals.test.title.create")
: t("tests.modals.test.title.edit")
}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("shared.actions.save")}
cancelBtn={t("shared.actions.cancel")}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
loading={loading}
>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("shared.labels.name")} />
)}
/>
<Controller
name="icon"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={5}
label={t("shared.labels.icon")}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={3}
label={t("tests.modals.test.fields.url")}
/>
)}
/>
</BaseDialog>
);
});
if (form.icon && form.icon.startsWith("<svg")) {
// 移除 icon 中的注释
if (form.icon) {
form.icon = form.icon.replace(/<!--[\s\S]*?-->/g, "");
}
const doc = new DOMParser().parseFromString(
form.icon,
"image/svg+xml",
);
if (doc.querySelector("parsererror")) {
throw new Error("`Icon`svg format error");
}
}
if (openType === "new") {
uid = nanoid();
const item = { ...form, uid };
newList = [...testList, item];
await patchVerge({ test_list: newList });
onChange(uid);
} else {
if (!form.uid) throw new Error("UID not found");
uid = form.uid;
await patchTestList(uid, form);
onChange(uid, form);
}
setOpen(false);
setLoading(false);
setTimeout(() => formIns.reset(), 500);
} catch (err: any) {
showNotice.error(err);
setLoading(false);
}
}),
);
const handleClose = () => {
setOpen(false);
setTimeout(() => formIns.reset(), 500);
};
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
return (
<BaseDialog
open={open}
title={
openType === "new"
? t("tests.modals.test.title.create")
: t("tests.modals.test.title.edit")
}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("shared.actions.save")}
cancelBtn={t("shared.actions.cancel")}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
loading={loading}
>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("shared.labels.name")} />
)}
/>
<Controller
name="icon"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={5}
label={t("shared.labels.icon")}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={3}
label={t("tests.modals.test.fields.url")}
/>
)}
/>
</BaseDialog>
);
},
);

View File

@@ -1,206 +0,0 @@
import { useCallback, useMemo } from "react";
import useSWR, { useSWRConfig } from "swr";
import {
getBaseConfig,
getRuleProviders,
getRules,
} from "tauri-plugin-mihomo-api";
import {
calcuProxies,
calcuProxyProviders,
getAppUptime,
getSystemProxy,
} from "@/services/cmds";
import { SWR_DEFAULTS, SWR_REALTIME, SWR_SLOW_POLL } from "@/services/config";
import { useSharedSWRPoller } from "./use-shared-swr-poller";
import { useVerge } from "./use-verge";
export const useProxiesData = () => {
const { mutate: globalMutate } = useSWRConfig();
const { data, error, isLoading } = useSWR("getProxies", calcuProxies, {
...SWR_REALTIME,
refreshInterval: 0,
onError: (err) => console.warn("[AppData] Proxy fetch failed:", err),
});
const refreshProxy = useCallback(
() => globalMutate("getProxies"),
[globalMutate],
);
const pollerRefresh = useCallback(() => {
void globalMutate("getProxies");
}, [globalMutate]);
useSharedSWRPoller("getProxies", SWR_REALTIME.refreshInterval, pollerRefresh);
return {
proxies: data,
refreshProxy,
isLoading,
error,
};
};
export const useClashConfig = () => {
const { mutate: globalMutate } = useSWRConfig();
const { data, error, isLoading } = useSWR("getClashConfig", getBaseConfig, {
...SWR_SLOW_POLL,
refreshInterval: 0,
});
const refreshClashConfig = useCallback(
() => globalMutate("getClashConfig"),
[globalMutate],
);
const pollerRefresh = useCallback(() => {
void globalMutate("getClashConfig");
}, [globalMutate]);
useSharedSWRPoller(
"getClashConfig",
SWR_SLOW_POLL.refreshInterval,
pollerRefresh,
);
return {
clashConfig: data,
refreshClashConfig,
isLoading,
error,
};
};
export const useProxyProvidersData = () => {
const { data, error, isLoading, mutate } = useSWR(
"getProxyProviders",
calcuProxyProviders,
SWR_DEFAULTS,
);
const refreshProxyProviders = useCallback(() => mutate(), [mutate]);
return {
proxyProviders: data || {},
refreshProxyProviders,
isLoading,
error,
};
};
export const useRuleProvidersData = () => {
const { data, error, isLoading, mutate } = useSWR(
"getRuleProviders",
getRuleProviders,
SWR_DEFAULTS,
);
const refreshRuleProviders = useCallback(() => mutate(), [mutate]);
return {
ruleProviders: data?.providers || {},
refreshRuleProviders,
isLoading,
error,
};
};
export const useRulesData = () => {
const { data, error, isLoading, mutate } = useSWR(
"getRules",
getRules,
SWR_DEFAULTS,
);
const refreshRules = useCallback(() => mutate(), [mutate]);
return {
rules: data?.rules || [],
refreshRules,
isLoading,
error,
};
};
export const useSystemProxyData = () => {
const { data, error, isLoading, mutate } = useSWR(
"getSystemProxy",
getSystemProxy,
SWR_DEFAULTS,
);
const refreshSysproxy = useCallback(() => mutate(), [mutate]);
return {
sysproxy: data,
refreshSysproxy,
isLoading,
error,
};
};
type ClashConfig = Awaited<ReturnType<typeof getBaseConfig>>;
type SystemProxy = Awaited<ReturnType<typeof getSystemProxy>>;
interface SystemProxyAddressParams {
clashConfig?: ClashConfig | null;
sysproxy?: SystemProxy | null;
}
export const useSystemProxyAddress = ({
clashConfig,
sysproxy,
}: SystemProxyAddressParams) => {
const { verge } = useVerge();
return useMemo(() => {
if (!verge || !clashConfig) return "-";
const isPacMode = verge.proxy_auto_config ?? false;
if (isPacMode) {
const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort = verge.verge_mixed_port || clashConfig.mixedPort || 7897;
return [proxyHost, proxyPort].join(":");
}
const systemServer = sysproxy?.server;
if (systemServer && systemServer !== "-" && !systemServer.startsWith(":")) {
return systemServer;
}
const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort = verge.verge_mixed_port || clashConfig.mixedPort || 7897;
return [proxyHost, proxyPort].join(":");
}, [clashConfig, sysproxy, verge]);
};
export const useAppUptime = () => {
const { data, error, isLoading } = useSWR("appUptime", getAppUptime, {
...SWR_DEFAULTS,
refreshInterval: 3000,
errorRetryCount: 1,
});
return {
uptime: data || 0,
error,
isLoading,
};
};
export const useRefreshAll = () => {
const { mutate } = useSWRConfig();
return useCallback(async () => {
await Promise.all([
mutate("getProxies"),
mutate("getClashConfig"),
mutate("getRules"),
mutate("getSystemProxy"),
mutate("getProxyProviders"),
mutate("getRuleProviders"),
]);
}, [mutate]);
};

View File

@@ -51,11 +51,12 @@ const validatePorts = (patch: ClashInfoPatch) => {
});
};
export const useRuntimeConfig = (shouldFetch: boolean = true) => {
return useSWR(shouldFetch ? "getRuntimeConfig" : null, getRuntimeConfig);
};
export const useClash = () => {
const { data: clash, mutate: mutateClash } = useSWR(
"getRuntimeConfig",
getRuntimeConfig,
);
const { data: clash, mutate: mutateClash } = useRuntimeConfig();
const { data: versionData, mutate: mutateVersion } = useSWR(
"getVersion",

View File

@@ -0,0 +1,76 @@
import { useMemo } from "react";
import { useAppData } from "@/providers/app-data-context";
// 定义代理组类型
interface ProxyGroup {
name: string;
now: string;
}
// 获取当前代理节点信息的自定义Hook
export const useCurrentProxy = () => {
// 从AppDataProvider获取数据
const { proxies, clashConfig, refreshProxy } = useAppData();
// 获取当前模式
const currentMode = clashConfig?.mode?.toLowerCase() || "rule";
// 获取当前代理节点信息
const currentProxyInfo = useMemo(() => {
if (!proxies) return { currentProxy: null, primaryGroupName: null };
const { global, groups, records } = proxies;
// 默认信息
let primaryGroupName = "GLOBAL";
let currentName = global?.now;
// 在规则模式下,寻找主要代理组(通常是第一个或者名字包含特定关键词的组)
if (currentMode === "rule" && groups.length > 0) {
// 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组
const primaryKeywords = [
"auto",
"select",
"proxy",
"节点选择",
"自动选择",
];
const primaryGroup =
groups.find((group: ProxyGroup) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) || groups.filter((g: ProxyGroup) => g.name !== "GLOBAL")[0];
if (primaryGroup) {
primaryGroupName = primaryGroup.name;
currentName = primaryGroup.now;
}
}
// 如果找不到当前节点返回null
if (!currentName) return { currentProxy: null, primaryGroupName };
// 获取完整的节点信息
const currentProxy = records[currentName] || {
name: currentName,
type: "Unknown",
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
};
return { currentProxy, primaryGroupName };
}, [proxies, currentMode]);
return {
currentProxy: currentProxyInfo.currentProxy,
primaryGroupName: currentProxyInfo.primaryGroupName,
mode: currentMode,
refreshProxy,
};
};

View File

@@ -4,7 +4,7 @@ import { mutate, type MutatorCallback } from "swr";
import useSWRSubscription from "swr/subscription";
import { type Message, type MihomoWebSocket } from "tauri-plugin-mihomo-api";
export const RECONNECT_DELAY_MS = 500;
export const RECONNECT_DELAY_MS = 100;
type NextFn<T> = (error?: any, data?: T | MutatorCallback<T>) => void;

22
src/hooks/use-network.ts Normal file
View File

@@ -0,0 +1,22 @@
import useSWR from "swr";
import { getNetworkInterfacesInfo } from "@/services/cmds";
export const useNetworkInterfaces = () => {
const { data, error, isLoading, mutate } = useSWR(
"getNetworkInterfacesInfo",
getNetworkInterfacesInfo,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
fallbackData: [],
},
);
return {
networkInterfaces: data || [],
loading: isLoading,
error,
mutate,
};
};

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