mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a097009679 | ||
|
|
b146013984 | ||
|
|
09dcb072c3 | ||
|
|
c5233a9c2c | ||
|
|
f246ea4469 | ||
|
|
733f7a139d | ||
|
|
357a683f92 |
@@ -307,7 +307,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -377,7 +377,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -505,7 +505,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -196,7 +196,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Pnpm Cache
|
- name: Pnpm Cache
|
||||||
@@ -253,12 +253,10 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# It should be ubuntu-22.04 to match the cross-compilation environment
|
- os: ubuntu-24.04
|
||||||
# ortherwise it is hard to resolve the dependencies
|
|
||||||
- os: ubuntu-22.04
|
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
arch: arm64
|
arch: arm64
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-24.04
|
||||||
target: armv7-unknown-linux-gnueabihf
|
target: armv7-unknown-linux-gnueabihf
|
||||||
arch: armhf
|
arch: armhf
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -294,7 +292,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Pnpm Cache
|
- name: Pnpm Cache
|
||||||
@@ -313,35 +311,39 @@ jobs:
|
|||||||
- name: Release ${{ env.TAG_CHANNEL }} Version
|
- name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
run: pnpm release-version autobuild-latest
|
run: pnpm release-version autobuild-latest
|
||||||
|
|
||||||
- name: "Setup for linux"
|
- name: Setup for linux
|
||||||
run: |-
|
run: |
|
||||||
sudo ls -lR /etc/apt/
|
sudo dpkg --add-architecture ${{ matrix.arch }}
|
||||||
|
|
||||||
cat > /tmp/sources.list << EOF
|
sudo rm -f /etc/apt/sources.list.d/ubuntu.sources
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
|
sudo tee /etc/apt/sources.list << EOF
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
|
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble main restricted universe multiverse
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
|
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-security main restricted universe multiverse
|
||||||
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
|
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
|
||||||
|
|
||||||
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
|
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
|
||||||
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
|
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
|
||||||
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
|
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
|
||||||
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
|
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
|
sudo apt-get update -y
|
||||||
sudo mv /tmp/sources.list /etc/apt/sources.list
|
|
||||||
|
|
||||||
sudo dpkg --add-architecture ${{ matrix.arch }}
|
sudo apt-get install -y libglib2.0-dev-bin
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
sudo apt install -y \
|
sudo apt-get install -y \
|
||||||
libxslt1.1:${{ matrix.arch }} \
|
linux-libc-dev:${{ matrix.arch }} \
|
||||||
|
libc6-dev:${{ matrix.arch }} \
|
||||||
|
libicu-dev:${{ matrix.arch }}
|
||||||
|
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libxslt1-dev:${{ matrix.arch }} \
|
||||||
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
|
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
|
||||||
libayatana-appindicator3-dev:${{ matrix.arch }} \
|
libayatana-appindicator3-dev:${{ matrix.arch }} \
|
||||||
libssl-dev:${{ matrix.arch }} \
|
libssl-dev:${{ matrix.arch }} \
|
||||||
patchelf:${{ matrix.arch }} \
|
librsvg2-dev:${{ matrix.arch }} \
|
||||||
librsvg2-dev:${{ matrix.arch }}
|
patchelf
|
||||||
|
|
||||||
- name: Install aarch64 tools
|
- name: Install aarch64 tools
|
||||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
@@ -439,7 +441,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Pnpm Cache
|
- name: Pnpm Cache
|
||||||
@@ -541,7 +543,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4.2.0
|
- uses: pnpm/action-setup@v4.2.0
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Pnpm Cache
|
- name: Pnpm Cache
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
if: steps.check_frontend.outputs.frontend == 'true'
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Restore pnpm cache
|
- name: Restore pnpm cache
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -281,7 +281,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -420,7 +420,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -505,7 +505,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -531,7 +531,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -593,7 +593,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24.13.1"
|
node-version: "24.13.0"
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -13,4 +13,3 @@ scripts/_env.sh
|
|||||||
.eslintcache
|
.eslintcache
|
||||||
.changelog_backups
|
.changelog_backups
|
||||||
target
|
target
|
||||||
CLAUDE.md
|
|
||||||
|
|||||||
+42
-5
@@ -1,14 +1,51 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if ! command -v "cargo-make" >/dev/null 2>&1; then
|
ROOT_DIR="$(git rev-parse --show-toplevel)"
|
||||||
echo "❌ cargo-make is required for pre-commit checks."
|
cd "$ROOT_DIR"
|
||||||
cargo install --force cargo-make
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v pnpm >/dev/null 2>&1; then
|
if ! command -v pnpm >/dev/null 2>&1; then
|
||||||
echo "❌ pnpm is required for pre-commit checks."
|
echo "❌ pnpm is required for pre-commit checks."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cargo make pre-commit
|
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."
|
||||||
|
|||||||
+31
-4
@@ -1,9 +1,36 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if ! command -v "cargo-make" >/dev/null 2>&1; then
|
remote_name="${1:-origin}"
|
||||||
echo "❌ cargo-make is required for pre-push checks."
|
remote_url="${2:-unknown}"
|
||||||
cargo install --force cargo-make
|
|
||||||
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cargo make pre-push
|
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."
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
queue_rules:
|
|
||||||
- name: LetMeMergeForYou
|
|
||||||
batch_size: 3
|
|
||||||
allow_queue_branch_edit: true
|
|
||||||
queue_conditions: []
|
|
||||||
Generated
+759
-688
File diff suppressed because it is too large
Load Diff
+7
-12
@@ -5,8 +5,8 @@ members = [
|
|||||||
"crates/clash-verge-logging",
|
"crates/clash-verge-logging",
|
||||||
"crates/clash-verge-signal",
|
"crates/clash-verge-signal",
|
||||||
"crates/tauri-plugin-clash-verge-sysinfo",
|
"crates/tauri-plugin-clash-verge-sysinfo",
|
||||||
|
"crates/clash-verge-types",
|
||||||
"crates/clash-verge-i18n",
|
"crates/clash-verge-i18n",
|
||||||
"crates/clash-verge-limiter",
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -40,31 +40,26 @@ opt-level = 0
|
|||||||
debug = true
|
debug = true
|
||||||
strip = false
|
strip = false
|
||||||
|
|
||||||
[profile.debug-release]
|
|
||||||
inherits = "fast-release"
|
|
||||||
codegen-units = 1
|
|
||||||
split-debuginfo = "unpacked"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
||||||
clash-verge-logging = { path = "crates/clash-verge-logging" }
|
clash-verge-logging = { path = "crates/clash-verge-logging" }
|
||||||
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
||||||
|
clash-verge-types = { path = "crates/clash-verge-types" }
|
||||||
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
|
||||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||||
|
|
||||||
tauri = { version = "2.10.2" }
|
tauri = { version = "2.9.5" }
|
||||||
tauri-plugin-clipboard-manager = "2.3.2"
|
tauri-plugin-clipboard-manager = "2.3.2"
|
||||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.100"
|
||||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
criterion = { version = "0.8.1", features = ["async_tokio"] }
|
||||||
tokio = { version = "1.49.0", features = [
|
tokio = { version = "1.49.0", features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"macros",
|
"macros",
|
||||||
"time",
|
"time",
|
||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
flexi_logger = "0.31.8"
|
flexi_logger = "0.31.7"
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
|
|
||||||
smartstring = { version = "1.0.1" }
|
smartstring = { version = "1.0.1" }
|
||||||
@@ -73,7 +68,7 @@ compact_str = { version = "0.9.0", features = ["serde"] }
|
|||||||
serde = { version = "1.0.228" }
|
serde = { version = "1.0.228" }
|
||||||
serde_json = { version = "1.0.149" }
|
serde_json = { version = "1.0.149" }
|
||||||
serde_yaml_ng = { version = "0.10.0" }
|
serde_yaml_ng = { version = "0.10.0" }
|
||||||
bitflags = { version = "2.11.0" }
|
bitflags = { version = "2.10.0" }
|
||||||
|
|
||||||
# *** For Windows platform only ***
|
# *** For Windows platform only ***
|
||||||
deelevate = "0.2.0"
|
deelevate = "0.2.0"
|
||||||
|
|||||||
+35
-5
@@ -1,19 +1,49 @@
|
|||||||
## v2.4.7
|
## v2.4.5
|
||||||
|
|
||||||
|
- **Mihomo(Meta) 内核升级至 v1.19.18**
|
||||||
|
|
||||||
### 🐞 修复问题
|
### 🐞 修复问题
|
||||||
|
|
||||||
- 修复 Windows 管理员身份运行时开关 TUN 模式异常
|
- 修复 macOS 有线网络 DNS 劫持失败
|
||||||
|
- 修复 Monaco 编辑器内右键菜单显示异常
|
||||||
|
- 修复设置代理端口时检查端口占用
|
||||||
|
- 修复 Monaco 编辑器初始化卡 Loading
|
||||||
|
- 修复恢复备份时 `config.yaml` / `profiles.yaml` 文件内字段未正确恢复
|
||||||
|
- 修复 Windows 下系统主题同步问题
|
||||||
|
- 修复 URL Schemes 无法正常导入
|
||||||
|
- 修复 Linux 下无法安装 TUN 服务
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||||
|
|
||||||
|
- 允许代理页面允许高级过滤搜索
|
||||||
|
- 备份设置页面新增导入备份按钮
|
||||||
|
- 允许修改通知弹窗位置
|
||||||
|
- 支持收起导航栏(导航栏右键菜单 / 界面设置)
|
||||||
|
- 允许将出站模式显示在托盘一级菜单
|
||||||
|
- 允许禁用在托盘中显示代理组
|
||||||
|
- 支持在「编辑节点」中直接导入 AnyTLS URI 配置
|
||||||
|
- 支持关闭「验证代理绕过格式」
|
||||||
|
- 新增系统代理绕过和 TUN 排除自定义网段的可视化编辑器
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<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>
|
</details>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
[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"]
|
|
||||||
@@ -66,6 +66,19 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
|
|||||||
|
|
||||||
🌐 官网:👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
🌐 官网:👉 [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
|
## Features
|
||||||
|
|
||||||
- 基于性能强劲的 Rust 和 Tauri 2 框架
|
- 基于性能强劲的 Rust 和 Tauri 2 框架
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ type DraftInner<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
|
|||||||
|
|
||||||
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储,
|
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储,
|
||||||
// (committed_snapshot, optional_draft_snapshot)
|
// (committed_snapshot, optional_draft_snapshot)
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Draft<T> {
|
pub struct Draft<T: Clone> {
|
||||||
inner: Arc<RwLock<DraftInner<T>>>,
|
inner: Arc<RwLock<DraftInner<T>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +90,3 @@ impl<T: Clone> Draft<T> {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Clone> Clone for Draft<T> {
|
|
||||||
fn clone(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::clone(&self.inner),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: لوحة التحكم
|
title: Dashboard
|
||||||
body: تم تحديث حالة عرض لوحة التحكم.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: تبديل الوضع
|
title: Mode Switch
|
||||||
body: تم التبديل إلى {mode}.
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: وكيل النظام
|
title: System Proxy
|
||||||
body: تم تحديث حالة وكيل النظام.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: وضع TUN
|
title: TUN Mode
|
||||||
body: تم تحديث حالة وضع TUN.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: الوضع الخفيف
|
title: Lightweight Mode
|
||||||
body: تم الدخول إلى الوضع الخفيف.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: الملفات التعريفية
|
title: Profiles
|
||||||
body: تمت إعادة تفعيل الملف التعريفي.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: على وشك الخروج
|
title: About to Exit
|
||||||
body: Clash Verge على وشك الخروج.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: تم إخفاء التطبيق
|
title: Application Hidden
|
||||||
body: Clash Verge يعمل في الخلفية.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: لوحة التحكم
|
dashboard: Dashboard
|
||||||
ruleMode: وضع القواعد
|
ruleMode: Rule Mode
|
||||||
globalMode: الوضع العام
|
globalMode: Global Mode
|
||||||
directMode: الوضع المباشر
|
directMode: Direct Mode
|
||||||
outboundModes: أوضاع الخروج
|
outboundModes: Outbound Modes
|
||||||
rule: قاعدة
|
rule: Rule
|
||||||
direct: مباشر
|
direct: Direct
|
||||||
global: عام
|
global: Global
|
||||||
profiles: الملفات التعريفية
|
profiles: Profiles
|
||||||
proxies: وكلاء
|
proxies: Proxies
|
||||||
systemProxy: وكيل النظام
|
systemProxy: System Proxy
|
||||||
tunMode: وضع TUN
|
tunMode: TUN Mode
|
||||||
closeAllConnections: إغلاق كل الاتصالات
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: الوضع الخفيف
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: نسخ متغيرات البيئة
|
copyEnv: Copy Environment Variables
|
||||||
confDir: دليل الإعدادات
|
confDir: Configuration Directory
|
||||||
coreDir: دليل النواة
|
coreDir: Core Directory
|
||||||
logsDir: دليل السجلات
|
logsDir: Log Directory
|
||||||
openDir: فتح الدليل
|
openDir: Open Directory
|
||||||
appLog: سجل التطبيق
|
appLog: Application Log
|
||||||
coreLog: سجل النواة
|
coreLog: Core Log
|
||||||
restartClash: إعادة تشغيل نواة Clash
|
restartClash: Restart Clash Core
|
||||||
restartApp: إعادة تشغيل التطبيق
|
restartApp: Restart Application
|
||||||
vergeVersion: إصدار Verge
|
vergeVersion: Verge Version
|
||||||
more: المزيد
|
more: More
|
||||||
exit: خروج
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: وكيل النظام
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: ملف تعريفي
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: Übersicht
|
title: Dashboard
|
||||||
body: Die Sichtbarkeit der Übersicht wurde aktualisiert.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: Moduswechsel
|
title: Mode Switch
|
||||||
body: Auf {mode} umgeschaltet.
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Systemproxy
|
title: System Proxy
|
||||||
body: Der Status des Systemproxys wurde aktualisiert.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN-Modus
|
title: TUN Mode
|
||||||
body: Der Status des TUN-Modus wurde aktualisiert.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Leichtmodus
|
title: Lightweight Mode
|
||||||
body: Leichtmodus aktiviert.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: Profile
|
title: Profiles
|
||||||
body: Profil reaktiviert.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: Beenden steht bevor
|
title: About to Exit
|
||||||
body: Clash Verge wird gleich beendet.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: Anwendung ausgeblendet
|
title: Application Hidden
|
||||||
body: Clash Verge läuft im Hintergrund.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Übersicht
|
dashboard: Dashboard
|
||||||
ruleMode: Regelmodus
|
ruleMode: Rule Mode
|
||||||
globalMode: Globaler Modus
|
globalMode: Global Mode
|
||||||
directMode: Direktmodus
|
directMode: Direct Mode
|
||||||
outboundModes: Ausgangsmodi
|
outboundModes: Outbound Modes
|
||||||
rule: Regel
|
rule: Regel
|
||||||
direct: Direkt
|
direct: Direkt
|
||||||
global: Global
|
global: Global
|
||||||
profiles: Profile
|
profiles: Profiles
|
||||||
proxies: Proxy
|
proxies: Proxies
|
||||||
systemProxy: Systemproxy
|
systemProxy: System Proxy
|
||||||
tunMode: TUN-Modus
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Alle Verbindungen schließen
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: Leichtmodus
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: Umgebungsvariablen kopieren
|
copyEnv: Copy Environment Variables
|
||||||
confDir: Konfigurationsverzeichnis
|
confDir: Configuration Directory
|
||||||
coreDir: Core-Verzeichnis
|
coreDir: Core Directory
|
||||||
logsDir: Log-Verzeichnis
|
logsDir: Log Directory
|
||||||
openDir: Verzeichnis öffnen
|
openDir: Open Directory
|
||||||
appLog: Anwendungslog
|
appLog: Application Log
|
||||||
coreLog: Core-Log
|
coreLog: Core Log
|
||||||
restartClash: Clash-Core neu starten
|
restartClash: Restart Clash Core
|
||||||
restartApp: Anwendung neu starten
|
restartApp: Restart Application
|
||||||
vergeVersion: Verge-Version
|
vergeVersion: Verge Version
|
||||||
more: Mehr
|
more: More
|
||||||
exit: Beenden
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: Systemproxy
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profil
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: Panel
|
title: Dashboard
|
||||||
body: La visibilidad del panel se ha actualizado.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: Cambio de modo
|
title: Mode Switch
|
||||||
body: Cambiado a {mode}.
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Proxy del sistema
|
title: System Proxy
|
||||||
body: El estado del proxy del sistema se ha actualizado.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: Modo TUN
|
title: TUN Mode
|
||||||
body: El estado del modo TUN se ha actualizado.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Modo ligero
|
title: Lightweight Mode
|
||||||
body: Se ha entrado en el modo ligero.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: Perfiles
|
title: Profiles
|
||||||
body: Perfil reactivado.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: A punto de salir
|
title: About to Exit
|
||||||
body: Clash Verge está a punto de salir.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: Aplicación oculta
|
title: Application Hidden
|
||||||
body: Clash Verge se está ejecutando en segundo plano.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Panel
|
dashboard: Dashboard
|
||||||
ruleMode: Modo de reglas
|
ruleMode: Rule Mode
|
||||||
globalMode: Modo global
|
globalMode: Global Mode
|
||||||
directMode: Modo directo
|
directMode: Direct Mode
|
||||||
outboundModes: Modos de salida
|
outboundModes: Outbound Modes
|
||||||
rule: Regla
|
rule: Regla
|
||||||
direct: Directo
|
direct: Directo
|
||||||
global: Global
|
global: Global
|
||||||
profiles: Perfiles
|
profiles: Profiles
|
||||||
proxies: Proxies
|
proxies: Proxies
|
||||||
systemProxy: Proxy del sistema
|
systemProxy: System Proxy
|
||||||
tunMode: Modo TUN
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Cerrar todas las conexiones
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: Modo ligero
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: Copiar variables de entorno
|
copyEnv: Copy Environment Variables
|
||||||
confDir: Directorio de configuración
|
confDir: Configuration Directory
|
||||||
coreDir: Directorio del núcleo
|
coreDir: Core Directory
|
||||||
logsDir: Directorio de registros
|
logsDir: Log Directory
|
||||||
openDir: Abrir directorio
|
openDir: Open Directory
|
||||||
appLog: Registro de la aplicación
|
appLog: Application Log
|
||||||
coreLog: Registro del núcleo
|
coreLog: Core Log
|
||||||
restartClash: Reiniciar el núcleo de Clash
|
restartClash: Restart Clash Core
|
||||||
restartApp: Reiniciar aplicación
|
restartApp: Restart Application
|
||||||
vergeVersion: Versión de Verge
|
vergeVersion: Verge Version
|
||||||
more: Más
|
more: More
|
||||||
exit: Salir
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: Proxy del sistema
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Perfil
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: داشبورد
|
title: Dashboard
|
||||||
body: وضعیت نمایش داشبورد بهروزرسانی شد.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: تغییر حالت
|
title: Mode Switch
|
||||||
body: به {mode} تغییر کرد.
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: پروکسی سیستم
|
title: System Proxy
|
||||||
body: وضعیت پروکسی سیستم بهروزرسانی شد.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: حالت TUN
|
title: TUN Mode
|
||||||
body: وضعیت حالت TUN بهروزرسانی شد.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: حالت سبک
|
title: Lightweight Mode
|
||||||
body: به حالت سبک وارد شد.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: پروفایلها
|
title: Profiles
|
||||||
body: پروفایل دوباره فعال شد.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: در آستانه خروج
|
title: About to Exit
|
||||||
body: Clash Verge در آستانه خروج است.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: برنامه پنهان شد
|
title: Application Hidden
|
||||||
body: Clash Verge در پسزمینه در حال اجراست.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: داشبورد
|
dashboard: Dashboard
|
||||||
ruleMode: حالت قوانین
|
ruleMode: Rule Mode
|
||||||
globalMode: حالت سراسری
|
globalMode: Global Mode
|
||||||
directMode: حالت مستقیم
|
directMode: Direct Mode
|
||||||
outboundModes: حالتهای خروجی
|
outboundModes: Outbound Modes
|
||||||
rule: قانون
|
rule: Rule
|
||||||
direct: مستقیم
|
direct: Direct
|
||||||
global: سراسری
|
global: Global
|
||||||
profiles: پروفایلها
|
profiles: Profiles
|
||||||
proxies: پروکسیها
|
proxies: Proxies
|
||||||
systemProxy: پروکسی سیستم
|
systemProxy: System Proxy
|
||||||
tunMode: حالت TUN
|
tunMode: TUN Mode
|
||||||
closeAllConnections: بستن همه اتصالها
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: حالت سبک
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: کپی متغیرهای محیطی
|
copyEnv: Copy Environment Variables
|
||||||
confDir: پوشه پیکربندی
|
confDir: Configuration Directory
|
||||||
coreDir: پوشه هسته
|
coreDir: Core Directory
|
||||||
logsDir: پوشه گزارشها
|
logsDir: Log Directory
|
||||||
openDir: باز کردن پوشه
|
openDir: Open Directory
|
||||||
appLog: گزارش برنامه
|
appLog: Application Log
|
||||||
coreLog: گزارش هسته
|
coreLog: Core Log
|
||||||
restartClash: راهاندازی مجدد هسته Clash
|
restartClash: Restart Clash Core
|
||||||
restartApp: راهاندازی مجدد برنامه
|
restartApp: Restart Application
|
||||||
vergeVersion: نسخه Verge
|
vergeVersion: Verge Version
|
||||||
more: بیشتر
|
more: More
|
||||||
exit: خروج
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: پروکسی سیستم
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: پروفایل
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: Dasbor
|
title: Dashboard
|
||||||
body: Visibilitas dasbor telah diperbarui.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: Peralihan Mode
|
title: Mode Switch
|
||||||
body: Beralih ke {mode}.
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Proksi Sistem
|
title: System Proxy
|
||||||
body: Status proksi sistem telah diperbarui.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: Mode TUN
|
title: TUN Mode
|
||||||
body: Status mode TUN telah diperbarui.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Mode Ringan
|
title: Lightweight Mode
|
||||||
body: Masuk ke mode ringan.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: Profil
|
title: Profiles
|
||||||
body: Profil diaktifkan kembali.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: Akan Keluar
|
title: About to Exit
|
||||||
body: Clash Verge akan keluar.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: Aplikasi Disembunyikan
|
title: Application Hidden
|
||||||
body: Clash Verge berjalan di latar belakang.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Dasbor
|
dashboard: Dashboard
|
||||||
ruleMode: Mode Aturan
|
ruleMode: Rule Mode
|
||||||
globalMode: Mode Global
|
globalMode: Global Mode
|
||||||
directMode: Mode Langsung
|
directMode: Direct Mode
|
||||||
outboundModes: Mode Keluar
|
outboundModes: Outbound Modes
|
||||||
rule: Aturan
|
rule: Aturan
|
||||||
direct: Langsung
|
direct: Langsung
|
||||||
global: Global
|
global: Global
|
||||||
profiles: Profil
|
profiles: Profiles
|
||||||
proxies: Proksi
|
proxies: Proxies
|
||||||
systemProxy: Proksi Sistem
|
systemProxy: System Proxy
|
||||||
tunMode: Mode TUN
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Tutup Semua Koneksi
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: Mode Ringan
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: Salin Variabel Lingkungan
|
copyEnv: Copy Environment Variables
|
||||||
confDir: Direktori Konfigurasi
|
confDir: Configuration Directory
|
||||||
coreDir: Direktori Core
|
coreDir: Core Directory
|
||||||
logsDir: Direktori Log
|
logsDir: Log Directory
|
||||||
openDir: Buka Direktori
|
openDir: Open Directory
|
||||||
appLog: Log Aplikasi
|
appLog: Application Log
|
||||||
coreLog: Log Core
|
coreLog: Core Log
|
||||||
restartClash: Mulai Ulang Core Clash
|
restartClash: Restart Clash Core
|
||||||
restartApp: Mulai Ulang Aplikasi
|
restartApp: Restart Application
|
||||||
vergeVersion: Versi Verge
|
vergeVersion: Verge Version
|
||||||
more: Lainnya
|
more: More
|
||||||
exit: Keluar
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: Proksi Sistem
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profil
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: ダッシュボード
|
title: Dashboard
|
||||||
body: ダッシュボードの表示状態が更新されました。
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: モード切り替え
|
title: Mode Switch
|
||||||
body: "{mode} に切り替えました。"
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: システムプロキシ
|
title: System Proxy
|
||||||
body: システムプロキシの状態が更新されました。
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN モード
|
title: TUN Mode
|
||||||
body: TUN モードの状態が更新されました。
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: 軽量モード
|
title: Lightweight Mode
|
||||||
body: 軽量モードに入りました。
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: プロファイル
|
title: Profiles
|
||||||
body: プロファイルが再有効化されました。
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: 終了間近
|
title: About to Exit
|
||||||
body: Clash Verge はまもなく終了します。
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: アプリが非表示
|
title: Application Hidden
|
||||||
body: Clash Verge はバックグラウンドで実行中です。
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: ダッシュボード
|
dashboard: Dashboard
|
||||||
ruleMode: ルールモード
|
ruleMode: Rule Mode
|
||||||
globalMode: グローバルモード
|
globalMode: Global Mode
|
||||||
directMode: ダイレクトモード
|
directMode: Direct Mode
|
||||||
outboundModes: アウトバウンドモード
|
outboundModes: Outbound Modes
|
||||||
rule: ルール
|
rule: ルール
|
||||||
direct: ダイレクト
|
direct: ダイレクト
|
||||||
global: グローバル
|
global: グローバル
|
||||||
profiles: プロファイル
|
profiles: Profiles
|
||||||
proxies: プロキシ
|
proxies: Proxies
|
||||||
systemProxy: システムプロキシ
|
systemProxy: System Proxy
|
||||||
tunMode: TUN モード
|
tunMode: TUN Mode
|
||||||
closeAllConnections: すべての接続を閉じる
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: 軽量モード
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: 環境変数をコピー
|
copyEnv: Copy Environment Variables
|
||||||
confDir: 設定ディレクトリ
|
confDir: Configuration Directory
|
||||||
coreDir: コアディレクトリ
|
coreDir: Core Directory
|
||||||
logsDir: ログディレクトリ
|
logsDir: Log Directory
|
||||||
openDir: ディレクトリを開く
|
openDir: Open Directory
|
||||||
appLog: アプリケーションログ
|
appLog: Application Log
|
||||||
coreLog: コアログ
|
coreLog: Core Log
|
||||||
restartClash: Clash コアを再起動
|
restartClash: Restart Clash Core
|
||||||
restartApp: アプリケーションを再起動
|
restartApp: Restart Application
|
||||||
vergeVersion: Verge バージョン
|
vergeVersion: Verge Version
|
||||||
more: その他
|
more: More
|
||||||
exit: 終了
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: システムプロキシ
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: プロファイル
|
profile: Profile
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ notifications:
|
|||||||
title: 경량 모드
|
title: 경량 모드
|
||||||
body: 경량 모드에 진입했습니다.
|
body: 경량 모드에 진입했습니다.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: 프로필
|
title: Profiles
|
||||||
body: 프로필이 다시 활성화되었습니다.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: 곧 종료
|
title: 곧 종료
|
||||||
body: Clash Verge가 곧 종료됩니다.
|
body: Clash Verge가 곧 종료됩니다.
|
||||||
@@ -25,14 +25,14 @@ notifications:
|
|||||||
title: 앱이 숨겨짐
|
title: 앱이 숨겨짐
|
||||||
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: 대시보드
|
dashboard: 대시보드
|
||||||
ruleMode: 규칙 모드
|
ruleMode: 규칙 모드
|
||||||
globalMode: 전역 모드
|
globalMode: 전역 모드
|
||||||
directMode: 직접 모드
|
directMode: 직접 모드
|
||||||
outboundModes: 아웃바운드 모드
|
outboundModes: Outbound Modes
|
||||||
rule: 규칙
|
rule: 규칙
|
||||||
direct: 직접
|
direct: 직접
|
||||||
global: 글로벌
|
global: 글로벌
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: Панель
|
title: Dashboard
|
||||||
body: Видимость панели обновлена.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: Смена режима
|
title: Mode Switch
|
||||||
body: Переключено на {mode}.
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Системный прокси
|
title: System Proxy
|
||||||
body: Статус системного прокси обновлен.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: Режим TUN
|
title: TUN Mode
|
||||||
body: Статус режима TUN обновлен.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Легкий режим
|
title: Lightweight Mode
|
||||||
body: Включен легкий режим.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: Профили
|
title: Profiles
|
||||||
body: Профиль повторно активирован.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: Скорый выход
|
title: About to Exit
|
||||||
body: Clash Verge скоро завершит работу.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: Приложение скрыто
|
title: Application Hidden
|
||||||
body: Clash Verge работает в фоновом режиме.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Панель
|
dashboard: Dashboard
|
||||||
ruleMode: Режим правил
|
ruleMode: Rule Mode
|
||||||
globalMode: Глобальный режим
|
globalMode: Global Mode
|
||||||
directMode: Прямой режим
|
directMode: Direct Mode
|
||||||
outboundModes: Исходящие режимы
|
outboundModes: Outbound Modes
|
||||||
rule: Правило
|
rule: Правило
|
||||||
direct: Прямой
|
direct: Прямой
|
||||||
global: Глобальный
|
global: Глобальный
|
||||||
profiles: Профили
|
profiles: Profiles
|
||||||
proxies: Прокси
|
proxies: Proxies
|
||||||
systemProxy: Системный прокси
|
systemProxy: System Proxy
|
||||||
tunMode: Режим TUN
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Закрыть все соединения
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: Легкий режим
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: Копировать переменные среды
|
copyEnv: Copy Environment Variables
|
||||||
confDir: Каталог конфигурации
|
confDir: Configuration Directory
|
||||||
coreDir: Каталог ядра
|
coreDir: Core Directory
|
||||||
logsDir: Каталог журналов
|
logsDir: Log Directory
|
||||||
openDir: Открыть каталог
|
openDir: Open Directory
|
||||||
appLog: Журнал приложения
|
appLog: Application Log
|
||||||
coreLog: Журнал ядра
|
coreLog: Core Log
|
||||||
restartClash: Перезапустить ядро Clash
|
restartClash: Restart Clash Core
|
||||||
restartApp: Перезапустить приложение
|
restartApp: Restart Application
|
||||||
vergeVersion: Версия Verge
|
vergeVersion: Verge Version
|
||||||
more: Еще
|
more: More
|
||||||
exit: Выход
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: Системный прокси
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Профиль
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: Gösterge Paneli
|
title: Dashboard
|
||||||
body: Gösterge panelinin görünürlüğü güncellendi.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: Mod Değişimi
|
title: Mode Switch
|
||||||
body: "{mode} moduna geçildi."
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Sistem Vekil'i
|
title: System Proxy
|
||||||
body: Sistem vekil'i durumu güncellendi.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN Modu
|
title: TUN Mode
|
||||||
body: TUN modu durumu güncellendi.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Hafif Mod
|
title: Lightweight Mode
|
||||||
body: Hafif moda geçildi.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: Profiller
|
title: Profiles
|
||||||
body: Profil yeniden etkinleştirildi.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: Çıkış Yapılmak Üzere
|
title: About to Exit
|
||||||
body: Clash Verge kapanmak üzere.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: Uygulama Gizlendi
|
title: Application Hidden
|
||||||
body: Clash Verge arka planda çalışıyor.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Gösterge Paneli
|
dashboard: Dashboard
|
||||||
ruleMode: Kural Modu
|
ruleMode: Rule Mode
|
||||||
globalMode: Küresel Mod
|
globalMode: Global Mode
|
||||||
directMode: Doğrudan Mod
|
directMode: Direct Mode
|
||||||
outboundModes: Giden Modlar
|
outboundModes: Outbound Modes
|
||||||
rule: Kural
|
rule: Kural
|
||||||
direct: Doğrudan
|
direct: Doğrudan
|
||||||
global: Küresel
|
global: Küresel
|
||||||
profiles: Profiller
|
profiles: Profiles
|
||||||
proxies: Vekil'ler
|
proxies: Proxies
|
||||||
systemProxy: Sistem Vekil'i
|
systemProxy: System Proxy
|
||||||
tunMode: TUN Modu
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Tüm Bağlantıları Kapat
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: Hafif Mod
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: Ortam Değişkenlerini Kopyala
|
copyEnv: Copy Environment Variables
|
||||||
confDir: Yapılandırma Dizini
|
confDir: Configuration Directory
|
||||||
coreDir: Çekirdek Dizini
|
coreDir: Core Directory
|
||||||
logsDir: Günlük Dizini
|
logsDir: Log Directory
|
||||||
openDir: Dizini Aç
|
openDir: Open Directory
|
||||||
appLog: Uygulama Günlüğü
|
appLog: Application Log
|
||||||
coreLog: Çekirdek Günlüğü
|
coreLog: Core Log
|
||||||
restartClash: Clash Çekirdeğini Yeniden Başlat
|
restartClash: Restart Clash Core
|
||||||
restartApp: Uygulamayı Yeniden Başlat
|
restartApp: Restart Application
|
||||||
vergeVersion: Verge Sürümü
|
vergeVersion: Verge Version
|
||||||
more: Daha Fazla
|
more: More
|
||||||
exit: Çıkış
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: Sistem Vekil'i
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profil
|
profile: Profile
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
_version: 1
|
_version: 1
|
||||||
notifications:
|
notifications:
|
||||||
dashboardToggled:
|
dashboardToggled:
|
||||||
title: Идарә панеле
|
title: Dashboard
|
||||||
body: Идарә панеленең күренеше яңартылды.
|
body: Dashboard visibility has been updated.
|
||||||
clashModeChanged:
|
clashModeChanged:
|
||||||
title: Режим алыштыру
|
title: Mode Switch
|
||||||
body: "{mode} режимына күчтел."
|
body: Switched to {mode}.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Системалы прокси
|
title: System Proxy
|
||||||
body: Системалы прокси хәле яңартылды.
|
body: System proxy status has been updated.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN режимы
|
title: TUN Mode
|
||||||
body: TUN режимы хәле яңартылды.
|
body: TUN mode status has been updated.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Җиңел режим
|
title: Lightweight Mode
|
||||||
body: Җиңел режимга күчелде.
|
body: Entered lightweight mode.
|
||||||
profilesReactivated:
|
profilesReactivated:
|
||||||
title: Профильләр
|
title: Profiles
|
||||||
body: Профиль яңадан активлаштырылды.
|
body: Profile Reactivated.
|
||||||
appQuit:
|
appQuit:
|
||||||
title: Чыгар алдыннан
|
title: About to Exit
|
||||||
body: Clash Verge чыгарга җыена.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
appHidden:
|
||||||
title: Кушымта яшерелде
|
title: Application Hidden
|
||||||
body: Clash Verge фон режимында эшли.
|
body: Clash Verge is running in the background.
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||||
tray:
|
tray:
|
||||||
dashboard: Идарә панеле
|
dashboard: Dashboard
|
||||||
ruleMode: Кагыйдә режимы
|
ruleMode: Rule Mode
|
||||||
globalMode: Глобаль режим
|
globalMode: Global Mode
|
||||||
directMode: Турыдан-туры режим
|
directMode: Direct Mode
|
||||||
outboundModes: Чыгыш режимнары
|
outboundModes: Outbound Modes
|
||||||
rule: Кагыйдә
|
rule: Rule
|
||||||
direct: Турыдан-туры
|
direct: Direct
|
||||||
global: Глобаль
|
global: Global
|
||||||
profiles: Профильләр
|
profiles: Profiles
|
||||||
proxies: Проксилар
|
proxies: Proxies
|
||||||
systemProxy: Системалы прокси
|
systemProxy: System Proxy
|
||||||
tunMode: TUN режимы
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Барлык тоташуларны ябу
|
closeAllConnections: Close All Connections
|
||||||
lightweightMode: Җиңел режим
|
lightweightMode: Lightweight Mode
|
||||||
copyEnv: Мохит үзгәрүчәннәрен күчерү
|
copyEnv: Copy Environment Variables
|
||||||
confDir: Конфигурация каталогы
|
confDir: Configuration Directory
|
||||||
coreDir: Ядро каталогы
|
coreDir: Core Directory
|
||||||
logsDir: Журнал каталогы
|
logsDir: Log Directory
|
||||||
openDir: Каталогны ачу
|
openDir: Open Directory
|
||||||
appLog: Кушымта журналы
|
appLog: Application Log
|
||||||
coreLog: Ядро журналы
|
coreLog: Core Log
|
||||||
restartClash: Clash ядрәсен кабат җибәрү
|
restartClash: Restart Clash Core
|
||||||
restartApp: Кушымтаны кабат җибәрү
|
restartApp: Restart Application
|
||||||
vergeVersion: Verge версиясе
|
vergeVersion: Verge Version
|
||||||
more: Күбрәк
|
more: More
|
||||||
exit: Чыгу
|
exit: Exit
|
||||||
tooltip:
|
tooltip:
|
||||||
systemProxy: Системалы прокси
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Профиль
|
profile: Profile
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "clash-verge-limiter"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
pub type SystemLimiter = Limiter<SystemClock>;
|
|
||||||
|
|
||||||
pub trait Clock: Send + Sync {
|
|
||||||
fn now_ms(&self) -> u64;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clock + ?Sized> Clock for &T {
|
|
||||||
fn now_ms(&self) -> u64 {
|
|
||||||
(**self).now_ms()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clock + ?Sized> Clock for Arc<T> {
|
|
||||||
fn now_ms(&self) -> u64 {
|
|
||||||
(**self).now_ms()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SystemClock;
|
|
||||||
|
|
||||||
impl Clock for SystemClock {
|
|
||||||
fn now_ms(&self) -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as u64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Limiter<C: Clock = SystemClock> {
|
|
||||||
last_run_ms: AtomicU64,
|
|
||||||
period_ms: u64,
|
|
||||||
clock: C,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<C: Clock> Limiter<C> {
|
|
||||||
pub const fn new(period: Duration, clock: C) -> Self {
|
|
||||||
Self {
|
|
||||||
last_run_ms: AtomicU64::new(0),
|
|
||||||
period_ms: period.as_millis() as u64,
|
|
||||||
clock,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check(&self) -> bool {
|
|
||||||
let now = self.clock.now_ms();
|
|
||||||
let last = self.last_run_ms.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
if now < last + self.period_ms && now >= last {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.last_run_ms
|
|
||||||
.compare_exchange(last, now, Ordering::SeqCst, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod extra_tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
struct MockClock(AtomicU64);
|
|
||||||
impl Clock for MockClock {
|
|
||||||
fn now_ms(&self) -> u64 {
|
|
||||||
self.0.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_zero_period_always_passes() {
|
|
||||||
let mock = MockClock(AtomicU64::new(100));
|
|
||||||
let limiter = Limiter::new(Duration::from_millis(0), &mock);
|
|
||||||
|
|
||||||
assert!(limiter.check());
|
|
||||||
assert!(limiter.check());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_boundary_condition() {
|
|
||||||
let period_ms = 100;
|
|
||||||
let mock = MockClock(AtomicU64::new(1000));
|
|
||||||
let limiter = Limiter::new(Duration::from_millis(period_ms), &mock);
|
|
||||||
|
|
||||||
assert!(limiter.check());
|
|
||||||
|
|
||||||
mock.0.store(1099, Ordering::SeqCst);
|
|
||||||
assert!(!limiter.check());
|
|
||||||
|
|
||||||
mock.0.store(1100, Ordering::SeqCst);
|
|
||||||
assert!(limiter.check(), "Should pass exactly at period boundary");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_high_concurrency_consistency() {
|
|
||||||
let period = Duration::from_millis(1000);
|
|
||||||
let mock = Arc::new(MockClock(AtomicU64::new(1000)));
|
|
||||||
let limiter = Arc::new(Limiter::new(period, Arc::clone(&mock)));
|
|
||||||
|
|
||||||
assert!(limiter.check());
|
|
||||||
|
|
||||||
mock.0.store(2500, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let mut handles = vec![];
|
|
||||||
for _ in 0..20 {
|
|
||||||
let l = Arc::clone(&limiter);
|
|
||||||
handles.push(thread::spawn(move || l.check()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
let results: Vec<bool> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
|
||||||
|
|
||||||
let success_count = results.iter().filter(|&&x| x).count();
|
|
||||||
assert_eq!(success_count, 1);
|
|
||||||
|
|
||||||
assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extreme_time_jump() {
|
|
||||||
let mock = MockClock(AtomicU64::new(100));
|
|
||||||
let limiter = Limiter::new(Duration::from_millis(100), &mock);
|
|
||||||
|
|
||||||
assert!(limiter.check());
|
|
||||||
|
|
||||||
mock.0.store(u64::MAX - 10, Ordering::SeqCst);
|
|
||||||
assert!(limiter.check());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_system_clock_real_path() {
|
|
||||||
let clock = SystemClock;
|
|
||||||
let start = clock.now_ms();
|
|
||||||
assert!(start > 0);
|
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
|
||||||
assert!(clock.now_ms() >= start);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_limiter_with_system_clock_default() {
|
|
||||||
let limiter = Limiter::new(Duration::from_millis(100), SystemClock);
|
|
||||||
assert!(limiter.check());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_coverage_time_backward() {
|
|
||||||
let mock = MockClock(AtomicU64::new(5000));
|
|
||||||
let limiter = Limiter::new(Duration::from_millis(100), &mock);
|
|
||||||
|
|
||||||
assert!(limiter.check());
|
|
||||||
|
|
||||||
mock.0.store(4000, Ordering::SeqCst);
|
|
||||||
|
|
||||||
assert!(limiter.check(), "Should pass and reset when time moves backward");
|
|
||||||
|
|
||||||
assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 4000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,20 +14,18 @@ where
|
|||||||
F: Fn() -> Fut + Send + Sync + 'static,
|
F: Fn() -> Fut + Send + Sync + 'static,
|
||||||
Fut: Future + Send + 'static,
|
Fut: Future + Send + 'static,
|
||||||
{
|
{
|
||||||
RUNTIME.get_or_init(
|
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
|
||||||
|| match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
Ok(rt) => Some(rt),
|
||||||
Ok(rt) => Some(rt),
|
Err(e) => {
|
||||||
Err(e) => {
|
logging!(
|
||||||
logging!(
|
info,
|
||||||
info,
|
Type::SystemSignal,
|
||||||
Type::SystemSignal,
|
"register shutdown signal failed, create tokio runtime error: {}",
|
||||||
"register shutdown signal failed, create tokio runtime error: {}",
|
e
|
||||||
e
|
);
|
||||||
);
|
None
|
||||||
None
|
}
|
||||||
}
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unix::register(f);
|
unix::register(f);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod runtime;
|
||||||
@@ -2,9 +2,7 @@ use serde_yaml_ng::{Mapping, Value};
|
|||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::enhance::field::use_keys;
|
const PATCH_CONFIG_INNER: [&str; 4] = ["allow-lan", "ipv6", "log-level", "unified-delay"];
|
||||||
|
|
||||||
const PATCH_CONFIG_INNER: [&str; 5] = ["allow-lan", "ipv6", "log-level", "unified-delay", "tunnels"];
|
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct IRuntime {
|
pub struct IRuntime {
|
||||||
@@ -22,7 +20,7 @@ impl IRuntime {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里只更改 allow-lan | ipv6 | log-level | tun | tunnels
|
// 这里只更改 allow-lan | ipv6 | log-level | tun
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn patch_config(&mut self, patch: &Mapping) {
|
pub fn patch_config(&mut self, patch: &Mapping) {
|
||||||
let config = if let Some(config) = self.config.as_mut() {
|
let config = if let Some(config) = self.config.as_mut() {
|
||||||
@@ -138,3 +136,13 @@ 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,12 +8,10 @@ rust-version = "1.91"
|
|||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
tauri-plugin-clipboard-manager = { workspace = true }
|
tauri-plugin-clipboard-manager = { workspace = true }
|
||||||
parking_lot = { workspace = true }
|
parking_lot = { workspace = true }
|
||||||
# sysinfo 0.38.2 conflicts with dark-light
|
sysinfo = { version = "0.37.2", features = ["network", "system"] }
|
||||||
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
|
|
||||||
sysinfo = { version = "0.38", features = ["network", "system"] }
|
|
||||||
|
|
||||||
[target.'cfg(not(windows))'.dependencies]
|
[target.'cfg(not(windows))'.dependencies]
|
||||||
libc = "0.2.182"
|
libc = "0.2.180"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
deelevate = { workspace = true }
|
deelevate = { workspace = true }
|
||||||
|
|||||||
@@ -120,12 +120,6 @@ fn is_binary_admin() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
|
||||||
#[cfg(unix)]
|
|
||||||
pub fn current_gid() -> u32 {
|
|
||||||
unsafe { libc::getgid() }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn list_network_interfaces() -> Vec<String> {
|
pub fn list_network_interfaces() -> Vec<String> {
|
||||||
let mut networks = Networks::new();
|
let mut networks = Networks::new();
|
||||||
|
|||||||
@@ -1,101 +1,3 @@
|
|||||||
## v(2.4.6)
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> 历经多轮磨合与修正,这是自 2.0 以来我们最满意的里程碑版本。建议所有用户立即升级。
|
|
||||||
|
|
||||||
### 🐞 修复问题
|
|
||||||
|
|
||||||
- 修复首次启动时代理信息刷新缓慢
|
|
||||||
- 修复无网络时无限请求 IP 归属查询
|
|
||||||
- 修复 WebDAV 页面重试逻辑
|
|
||||||
- 修复 Linux 通过 GUI 安装服务模式权限不符合预期
|
|
||||||
- 修复 macOS 因网口顺序导致无法正确设置代理
|
|
||||||
- 修复恢复休眠后无法操作托盘
|
|
||||||
- 修复首页当前节点图标语义显示不一致
|
|
||||||
- 修复使用 URL scheme 导入订阅时没有及时重载配置
|
|
||||||
- 修复规则界面里的行号展示逻辑
|
|
||||||
- 修复 Windows 托盘打开日志失败
|
|
||||||
- 修复 KDE 首次启动报错
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
|
||||||
|
|
||||||
- 升级 Mihomo 内核到最新
|
|
||||||
- 支持订阅设置自动延时监测间隔
|
|
||||||
- 新增流量隧道管理界面,支持可视化添加/删除隧道配置
|
|
||||||
- Masque 协议的 GUI 支持
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
|
||||||
|
|
||||||
- 安装服务失败时报告更详细的错误
|
|
||||||
- 避免脏订阅地址无法 Scheme 导入订阅
|
|
||||||
- macOS TUN 覆盖 DNS 时使用 114.114.114.114
|
|
||||||
- 连通性测试替换为更快的 http://1.0.0.1
|
|
||||||
- 连接、规则、日志等页面的过滤搜索组件新增了清空输入框按钮
|
|
||||||
- 链式代理增加明显的入口出口与数据流向标识
|
|
||||||
- 优化 IP 信息卡
|
|
||||||
- 美化代理组图标样式
|
|
||||||
- 移除 Linux resources 文件夹下多余的服务二进制文件
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 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
|
## v2.4.4
|
||||||
|
|
||||||
- **Mihomo(Meta) 内核升级至 v1.19.17**
|
- **Mihomo(Meta) 内核升级至 v1.19.17**
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
|||||||
|
|
||||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
🌐 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
|
## Features
|
||||||
|
|
||||||
- Built on high-performance Rust with the Tauri 2 framework
|
- Built on high-performance Rust with the Tauri 2 framework
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
|
|||||||
- Desbloquea servicios de streaming y acceso a ChatGPT
|
- Desbloquea servicios de streaming y acceso a ChatGPT
|
||||||
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- 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
|
## Funciones
|
||||||
|
|
||||||
- Basado en Rust de alto rendimiento y en el framework Tauri 2
|
- Basado en Rust de alto rendimiento y en el framework Tauri 2
|
||||||
|
|||||||
@@ -62,6 +62,18 @@
|
|||||||
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
||||||
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- وبسایت رسمی: [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
|
- ساخته شده بر اساس Rust با کارایی بالا و فریمورک Tauri 2
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
|
|||||||
- ストリーミングおよび ChatGPT の利用にも対応
|
- ストリーミングおよび ChatGPT の利用にも対応
|
||||||
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- 公式サイト: [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 フレームワークに基づくデスクトップアプリ
|
- 高性能な Rust と Tauri 2 フレームワークに基づくデスクトップアプリ
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
|
|||||||
- 스트리밍 및 ChatGPT 접근 지원
|
- 스트리밍 및 ChatGPT 접근 지원
|
||||||
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- 공식 사이트: [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 프레임워크 기반 데스크톱 앱
|
- 고성능 Rust와 Tauri 2 프레임워크 기반 데스크톱 앱
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
|||||||
- Разблокировка потоковые сервисы и ChatGPT
|
- Разблокировка потоковые сервисы и ChatGPT
|
||||||
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- Официальный сайт: [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
|
- Основан на произвоительном Rust и фреймворке Tauri 2
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ export default defineConfig([
|
|||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
parserOptions: {
|
|
||||||
projectService: {
|
|
||||||
allowDefaultProject: ["src/polyfills/*.js"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+39
-41
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.4.7",
|
"version": "2.4.5-rc.1",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
@@ -41,82 +41,80 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@mui/icons-material": "^7.3.7",
|
||||||
"@mui/lab": "7.0.0-beta.17",
|
"@mui/lab": "7.0.0-beta.17",
|
||||||
"@mui/material": "^7.3.8",
|
"@mui/material": "^7.3.7",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@tauri-apps/api": "2.10.1",
|
"@tauri-apps/api": "2.9.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.5.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-http": "~2.5.7",
|
"@tauri-apps/plugin-http": "~2.5.5",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-shell": "2.3.5",
|
"@tauri-apps/plugin-shell": "2.3.4",
|
||||||
"@tauri-apps/plugin-updater": "2.10.0",
|
"@tauri-apps/plugin-updater": "2.9.0",
|
||||||
"ahooks": "^3.9.6",
|
"ahooks": "^3.9.6",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.2",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.19",
|
||||||
"foxact": "^0.2.52",
|
"foxact": "^0.2.52",
|
||||||
"foxts": "^5.2.1",
|
"i18next": "^25.7.4",
|
||||||
"i18next": "^25.8.13",
|
|
||||||
"ipaddr.js": "^2.3.0",
|
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.17.22",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"monaco-yaml": "^5.4.1",
|
"monaco-yaml": "^5.4.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.3",
|
||||||
"react-error-boundary": "6.1.1",
|
"react-error-boundary": "6.0.3",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.0",
|
||||||
"react-i18next": "16.5.4",
|
"react-i18next": "16.5.2",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.12.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"swr": "^2.4.0",
|
"swr": "^2.3.8",
|
||||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||||
"types-pac": "^1.0.3"
|
"types-pac": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/github": "^9.0.0",
|
"@actions/github": "^7.0.0",
|
||||||
"@eslint-react/eslint-plugin": "^2.13.0",
|
"@eslint-react/eslint-plugin": "^2.5.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^9.39.2",
|
||||||
"@tauri-apps/cli": "2.10.0",
|
"@tauri-apps/cli": "2.9.6",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.7",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.8",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-legacy": "^7.2.1",
|
"@vitejs/plugin-legacy": "^7.2.1",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"cli-color": "^2.0.4",
|
"cli-color": "^2.0.4",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^10.0.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-import-x": "^4.16.1",
|
"eslint-plugin-import-x": "^4.16.1",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.3.0",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.0",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.0.0",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.7.4",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.97.2",
|
||||||
"tar": "^7.5.9",
|
"tar": "^7.5.2",
|
||||||
"terser": "^5.46.0",
|
"terser": "^5.44.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.56.0",
|
"typescript-eslint": "^8.52.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-svgr": "^4.5.0"
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
},
|
},
|
||||||
@@ -130,7 +128,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.29.2",
|
"packageManager": "pnpm@10.28.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
Generated
+1942
-1899
File diff suppressed because it is too large
Load Diff
+10
-16
@@ -68,11 +68,6 @@ const SIDECAR_HOST = target
|
|||||||
.toString()
|
.toString()
|
||||||
.match(/(?<=host: ).+(?=\s*)/g)[0];
|
.match(/(?<=host: ).+(?=\s*)/g)[0];
|
||||||
|
|
||||||
const RESOURCES_DIR = path.join(cwd, "src-tauri", "resources");
|
|
||||||
const SIDECAR_DIR = path.join(cwd, "src-tauri", "sidecar");
|
|
||||||
// Linux service binaries are bundled as externalBin sidecars (see tauri.linux.conf.json)
|
|
||||||
const SERVICE_DIR = platform === "linux" ? SIDECAR_DIR : RESOURCES_DIR;
|
|
||||||
|
|
||||||
// =======================
|
// =======================
|
||||||
// Version Cache
|
// Version Cache
|
||||||
// =======================
|
// =======================
|
||||||
@@ -374,8 +369,9 @@ async function downloadFile(url, outPath) {
|
|||||||
// =======================
|
// =======================
|
||||||
async function resolveSidecar(binInfo) {
|
async function resolveSidecar(binInfo) {
|
||||||
const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo;
|
const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo;
|
||||||
const sidecarPath = path.join(SIDECAR_DIR, targetFile);
|
const sidecarDir = path.join(cwd, "src-tauri", "sidecar");
|
||||||
await fsp.mkdir(SIDECAR_DIR, { recursive: true });
|
const sidecarPath = path.join(sidecarDir, targetFile);
|
||||||
|
await fsp.mkdir(sidecarDir, { recursive: true });
|
||||||
|
|
||||||
if (!FORCE && fs.existsSync(sidecarPath)) {
|
if (!FORCE && fs.existsSync(sidecarPath)) {
|
||||||
log_success(`"${name}" already exists, skipping download`);
|
log_success(`"${name}" already exists, skipping download`);
|
||||||
@@ -464,9 +460,9 @@ async function resolveSidecar(binInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveResource(binInfo) {
|
async function resolveResource(binInfo) {
|
||||||
const { file, downloadURL, localPath, dir } = binInfo;
|
const { file, downloadURL, localPath } = binInfo;
|
||||||
const baseDir = dir ?? RESOURCES_DIR;
|
const resDir = path.join(cwd, "src-tauri/resources");
|
||||||
const targetPath = path.join(baseDir, file);
|
const targetPath = path.join(resDir, file);
|
||||||
|
|
||||||
if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) {
|
if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) {
|
||||||
log_success(`"${file}" already exists, skipping`);
|
log_success(`"${file}" already exists, skipping`);
|
||||||
@@ -478,7 +474,7 @@ async function resolveResource(binInfo) {
|
|||||||
log_success(`"${file}" already exists, skipping download`);
|
log_success(`"${file}" already exists, skipping download`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fsp.mkdir(baseDir, { recursive: true });
|
await fsp.mkdir(resDir, { recursive: true });
|
||||||
await downloadFile(downloadURL, targetPath);
|
await downloadFile(downloadURL, targetPath);
|
||||||
await updateHashCache(targetPath);
|
await updateHashCache(targetPath);
|
||||||
}
|
}
|
||||||
@@ -487,7 +483,7 @@ async function resolveResource(binInfo) {
|
|||||||
if (!(await hasFileChanged(localPath, targetPath))) {
|
if (!(await hasFileChanged(localPath, targetPath))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fsp.mkdir(baseDir, { recursive: true });
|
await fsp.mkdir(resDir, { recursive: true });
|
||||||
await fsp.copyFile(localPath, targetPath);
|
await fsp.copyFile(localPath, targetPath);
|
||||||
await updateHashCache(targetPath);
|
await updateHashCache(targetPath);
|
||||||
log_success(`Copied file: ${file}`);
|
log_success(`Copied file: ${file}`);
|
||||||
@@ -549,11 +545,12 @@ const resolveServicePermission = async () => {
|
|||||||
"clash-verge-service-install*",
|
"clash-verge-service-install*",
|
||||||
"clash-verge-service-uninstall*",
|
"clash-verge-service-uninstall*",
|
||||||
];
|
];
|
||||||
|
const resDir = path.join(cwd, "src-tauri/resources");
|
||||||
const hashCache = await loadHashCache();
|
const hashCache = await loadHashCache();
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
for (const f of serviceExecutables) {
|
for (const f of serviceExecutables) {
|
||||||
const files = glob.sync(path.join(SERVICE_DIR, f));
|
const files = glob.sync(path.join(resDir, f));
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const currentHash = await calculateFileHash(filePath);
|
const currentHash = await calculateFileHash(filePath);
|
||||||
@@ -587,7 +584,6 @@ const resolveService = () => {
|
|||||||
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
|
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
|
||||||
return resolveResource({
|
return resolveResource({
|
||||||
file: "clash-verge-service" + suffix + ext,
|
file: "clash-verge-service" + suffix + ext,
|
||||||
dir: SERVICE_DIR,
|
|
||||||
downloadURL: `${SERVICE_URL}/clash-verge-service${ext}`,
|
downloadURL: `${SERVICE_URL}/clash-verge-service${ext}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -596,7 +592,6 @@ const resolveInstall = () => {
|
|||||||
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
|
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
|
||||||
return resolveResource({
|
return resolveResource({
|
||||||
file: "clash-verge-service-install" + suffix + ext,
|
file: "clash-verge-service-install" + suffix + ext,
|
||||||
dir: SERVICE_DIR,
|
|
||||||
downloadURL: `${SERVICE_URL}/clash-verge-service-install${ext}`,
|
downloadURL: `${SERVICE_URL}/clash-verge-service-install${ext}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -605,7 +600,6 @@ const resolveUninstall = () => {
|
|||||||
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
|
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
|
||||||
return resolveResource({
|
return resolveResource({
|
||||||
file: "clash-verge-service-uninstall" + suffix + ext,
|
file: "clash-verge-service-uninstall" + suffix + ext,
|
||||||
dir: SERVICE_DIR,
|
|
||||||
downloadURL: `${SERVICE_URL}/clash-verge-service-uninstall${ext}`,
|
downloadURL: `${SERVICE_URL}/clash-verge-service-uninstall${ext}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ async function sendTelegramNotification() {
|
|||||||
|
|
||||||
const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布";
|
const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布";
|
||||||
const encodedVersion = encodeURIComponent(version);
|
const encodedVersion = encodeURIComponent(version);
|
||||||
const releaseTag = isAutobuild ? "autobuild" : `v${version}`;
|
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`;
|
||||||
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/${releaseTag}">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`;
|
|
||||||
|
|
||||||
// 发送到 Telegram
|
// 发送到 Telegram
|
||||||
try {
|
try {
|
||||||
|
|||||||
+36
-20
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.4.7"
|
version = "2.4.5-rc.1"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -22,19 +22,20 @@ tauri-dev = []
|
|||||||
tokio-trace = ["console-subscriber"]
|
tokio-trace = ["console-subscriber"]
|
||||||
clippy = ["tauri/test"]
|
clippy = ["tauri/test"]
|
||||||
tracing = []
|
tracing = []
|
||||||
|
tracing-full = []
|
||||||
|
|
||||||
[package.metadata.bundle]
|
[package.metadata.bundle]
|
||||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.5", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clash-verge-draft = { workspace = true }
|
clash-verge-draft = { workspace = true }
|
||||||
clash-verge-logging = { workspace = true }
|
clash-verge-logging = { workspace = true }
|
||||||
clash-verge-signal = { workspace = true }
|
clash-verge-signal = { workspace = true }
|
||||||
|
clash-verge-types = { workspace = true }
|
||||||
clash-verge-i18n = { workspace = true }
|
clash-verge-i18n = { workspace = true }
|
||||||
clash-verge-limiter = { workspace = true }
|
|
||||||
tauri-plugin-clash-verge-sysinfo = { workspace = true }
|
tauri-plugin-clash-verge-sysinfo = { workspace = true }
|
||||||
tauri-plugin-clipboard-manager = { workspace = true }
|
tauri-plugin-clipboard-manager = { workspace = true }
|
||||||
tauri = { workspace = true, features = [
|
tauri = { workspace = true, features = [
|
||||||
@@ -59,63 +60,78 @@ warp = { version = "0.4.2", features = ["server"] }
|
|||||||
open = "5.3.3"
|
open = "5.3.3"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
nanoid = "0.4"
|
nanoid = "0.4"
|
||||||
chrono = "0.4.43"
|
chrono = "0.4.42"
|
||||||
boa_engine = "0.21.0"
|
boa_engine = "0.21.0"
|
||||||
once_cell = { version = "1.21.3", features = ["parking_lot"] }
|
once_cell = { version = "1.21.3", features = ["parking_lot"] }
|
||||||
delay_timer = "0.11.6"
|
delay_timer = "0.11.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
reqwest = { version = "0.13.2", features = [
|
reqwest = { version = "0.13.1", features = [
|
||||||
"json",
|
"json",
|
||||||
"cookies",
|
"cookies",
|
||||||
"rustls",
|
"rustls",
|
||||||
"form",
|
"form",
|
||||||
] }
|
] }
|
||||||
regex = "1.12.3"
|
regex = "1.12.2"
|
||||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "0.4.5", features = [
|
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [
|
||||||
"guard",
|
"guard",
|
||||||
] }
|
] }
|
||||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||||
tauri-plugin-shell = "2.3.5"
|
tauri-plugin-shell = "2.3.4"
|
||||||
tauri-plugin-dialog = "2.6.0"
|
tauri-plugin-dialog = "2.5.0"
|
||||||
tauri-plugin-fs = "2.4.5"
|
tauri-plugin-fs = "2.4.5"
|
||||||
tauri-plugin-process = "2.3.1"
|
tauri-plugin-process = "2.3.1"
|
||||||
tauri-plugin-deep-link = "2.4.7"
|
tauri-plugin-deep-link = "2.4.6"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
zip = "8.1.0"
|
zip = "7.0.0"
|
||||||
reqwest_dav = "0.3.2"
|
reqwest_dav = "0.2.2"
|
||||||
aes-gcm = { version = "0.10.3", features = ["std"] }
|
aes-gcm = { version = "0.10.3", features = ["std"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
getrandom = "0.4.1"
|
getrandom = "0.3.4"
|
||||||
futures = "0.3.32"
|
futures = "0.3.31"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
tokio-stream = "0.1.18"
|
tokio-stream = "0.1.18"
|
||||||
backoff = { version = "0.4.0", features = ["tokio"] }
|
backoff = { version = "0.4.0", features = ["tokio"] }
|
||||||
tauri-plugin-http = "2.5.7"
|
tauri-plugin-http = "2.5.5"
|
||||||
console-subscriber = { version = "0.5.0", optional = true }
|
console-subscriber = { version = "0.5.0", optional = true }
|
||||||
tauri-plugin-devtools = { version = "2.0.1" }
|
tauri-plugin-devtools = { version = "2.0.1" }
|
||||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo"}
|
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" }
|
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
clash_verge_service_ipc = { version = "2.1.3", features = [
|
clash_verge_service_ipc = { version = "2.0.29", features = [
|
||||||
"client",
|
"client",
|
||||||
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
|
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
|
||||||
arc-swap = "1.8.2"
|
arc-swap = "1.8.0"
|
||||||
rust_iso3166 = "0.1.14"
|
rust_iso3166 = "0.1.14"
|
||||||
# Use the git repo until the next release after v2.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" }
|
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
|
||||||
|
governor = "0.10.4"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
deelevate = { workspace = true }
|
deelevate = { workspace = true }
|
||||||
runas = "=1.2.0"
|
runas = "=1.2.0"
|
||||||
winreg = "0.55.0"
|
winreg = "0.55.0"
|
||||||
windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
winapi = { version = "0.3.9", features = [
|
||||||
|
"winbase",
|
||||||
|
"fileapi",
|
||||||
|
"winnt",
|
||||||
|
"handleapi",
|
||||||
|
"errhandlingapi",
|
||||||
|
"minwindef",
|
||||||
|
"winerror",
|
||||||
|
"stringapiset",
|
||||||
|
"tlhelp32",
|
||||||
|
"processthreadsapi",
|
||||||
|
"winhttp",
|
||||||
|
"winreg",
|
||||||
|
"winnls",
|
||||||
|
] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2.5.1"
|
tauri-plugin-autostart = "2.5.1"
|
||||||
tauri-plugin-global-shortcut = "2.3.1"
|
tauri-plugin-global-shortcut = "2.3.1"
|
||||||
tauri-plugin-updater = "2.10.0"
|
tauri-plugin-updater = "2.9.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
|
|||||||
@@ -45,20 +45,14 @@ pub fn open_web_url(url: String) -> CmdResult<()> {
|
|||||||
/// 打开 Verge 最新日志
|
/// 打开 Verge 最新日志
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_app_log() -> CmdResult<()> {
|
pub async fn open_app_log() -> CmdResult<()> {
|
||||||
let log_path = dirs::app_latest_log().stringify_err()?;
|
open::that(dirs::app_latest_log().stringify_err()?).stringify_err()
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
|
|
||||||
open::that(log_path).stringify_err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用
|
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用
|
||||||
/// 打开 Clash 最新日志
|
/// 打开 Clash 最新日志
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_core_log() -> CmdResult<()> {
|
pub async fn open_core_log() -> CmdResult<()> {
|
||||||
let log_path = dirs::clash_latest_log().stringify_err()?;
|
open::that(dirs::clash_latest_log().stringify_err()?).stringify_err()
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
|
|
||||||
open::that(log_path).stringify_err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开/关闭开发者工具
|
/// 打开/关闭开发者工具
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use super::StringifyErr as _;
|
use super::StringifyErr as _;
|
||||||
use crate::utils::window_manager::WindowManager;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{
|
config::{
|
||||||
Config, IProfiles, PrfItem, PrfOption,
|
Config, IProfiles, PrfItem, PrfOption,
|
||||||
@@ -65,7 +64,7 @@ pub async fn enhance_profiles() -> CmdResult {
|
|||||||
/// 导入配置文件
|
/// 导入配置文件
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
||||||
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url));
|
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", url);
|
||||||
|
|
||||||
// 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹
|
// 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹
|
||||||
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
|
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
|
||||||
@@ -96,18 +95,19 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
|||||||
|
|
||||||
if let Some(uid) = &item.uid {
|
if let Some(uid) = &item.uid {
|
||||||
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
||||||
handle::Handle::notify_profile_changed(uid);
|
handle::Handle::notify_profile_changed(uid.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步保存配置文件并发送全局通知
|
// 异步保存配置文件并发送全局通知
|
||||||
if let Some(uid) = &item.uid {
|
let uid_clone = item.uid.clone();
|
||||||
|
if let Some(uid) = uid_clone {
|
||||||
// 延迟发送,确保文件已完全写入
|
// 延迟发送,确保文件已完全写入
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
||||||
handle::Handle::notify_profile_changed(uid);
|
handle::Handle::notify_profile_changed(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", help::mask_url(&url));
|
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url);
|
||||||
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
|||||||
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||||
match profiles_reorder_safe(&active_id, &over_id).await {
|
match profiles_reorder_safe(&active_id, &over_id).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
logging!(info, Type::Cmd, "重新排序配置文件");
|
logging!(debug, Type::Cmd, "重新排序配置文件");
|
||||||
Config::profiles().await.apply();
|
Config::profiles().await.apply();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
|||||||
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// 发送配置变更通知
|
// 发送配置变更通知
|
||||||
if let Some(uid) = &item.uid {
|
if let Some(uid) = item.uid.clone() {
|
||||||
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
|
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
|
||||||
handle::Handle::notify_profile_changed(uid);
|
handle::Handle::notify_profile_changed(uid);
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
|||||||
/// 更新配置文件
|
/// 更新配置文件
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
||||||
match feat::update_profile(&index, option.as_ref(), true, true, true).await {
|
match feat::update_profile(&index, option.as_ref(), true, true).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _: () = Config::profiles().await.apply();
|
let _: () = Config::profiles().await.apply();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -183,7 +183,7 @@ pub async fn delete_profile(index: String) -> CmdResult {
|
|||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
// 发送配置变更通知
|
// 发送配置变更通知
|
||||||
logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index);
|
logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index);
|
||||||
handle::Handle::notify_profile_changed(&index);
|
handle::Handle::notify_profile_changed(index);
|
||||||
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -310,11 +310,9 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<bool> {
|
|||||||
logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}");
|
logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(current) = current_value
|
if let Some(current) = current_value {
|
||||||
&& WindowManager::get_main_window().is_some()
|
|
||||||
{
|
|
||||||
logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current);
|
logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current);
|
||||||
handle::Handle::notify_profile_changed(current);
|
handle::Handle::notify_profile_changed(current.to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -433,7 +431,7 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
|||||||
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
|
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
|
||||||
} else {
|
} else {
|
||||||
// 刷新成功后发送自定义事件,不触发配置重载
|
// 刷新成功后发送自定义事件,不触发配置重载
|
||||||
crate::core::handle::Handle::notify_timer_updated(&index);
|
crate::core::handle::Handle::notify_timer_updated(index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::{IClashTemp, IProfiles, IVerge};
|
use super::{IClashTemp, IProfiles, IVerge};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{PrfItem, profiles_append_item_safe, runtime::IRuntime},
|
config::{PrfItem, profiles_append_item_safe},
|
||||||
constants::{files, timing},
|
constants::{files, timing},
|
||||||
core::{
|
core::{
|
||||||
CoreManager,
|
CoreManager,
|
||||||
@@ -16,9 +16,9 @@ use anyhow::{Result, anyhow};
|
|||||||
use backoff::{Error as BackoffError, ExponentialBackoff};
|
use backoff::{Error as BackoffError, ExponentialBackoff};
|
||||||
use clash_verge_draft::Draft;
|
use clash_verge_draft::Draft;
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use clash_verge_types::runtime::IRuntime;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{collections::HashSet, path::PathBuf};
|
use std::path::PathBuf;
|
||||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||||
use tokio::sync::OnceCell;
|
use tokio::sync::OnceCell;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
@@ -188,9 +188,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate() -> Result<()> {
|
pub async fn generate() -> Result<()> {
|
||||||
let (mut config, exists_keys, logs) = enhance::enhance().await;
|
let (config, exists_keys, logs) = enhance::enhance().await;
|
||||||
|
|
||||||
sanitize_tunnels_proxy(&mut config);
|
|
||||||
|
|
||||||
Self::runtime().await.edit_draft(|d| {
|
Self::runtime().await.edit_draft(|d| {
|
||||||
*d = IRuntime {
|
*d = IRuntime {
|
||||||
@@ -252,73 +250,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_tunnels_proxy(config: &mut Mapping) {
|
|
||||||
// 检查是否存在 tunnels
|
|
||||||
if !config
|
|
||||||
.get("tunnels")
|
|
||||||
.and_then(|v| v.as_sequence())
|
|
||||||
.is_some_and(|t| tunnels_need_validation(t))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在需要时,收集可用目标(proxies + proxy-groups + 内建)
|
|
||||||
let mut valid: HashSet<String> = HashSet::with_capacity(64);
|
|
||||||
collect_names(config, "proxies", &mut valid);
|
|
||||||
collect_names(config, "proxy-groups", &mut valid);
|
|
||||||
|
|
||||||
valid.insert("DIRECT".into());
|
|
||||||
valid.insert("REJECT".into());
|
|
||||||
|
|
||||||
let Some(tunnels) = config.get_mut("tunnels").and_then(|v| v.as_sequence_mut()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 修改 tunnels:删除无效 proxy
|
|
||||||
for item in tunnels {
|
|
||||||
let Some(tunnel) = item.as_mapping_mut() else { continue };
|
|
||||||
|
|
||||||
let Some(proxy_name) = tunnel.get("proxy").and_then(|v| v.as_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if proxy_name == "DIRECT" || proxy_name == "REJECT" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid.contains(proxy_name) {
|
|
||||||
tunnel.remove("proxy");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tunnels 存在且至少有一条 tunnel 的 proxy 需要校验时才返回 true
|
|
||||||
fn tunnels_need_validation(tunnels: &[Value]) -> bool {
|
|
||||||
tunnels.iter().any(|item| {
|
|
||||||
item.as_mapping()
|
|
||||||
.and_then(|t| t.get("proxy"))
|
|
||||||
.and_then(|p| p.as_str())
|
|
||||||
.is_some_and(|name| name != "DIRECT" && name != "REJECT")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_names(config: &Mapping, list_key: &str, out: &mut HashSet<String>) {
|
|
||||||
let Some(Value::Sequence(seq)) = config.get(list_key) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
for item in seq {
|
|
||||||
let Value::Mapping(map) = item else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(Value::String(n)) = map.get("name")
|
|
||||||
&& !n.is_empty()
|
|
||||||
{
|
|
||||||
out.insert(n.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ConfigType {
|
pub enum ConfigType {
|
||||||
Run,
|
Run,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ mod config;
|
|||||||
mod encrypt;
|
mod encrypt;
|
||||||
mod prfitem;
|
mod prfitem;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
pub mod runtime;
|
|
||||||
mod verge;
|
mod verge;
|
||||||
|
|
||||||
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
|
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ use serde_yaml_ng::Mapping;
|
|||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
// TODO, use other re-export
|
|
||||||
use reqwest_dav::re_exports::url::form_urlencoded;
|
|
||||||
use tauri::Url;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
pub struct PrfItem {
|
pub struct PrfItem {
|
||||||
@@ -63,13 +60,13 @@ pub struct PrfItem {
|
|||||||
pub file_data: Option<String>,
|
pub file_data: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
pub struct PrfSelected {
|
pub struct PrfSelected {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub now: Option<String>,
|
pub now: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)]
|
#[derive(Default, Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
|
||||||
pub struct PrfExtra {
|
pub struct PrfExtra {
|
||||||
pub upload: u64,
|
pub upload: u64,
|
||||||
pub download: u64,
|
pub download: u64,
|
||||||
@@ -127,25 +124,22 @@ pub struct PrfOption {
|
|||||||
impl PrfOption {
|
impl PrfOption {
|
||||||
pub fn merge(one: Option<&Self>, other: Option<&Self>) -> Option<Self> {
|
pub fn merge(one: Option<&Self>, other: Option<&Self>) -> Option<Self> {
|
||||||
match (one, other) {
|
match (one, other) {
|
||||||
(Some(a_ref), Some(b_ref)) => {
|
(Some(a), Some(b)) => Some(Self {
|
||||||
let mut result = a_ref.clone();
|
user_agent: b.user_agent.as_ref().or(a.user_agent.as_ref()).cloned(),
|
||||||
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
|
with_proxy: b.with_proxy.or(a.with_proxy),
|
||||||
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
|
self_proxy: b.self_proxy.or(a.self_proxy),
|
||||||
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
|
danger_accept_invalid_certs: b.danger_accept_invalid_certs.or(a.danger_accept_invalid_certs),
|
||||||
result.danger_accept_invalid_certs =
|
allow_auto_update: b.allow_auto_update.or(a.allow_auto_update),
|
||||||
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
|
update_interval: b.update_interval.or(a.update_interval),
|
||||||
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
|
merge: b.merge.as_ref().or(a.merge.as_ref()).cloned(),
|
||||||
result.update_interval = b_ref.update_interval.or(result.update_interval);
|
script: b.script.as_ref().or(a.script.as_ref()).cloned(),
|
||||||
result.merge = b_ref.merge.clone().or(result.merge);
|
rules: b.rules.as_ref().or(a.rules.as_ref()).cloned(),
|
||||||
result.script = b_ref.script.clone().or(result.script);
|
proxies: b.proxies.as_ref().or(a.proxies.as_ref()).cloned(),
|
||||||
result.rules = b_ref.rules.clone().or(result.rules);
|
groups: b.groups.as_ref().or(a.groups.as_ref()).cloned(),
|
||||||
result.proxies = b_ref.proxies.clone().or(result.proxies);
|
timeout_seconds: b.timeout_seconds.or(a.timeout_seconds),
|
||||||
result.groups = b_ref.groups.clone().or(result.groups);
|
}),
|
||||||
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
|
(Some(a), None) => Some(a.clone()),
|
||||||
Some(result)
|
(None, Some(b)) => Some(b.clone()),
|
||||||
}
|
|
||||||
(Some(a_ref), None) => Some(a_ref.clone()),
|
|
||||||
(None, Some(b_ref)) => Some(b_ref.clone()),
|
|
||||||
(None, None) => None,
|
(None, None) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,17 +275,9 @@ impl PrfItem {
|
|||||||
ProxyType::None
|
ProxyType::None
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = fix_dirty_url(url)?;
|
|
||||||
|
|
||||||
// 使用网络管理器发送请求
|
// 使用网络管理器发送请求
|
||||||
let resp = match NetworkManager::new()
|
let resp = match NetworkManager::new()
|
||||||
.get_with_interrupt(
|
.get_with_interrupt(url, proxy_type, Some(timeout), user_agent.clone(), accept_invalid_certs)
|
||||||
url.as_str(),
|
|
||||||
proxy_type,
|
|
||||||
Some(timeout),
|
|
||||||
user_agent.clone(),
|
|
||||||
accept_invalid_certs,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@@ -351,9 +337,7 @@ impl PrfItem {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => Some(crate::utils::help::get_last_part_and_decode(url).unwrap_or_else(|| "Remote File".into())),
|
||||||
Some(crate::utils::help::get_last_part_and_decode(url.as_str()).unwrap_or_else(|| "Remote File".into()))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let update_interval = match update_interval {
|
let update_interval = match update_interval {
|
||||||
Some(val) => Some(val),
|
Some(val) => Some(val),
|
||||||
@@ -423,7 +407,7 @@ impl PrfItem {
|
|||||||
name: Some(name),
|
name: Some(name),
|
||||||
desc: desc.cloned(),
|
desc: desc.cloned(),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
url: Some(url.as_str().into()),
|
url: Some(url.into()),
|
||||||
selected: None,
|
selected: None,
|
||||||
extra,
|
extra,
|
||||||
option: Some(PrfOption {
|
option: Some(PrfOption {
|
||||||
@@ -582,32 +566,3 @@ impl PrfItem {
|
|||||||
const fn default_allow_auto_update() -> Option<bool> {
|
const fn default_allow_auto_update() -> Option<bool> {
|
||||||
Some(true)
|
Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fix URLs where query parameters are incorrectly appended to the path segment
|
|
||||||
///
|
|
||||||
/// Incorrect Example: https://example.com/path¶m1=value1
|
|
||||||
fn fix_dirty_url(input: &str) -> Result<Url> {
|
|
||||||
let mut url = match Url::parse(input) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(e) => {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"failed to parse deep link url: {:?}, input: {:?}",
|
|
||||||
e,
|
|
||||||
input
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if url.query().is_none() && url.path().contains('&') {
|
|
||||||
let path = url.path().to_string();
|
|
||||||
|
|
||||||
if let Some((clean_path, dirty_params)) = path.split_once('&') {
|
|
||||||
url.set_path(clean_path);
|
|
||||||
|
|
||||||
url.query_pairs_mut()
|
|
||||||
.extend_pairs(form_urlencoded::parse(dirty_params.as_bytes()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|||||||
+242
-261
@@ -5,12 +5,16 @@ use crate::utils::{
|
|||||||
};
|
};
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
|
static PROFILE_FILE_RE: OnceCell<Regex> = OnceCell::new();
|
||||||
|
|
||||||
/// Define the `profiles.yaml` schema
|
/// Define the `profiles.yaml` schema
|
||||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct IProfiles {
|
pub struct IProfiles {
|
||||||
@@ -31,25 +35,21 @@ pub struct IProfilePreview<'a> {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CleanupResult {
|
pub struct CleanupResult {
|
||||||
pub total_files: usize,
|
pub total_files: usize,
|
||||||
pub deleted_files: usize,
|
pub deleted_files: Vec<String>,
|
||||||
pub failed_deletions: usize,
|
pub failed_deletions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! patch {
|
macro_rules! patch {
|
||||||
($lv: expr, $rv: expr, $key: tt) => {
|
($lv: expr, $rv: expr, $key: tt) => {
|
||||||
if ($rv.$key).is_some() {
|
if let Some(ref val) = $rv.$key {
|
||||||
$lv.$key = $rv.$key.to_owned();
|
if Some(val) != $lv.$key.as_ref() {
|
||||||
|
$lv.$key = Some(val.to_owned());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IProfiles {
|
impl IProfiles {
|
||||||
// Helper to find and remove an item by uid from the items vec, returning its file name (if any).
|
|
||||||
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
|
|
||||||
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
|
|
||||||
items.remove(index).file
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn new() -> Self {
|
pub async fn new() -> Self {
|
||||||
let path = match dirs::profiles_path() {
|
let path = match dirs::profiles_path() {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
@@ -59,21 +59,22 @@ impl IProfiles {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match help::read_yaml::<Self>(&path).await {
|
let mut profiles = match help::read_yaml::<Self>(&path).await {
|
||||||
Ok(mut profiles) => {
|
Ok(profiles) => profiles,
|
||||||
let items = profiles.items.get_or_insert_with(Vec::new);
|
|
||||||
for item in items.iter_mut() {
|
|
||||||
if item.uid.is_none() {
|
|
||||||
item.uid = Some(help::get_uid("d").into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
profiles
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(error, Type::Config, "{err}");
|
logging!(error, Type::Config, "{err}");
|
||||||
Self::default()
|
return Self::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = profiles.items.get_or_insert_with(Vec::new);
|
||||||
|
for item in items.iter_mut() {
|
||||||
|
if item.uid.is_none() {
|
||||||
|
item.uid = Some(help::get_uid("d").into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_file(&self) -> Result<()> {
|
pub async fn save_file(&self) -> Result<()> {
|
||||||
@@ -109,38 +110,28 @@ impl IProfiles {
|
|||||||
pub fn get_item(&self, uid: impl AsRef<str>) -> Result<&PrfItem> {
|
pub fn get_item(&self, uid: impl AsRef<str>) -> Result<&PrfItem> {
|
||||||
let uid_str = uid.as_ref();
|
let uid_str = uid.as_ref();
|
||||||
|
|
||||||
if let Some(items) = self.items.as_ref() {
|
self.items
|
||||||
for each in items.iter() {
|
.as_ref()
|
||||||
if let Some(uid_val) = &each.uid
|
.ok_or_else(|| anyhow::anyhow!("no profile items found"))?
|
||||||
&& uid_val.as_str() == uid_str
|
.iter()
|
||||||
{
|
.find(|each| each.uid.as_ref().is_some_and(|uid_val| uid_val.as_str() == uid_str))
|
||||||
return Ok(each);
|
.ok_or_else(|| anyhow::anyhow!("failed to get the profile item \"uid:{}\"", uid_str))
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bail!("failed to get the profile item \"uid:{}\"", uid_str);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// append new item
|
/// append new item
|
||||||
/// if the file_data is some
|
/// if the file_data is some
|
||||||
/// then should save the data to file
|
/// then should save the data to file
|
||||||
pub async fn append_item(&mut self, item: &mut PrfItem) -> Result<()> {
|
pub async fn append_item(&mut self, item: &mut PrfItem) -> Result<()> {
|
||||||
let uid = &item.uid;
|
anyhow::ensure!(item.uid.is_some(), "the uid should not be null");
|
||||||
if uid.is_none() {
|
|
||||||
bail!("the uid should not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
// save the file data
|
// save the file data
|
||||||
// move the field value after save
|
// move the field value after save
|
||||||
if let Some(file_data) = item.file_data.take() {
|
if let Some(file_data) = item.file_data.take() {
|
||||||
if item.file.is_none() {
|
anyhow::ensure!(item.file.is_some(), "the file should not be null");
|
||||||
bail!("the file should not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = item
|
let file = item
|
||||||
.file
|
.file
|
||||||
.clone()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("file field is required when file_data is provided"))?;
|
.ok_or_else(|| anyhow::anyhow!("file field is required when file_data is provided"))?;
|
||||||
let path = dirs::app_profiles_dir()?.join(file.as_str());
|
let path = dirs::app_profiles_dir()?.join(file.as_str());
|
||||||
|
|
||||||
@@ -149,111 +140,116 @@ impl IProfiles {
|
|||||||
.with_context(|| format!("failed to write to file \"{file}\""))?;
|
.with_context(|| format!("failed to write to file \"{file}\""))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.current.is_none() && (item.itype == Some("remote".into()) || item.itype == Some("local".into())) {
|
if self.current.is_none()
|
||||||
self.current = uid.to_owned();
|
&& let Some(t) = item.itype.as_deref()
|
||||||
|
&& (t == "remote" || t == "local")
|
||||||
|
{
|
||||||
|
self.current = item.uid.to_owned();
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.items.is_none() {
|
self.items.get_or_insert_default().push(std::mem::take(item));
|
||||||
self.items = Some(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(items) = self.items.as_mut() {
|
|
||||||
items.push(item.to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// reorder items
|
/// reorder items
|
||||||
pub async fn reorder(&mut self, active_id: &String, over_id: &String) -> Result<()> {
|
pub async fn reorder(&mut self, active_id: &str, over_id: &str) -> Result<()> {
|
||||||
let mut items = self.items.take().unwrap_or_default();
|
if active_id == over_id {
|
||||||
let mut old_index = None;
|
return Ok(());
|
||||||
let mut new_index = None;
|
}
|
||||||
|
|
||||||
for (i, _) in items.iter().enumerate() {
|
let Some(items) = self.items.as_mut() else {
|
||||||
if items[i].uid.as_ref() == Some(active_id) {
|
return Ok(());
|
||||||
old_index = Some(i);
|
};
|
||||||
|
|
||||||
|
let mut old_idx = None;
|
||||||
|
let mut new_idx = None;
|
||||||
|
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
if let Some(uid) = item.uid.as_ref() {
|
||||||
|
if uid == active_id {
|
||||||
|
old_idx = Some(i);
|
||||||
|
}
|
||||||
|
if uid == over_id {
|
||||||
|
new_idx = Some(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if items[i].uid.as_ref() == Some(over_id) {
|
if old_idx.is_some() && new_idx.is_some() {
|
||||||
new_index = Some(i);
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (old_idx, new_idx) = match (old_index, new_index) {
|
if let (Some(old), Some(new)) = (old_idx, new_idx) {
|
||||||
(Some(old), Some(new)) => (old, new),
|
if old < new {
|
||||||
_ => return Ok(()),
|
items[old..=new].rotate_left(1);
|
||||||
};
|
} else {
|
||||||
let item = items.remove(old_idx);
|
items[new..=old].rotate_right(1);
|
||||||
items.insert(new_idx, item);
|
}
|
||||||
self.items = Some(items);
|
|
||||||
self.save_file().await
|
return self.save_file().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// update the item value
|
/// update the item value
|
||||||
pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> {
|
pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> {
|
||||||
let mut items = self.items.take().unwrap_or_default();
|
let items = self
|
||||||
|
.items
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no profile items found"))?;
|
||||||
|
|
||||||
for each in items.iter_mut() {
|
let target = items.iter_mut().find(|each| each.uid.as_ref() == Some(uid));
|
||||||
if each.uid.as_ref() == Some(uid) {
|
|
||||||
patch!(each, item, itype);
|
|
||||||
patch!(each, item, name);
|
|
||||||
patch!(each, item, desc);
|
|
||||||
patch!(each, item, file);
|
|
||||||
patch!(each, item, url);
|
|
||||||
patch!(each, item, selected);
|
|
||||||
patch!(each, item, extra);
|
|
||||||
patch!(each, item, updated);
|
|
||||||
patch!(each, item, option);
|
|
||||||
|
|
||||||
self.items = Some(items);
|
if let Some(each) = target {
|
||||||
return self.save_file().await;
|
patch!(each, item, itype);
|
||||||
}
|
patch!(each, item, name);
|
||||||
|
patch!(each, item, desc);
|
||||||
|
patch!(each, item, file);
|
||||||
|
patch!(each, item, url);
|
||||||
|
patch!(each, item, selected);
|
||||||
|
patch!(each, item, extra);
|
||||||
|
patch!(each, item, updated);
|
||||||
|
patch!(each, item, option);
|
||||||
|
|
||||||
|
return self.save_file().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.items = Some(items);
|
|
||||||
bail!("failed to find the profile item \"uid:{uid}\"")
|
bail!("failed to find the profile item \"uid:{uid}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// be used to update the remote item
|
/// be used to update the remote item
|
||||||
/// only patch `updated` `extra` `file_data`
|
/// only patch `updated` `extra` `file_data`
|
||||||
pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> {
|
pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> {
|
||||||
if self.items.is_none() {
|
let target = self
|
||||||
self.items = Some(vec![]);
|
.items
|
||||||
}
|
.get_or_insert_default()
|
||||||
|
.iter_mut()
|
||||||
|
.find(|each| each.uid.as_ref() == Some(uid))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Item not found"))?;
|
||||||
|
|
||||||
// find the item
|
target.extra = item.extra;
|
||||||
let _ = self.get_item(uid)?;
|
target.updated = item.updated;
|
||||||
|
target.home = std::mem::take(&mut item.home);
|
||||||
|
target.option = PrfOption::merge(target.option.as_ref(), item.option.as_ref());
|
||||||
|
|
||||||
if let Some(items) = self.items.as_mut() {
|
let Some(file_data) = item.file_data.take() else {
|
||||||
let some_uid = Some(uid.clone());
|
return self.save_file().await;
|
||||||
|
};
|
||||||
|
|
||||||
for each in items.iter_mut() {
|
let file = target
|
||||||
if each.uid == some_uid {
|
.file
|
||||||
each.extra = item.extra;
|
.take()
|
||||||
each.updated = item.updated;
|
.or_else(|| item.file.take())
|
||||||
each.home = item.home.to_owned();
|
.unwrap_or_else(|| format!("{}.yaml", uid).into());
|
||||||
each.option = PrfOption::merge(each.option.as_ref(), item.option.as_ref());
|
|
||||||
// save the file data
|
|
||||||
// move the field value after save
|
|
||||||
if let Some(file_data) = item.file_data.take() {
|
|
||||||
let file = each.file.take();
|
|
||||||
let file =
|
|
||||||
file.unwrap_or_else(|| item.file.take().unwrap_or_else(|| format!("{}.yaml", &uid).into()));
|
|
||||||
|
|
||||||
// the file must exists
|
let path = dirs::app_profiles_dir()?.join(file.as_str());
|
||||||
each.file = Some(file.clone());
|
|
||||||
|
|
||||||
let path = dirs::app_profiles_dir()?.join(file.as_str());
|
fs::write(&path, file_data.as_bytes())
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to write to file \"{file}\""))?;
|
||||||
|
|
||||||
fs::write(&path, file_data.as_bytes())
|
target.file = Some(file);
|
||||||
.await
|
|
||||||
.with_context(|| format!("failed to write to file \"{file}\""))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.save_file().await
|
self.save_file().await
|
||||||
}
|
}
|
||||||
@@ -261,73 +257,82 @@ impl IProfiles {
|
|||||||
/// delete item
|
/// delete item
|
||||||
/// if delete the current then return true
|
/// if delete the current then return true
|
||||||
pub async fn delete_item(&mut self, uid: &String) -> Result<bool> {
|
pub async fn delete_item(&mut self, uid: &String) -> Result<bool> {
|
||||||
let current = self.current.as_ref().unwrap_or(uid);
|
let uids_to_remove: HashSet<String> = {
|
||||||
let current = current.clone();
|
|
||||||
let (merge_uid, script_uid, rules_uid, proxies_uid, groups_uid) = {
|
|
||||||
let item = self.get_item(uid)?;
|
let item = self.get_item(uid)?;
|
||||||
let option = item.option.as_ref();
|
let mut set = HashSet::new();
|
||||||
(
|
set.insert(uid.clone());
|
||||||
option.and_then(|e| e.merge.clone()),
|
|
||||||
option.and_then(|e| e.script.clone()),
|
|
||||||
option.and_then(|e| e.rules.clone()),
|
|
||||||
option.and_then(|e| e.proxies.clone()),
|
|
||||||
option.and_then(|e| e.groups.clone()),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mut items = self.items.take().unwrap_or_default();
|
|
||||||
|
|
||||||
// remove the main item (if exists) and delete its file
|
if let Some(opt) = &item.option {
|
||||||
if let Some(file) = Self::take_item_file_by_uid(&mut items, Some(uid.as_str())) {
|
if let Some(u) = &opt.merge {
|
||||||
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
|
set.insert(u.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(u) = &opt.script {
|
||||||
// remove related extension items (merge, script, rules, proxies, groups)
|
set.insert(u.clone());
|
||||||
if let Some(file) = Self::take_item_file_by_uid(&mut items, merge_uid.as_deref()) {
|
}
|
||||||
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
|
if let Some(u) = &opt.rules {
|
||||||
}
|
set.insert(u.clone());
|
||||||
if let Some(file) = Self::take_item_file_by_uid(&mut items, script_uid.as_deref()) {
|
}
|
||||||
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
|
if let Some(u) = &opt.proxies {
|
||||||
}
|
set.insert(u.clone());
|
||||||
if let Some(file) = Self::take_item_file_by_uid(&mut items, rules_uid.as_deref()) {
|
}
|
||||||
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
|
if let Some(u) = &opt.groups {
|
||||||
}
|
set.insert(u.clone());
|
||||||
if let Some(file) = Self::take_item_file_by_uid(&mut items, proxies_uid.as_deref()) {
|
|
||||||
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
|
|
||||||
}
|
|
||||||
if let Some(file) = Self::take_item_file_by_uid(&mut items, groups_uid.as_deref()) {
|
|
||||||
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
|
|
||||||
}
|
|
||||||
// delete the original uid
|
|
||||||
if current == *uid {
|
|
||||||
self.current = None;
|
|
||||||
for item in items.iter() {
|
|
||||||
if item.itype == Some("remote".into()) || item.itype == Some("local".into()) {
|
|
||||||
self.current = item.uid.clone();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
set
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items = self.items.take().unwrap_or_default();
|
||||||
|
let mut deleted_files = Vec::new();
|
||||||
|
|
||||||
|
items.retain_mut(|item| {
|
||||||
|
if let Some(item_uid) = item.uid.as_ref()
|
||||||
|
&& uids_to_remove.contains(item_uid)
|
||||||
|
{
|
||||||
|
if let Some(file) = item.file.take() {
|
||||||
|
deleted_files.push(file);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
|
||||||
|
let is_deleting_current = self.current.as_ref() == Some(uid);
|
||||||
|
if is_deleting_current {
|
||||||
|
self.current = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.itype.as_deref() == Some("remote") || i.itype.as_deref() == Some("local"))
|
||||||
|
.and_then(|i| i.uid.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.items = Some(items);
|
self.items = Some(items);
|
||||||
|
|
||||||
|
if let Ok(profile_dir) = dirs::app_profiles_dir() {
|
||||||
|
for file in deleted_files {
|
||||||
|
let _ = profile_dir.join(file.as_str()).remove_if_exists().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.save_file().await?;
|
self.save_file().await?;
|
||||||
Ok(current == *uid)
|
Ok(is_deleting_current)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取current指向的订阅内容
|
/// 获取current指向的订阅内容
|
||||||
pub async fn current_mapping(&self) -> Result<Mapping> {
|
pub async fn current_mapping(&self) -> Result<Mapping> {
|
||||||
match (self.current.as_ref(), self.items.as_ref()) {
|
let (Some(current), Some(items)) = (self.current.as_ref(), self.items.as_ref()) else {
|
||||||
(Some(current), Some(items)) => {
|
return Ok(Mapping::new());
|
||||||
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
|
};
|
||||||
let file_path = match item.file.as_ref() {
|
|
||||||
Some(file) => dirs::app_profiles_dir()?.join(file.as_str()),
|
let Some(target) = items.iter().find(|e| e.uid.as_ref() == Some(current)) else {
|
||||||
None => bail!("failed to get the file field"),
|
bail!("failed to find the current profile \"uid:{current}\"");
|
||||||
};
|
};
|
||||||
return help::read_mapping(&file_path).await;
|
|
||||||
}
|
let file = target
|
||||||
bail!("failed to find the current profile \"uid:{current}\"");
|
.file
|
||||||
}
|
.as_ref()
|
||||||
_ => Ok(Mapping::new()),
|
.ok_or_else(|| anyhow::anyhow!("failed to get the file field"))?;
|
||||||
}
|
let file_path = dirs::app_profiles_dir()?.join(file.as_str());
|
||||||
|
help::read_mapping(&file_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断profile是否是current指向的
|
/// 判断profile是否是current指向的
|
||||||
@@ -337,40 +342,44 @@ impl IProfiles {
|
|||||||
|
|
||||||
/// 获取所有的profiles(uid,名称, 是否为 current)
|
/// 获取所有的profiles(uid,名称, 是否为 current)
|
||||||
pub fn profiles_preview(&self) -> Option<Vec<IProfilePreview<'_>>> {
|
pub fn profiles_preview(&self) -> Option<Vec<IProfilePreview<'_>>> {
|
||||||
self.items.as_ref().map(|items| {
|
let items = self.items.as_ref()?;
|
||||||
items
|
let current_uid = self.current.as_ref();
|
||||||
.iter()
|
|
||||||
.filter_map(|e| {
|
let previews = items
|
||||||
if let (Some(uid), Some(name)) = (e.uid.as_ref(), e.name.as_ref()) {
|
.iter()
|
||||||
let is_current = self.is_current_profile_index(uid);
|
.filter_map(|e| {
|
||||||
let preview = IProfilePreview { uid, name, is_current };
|
let uid = e.uid.as_ref()?;
|
||||||
Some(preview)
|
let name = e.name.as_ref()?;
|
||||||
} else {
|
Some(IProfilePreview {
|
||||||
None
|
uid,
|
||||||
}
|
name,
|
||||||
|
is_current: current_uid == Some(uid),
|
||||||
})
|
})
|
||||||
.collect()
|
})
|
||||||
})
|
.collect();
|
||||||
|
|
||||||
|
Some(previews)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通过 uid 获取名称
|
/// 通过 uid 获取名称
|
||||||
pub fn get_name_by_uid(&self, uid: &String) -> Option<&String> {
|
pub fn get_name_by_uid(&self, uid: &str) -> Option<&String> {
|
||||||
if let Some(items) = &self.items {
|
self.items
|
||||||
for item in items {
|
.as_ref()?
|
||||||
if item.uid.as_ref() == Some(uid) {
|
.iter()
|
||||||
return item.name.as_ref();
|
.find(|item| item.uid.as_deref() == Some(uid))
|
||||||
}
|
.and_then(|item| item.name.as_ref())
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 以 app 中的 profile 列表为准,删除不再需要的文件
|
/// 以 app 中的 profile 列表为准,删除不再需要的文件
|
||||||
pub async fn cleanup_orphaned_files(&self) -> Result<()> {
|
pub async fn cleanup_orphaned_files(&self) -> Result<CleanupResult> {
|
||||||
let profiles_dir = dirs::app_profiles_dir()?;
|
let profiles_dir = dirs::app_profiles_dir()?;
|
||||||
|
|
||||||
if !profiles_dir.exists() {
|
if !profiles_dir.exists() {
|
||||||
return Ok(());
|
return Ok(CleanupResult {
|
||||||
|
total_files: 0,
|
||||||
|
deleted_files: vec![],
|
||||||
|
failed_deletions: vec![],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有 active profile 的文件名集合
|
// 获取所有 active profile 的文件名集合
|
||||||
@@ -381,11 +390,11 @@ impl IProfiles {
|
|||||||
|
|
||||||
// 扫描 profiles 目录下的所有文件
|
// 扫描 profiles 目录下的所有文件
|
||||||
let mut total_files = 0;
|
let mut total_files = 0;
|
||||||
let mut deleted_files = 0;
|
let mut deleted_files = vec![];
|
||||||
let mut failed_deletions = 0;
|
let mut failed_deletions = vec![];
|
||||||
|
|
||||||
let mut dir_entries = tokio::fs::read_dir(&profiles_dir).await?;
|
for entry in std::fs::read_dir(&profiles_dir)? {
|
||||||
while let Some(entry) = dir_entries.next_entry().await? {
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
@@ -407,11 +416,11 @@ impl IProfiles {
|
|||||||
if !active_files.contains(file_name) {
|
if !active_files.contains(file_name) {
|
||||||
match path.to_path_buf().remove_if_exists().await {
|
match path.to_path_buf().remove_if_exists().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
deleted_files += 1;
|
deleted_files.push(file_name.into());
|
||||||
logging!(debug, Type::Config, "已清理冗余文件: {file_name}");
|
logging!(debug, Type::Config, "已清理冗余文件: {file_name}");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
failed_deletions += 1;
|
failed_deletions.push(format!("{file_name}: {e}").into());
|
||||||
logging!(warn, Type::Config, "Warning: 清理文件失败: {file_name} - {e}");
|
logging!(warn, Type::Config, "Warning: 清理文件失败: {file_name} - {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,11 +439,11 @@ impl IProfiles {
|
|||||||
Type::Config,
|
Type::Config,
|
||||||
"Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}",
|
"Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}",
|
||||||
result.total_files,
|
result.total_files,
|
||||||
result.deleted_files,
|
result.deleted_files.len(),
|
||||||
result.failed_deletions
|
result.failed_deletions.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不删除全局扩展配置
|
/// 不删除全局扩展配置
|
||||||
@@ -449,59 +458,39 @@ impl IProfiles {
|
|||||||
|
|
||||||
/// 获取所有 active profile 关联的文件名
|
/// 获取所有 active profile 关联的文件名
|
||||||
fn get_all_active_files(&self) -> HashSet<&str> {
|
fn get_all_active_files(&self) -> HashSet<&str> {
|
||||||
let mut active_files: HashSet<&str> = HashSet::new();
|
let mut active_files = HashSet::new();
|
||||||
|
let items = match &self.items {
|
||||||
|
Some(i) => i,
|
||||||
|
None => return active_files,
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(items) = &self.items {
|
let item_map: HashMap<Option<&str>, &PrfItem> = items.iter().map(|i| (i.uid.as_deref(), i)).collect();
|
||||||
for item in items {
|
|
||||||
// 收集所有类型 profile 的文件
|
|
||||||
if let Some(file) = &item.file {
|
|
||||||
active_files.insert(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对于主 profile 类型(remote/local),还需要收集其关联的扩展文件
|
for item in items {
|
||||||
if let Some(itype) = &item.itype
|
if let Some(f) = &item.file {
|
||||||
&& (itype == "remote" || itype == "local")
|
active_files.insert(f.as_str());
|
||||||
&& let Some(option) = &item.option
|
}
|
||||||
|
|
||||||
|
let Some(opt) = &item.option else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let related = [
|
||||||
|
opt.merge.as_deref(),
|
||||||
|
opt.script.as_deref(),
|
||||||
|
opt.rules.as_deref(),
|
||||||
|
opt.proxies.as_deref(),
|
||||||
|
opt.groups.as_deref(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for r_uid in related.into_iter().flatten() {
|
||||||
|
if let Some(r_item) = item_map.get(&Some(r_uid))
|
||||||
|
&& let Some(f) = &r_item.file
|
||||||
{
|
{
|
||||||
// 收集关联的扩展文件
|
active_files.insert(f.as_str());
|
||||||
if let Some(merge_uid) = &option.merge
|
|
||||||
&& let Ok(merge_item) = self.get_item(merge_uid)
|
|
||||||
&& let Some(file) = &merge_item.file
|
|
||||||
{
|
|
||||||
active_files.insert(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(script_uid) = &option.script
|
|
||||||
&& let Ok(script_item) = self.get_item(script_uid)
|
|
||||||
&& let Some(file) = &script_item.file
|
|
||||||
{
|
|
||||||
active_files.insert(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rules_uid) = &option.rules
|
|
||||||
&& let Ok(rules_item) = self.get_item(rules_uid)
|
|
||||||
&& let Some(file) = &rules_item.file
|
|
||||||
{
|
|
||||||
active_files.insert(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(proxies_uid) = &option.proxies
|
|
||||||
&& let Ok(proxies_item) = self.get_item(proxies_uid)
|
|
||||||
&& let Some(file) = &proxies_item.file
|
|
||||||
{
|
|
||||||
active_files.insert(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(groups_uid) = &option.groups
|
|
||||||
&& let Ok(groups_item) = self.get_item(groups_uid)
|
|
||||||
&& let Some(file) = &groups_item.file
|
|
||||||
{
|
|
||||||
active_files.insert(file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
active_files
|
active_files
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,18 +505,10 @@ impl IProfiles {
|
|||||||
// p12345678.yaml (proxies)
|
// p12345678.yaml (proxies)
|
||||||
// g12345678.yaml (groups)
|
// g12345678.yaml (groups)
|
||||||
|
|
||||||
let patterns = [
|
#[allow(clippy::unwrap_used)]
|
||||||
r"^[RL][a-zA-Z0-9]+\.yaml$", // Remote/Local profiles
|
let re = PROFILE_FILE_RE
|
||||||
r"^m[a-zA-Z0-9]+\.yaml$", // Merge files
|
.get_or_init(|| Regex::new(r"^(?:[RLmprg][a-zA-Z0-9_-]+\.yaml|s[a-zA-Z0-9_-]+\.js)$").unwrap());
|
||||||
r"^s[a-zA-Z0-9]+\.js$", // Script files
|
re.is_match(filename)
|
||||||
r"^[rpg][a-zA-Z0-9]+\.yaml$", // Rules/Proxies/Groups files
|
|
||||||
];
|
|
||||||
|
|
||||||
patterns.iter().any(|pattern| {
|
|
||||||
regex::Regex::new(pattern)
|
|
||||||
.map(|re| re.is_match(filename))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,6 @@ pub struct IVerge {
|
|||||||
/// 是否自动检测当前节点延迟
|
/// 是否自动检测当前节点延迟
|
||||||
pub enable_auto_delay_detection: Option<bool>,
|
pub enable_auto_delay_detection: Option<bool>,
|
||||||
|
|
||||||
/// 自动检测当前节点延迟的间隔(分钟)
|
|
||||||
pub auto_delay_detection_interval_minutes: Option<u64>,
|
|
||||||
|
|
||||||
/// 是否使用内部的脚本支持,默认为真
|
/// 是否使用内部的脚本支持,默认为真
|
||||||
pub enable_builtin_enhanced: Option<bool>,
|
pub enable_builtin_enhanced: Option<bool>,
|
||||||
|
|
||||||
@@ -526,7 +523,6 @@ impl IVerge {
|
|||||||
patch!(default_latency_test);
|
patch!(default_latency_test);
|
||||||
patch!(default_latency_timeout);
|
patch!(default_latency_timeout);
|
||||||
patch!(enable_auto_delay_detection);
|
patch!(enable_auto_delay_detection);
|
||||||
patch!(auto_delay_detection_interval_minutes);
|
|
||||||
patch!(enable_builtin_enhanced);
|
patch!(enable_builtin_enhanced);
|
||||||
patch!(proxy_layout_column);
|
patch!(proxy_layout_column);
|
||||||
patch!(test_list);
|
patch!(test_list);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod timing {
|
|||||||
use super::Duration;
|
use super::Duration;
|
||||||
|
|
||||||
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300);
|
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
pub const EVENT_EMIT_DELAY: Duration = Duration::from_millis(20);
|
||||||
pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
|
pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
use tauri_plugin_http::reqwest as tauri_reqwest;
|
||||||
use tokio::{fs, time::timeout};
|
use tokio::{fs, time::timeout};
|
||||||
use zip::write::SimpleFileOptions;
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
@@ -110,12 +111,12 @@ impl WebDavClient {
|
|||||||
// 创建新的客户端
|
// 创建新的客户端
|
||||||
let client = reqwest_dav::ClientBuilder::new()
|
let client = reqwest_dav::ClientBuilder::new()
|
||||||
.set_agent(
|
.set_agent(
|
||||||
reqwest::Client::builder()
|
tauri_reqwest::Client::builder()
|
||||||
.use_rustls_tls()
|
.use_rustls_tls()
|
||||||
.danger_accept_invalid_certs(true)
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(Duration::from_secs(op.timeout()))
|
.timeout(Duration::from_secs(op.timeout()))
|
||||||
.user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
|
.user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
|
||||||
.redirect(reqwest::redirect::Policy::custom(|attempt| {
|
.redirect(tauri_reqwest::redirect::Policy::custom(|attempt| {
|
||||||
// 允许所有请求类型的重定向,包括PUT
|
// 允许所有请求类型的重定向,包括PUT
|
||||||
if attempt.previous().len() >= 5 {
|
if attempt.previous().len() >= 5 {
|
||||||
attempt.error("重定向次数过多")
|
attempt.error("重定向次数过多")
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
use crate::{APP_HANDLE, singleton};
|
use crate::{APP_HANDLE, singleton, utils::window_manager::WindowManager};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::{
|
||||||
use tauri::AppHandle;
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
use tauri::{AppHandle, Manager as _, WebviewWindow};
|
||||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
||||||
use tokio::sync::RwLockReadGuard;
|
use tokio::sync::RwLockReadGuard;
|
||||||
|
|
||||||
@@ -10,12 +14,14 @@ use super::notification::{FrontendEvent, NotificationSystem};
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Handle {
|
pub struct Handle {
|
||||||
is_exiting: AtomicBool,
|
is_exiting: AtomicBool,
|
||||||
|
pub(crate) notification_system: Arc<RwLock<Option<NotificationSystem>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Handle {
|
impl Default for Handle {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
is_exiting: AtomicBool::new(false),
|
is_exiting: AtomicBool::new(false),
|
||||||
|
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +33,19 @@ impl Handle {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn init(&self) {
|
||||||
|
if self.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut system_opt = self.notification_system.write();
|
||||||
|
if let Some(system) = system_opt.as_mut()
|
||||||
|
&& !system.is_running()
|
||||||
|
{
|
||||||
|
system.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn app_handle() -> &'static AppHandle {
|
pub fn app_handle() -> &'static AppHandle {
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
APP_HANDLE.get().expect("App handle not initialized")
|
APP_HANDLE.get().expect("App handle not initialized")
|
||||||
@@ -36,34 +55,66 @@ impl Handle {
|
|||||||
Self::app_handle().mihomo().read().await
|
Self::app_handle().mihomo().read().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_window() -> Option<WebviewWindow> {
|
||||||
|
Self::app_handle().get_webview_window("main")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn refresh_clash() {
|
pub fn refresh_clash() {
|
||||||
Self::send_event(FrontendEvent::RefreshClash);
|
let handle = Self::global();
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::RefreshClash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_verge() {
|
pub fn refresh_verge() {
|
||||||
Self::send_event(FrontendEvent::RefreshVerge);
|
let handle = Self::global();
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::RefreshVerge);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notify_profile_changed(profile_id: &String) {
|
pub fn notify_profile_changed(profile_id: String) {
|
||||||
Self::send_event(FrontendEvent::ProfileChanged {
|
Self::send_event(FrontendEvent::ProfileChanged {
|
||||||
current_profile_id: profile_id,
|
current_profile_id: profile_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notify_timer_updated(profile_index: &String) {
|
pub fn notify_timer_updated(profile_index: String) {
|
||||||
Self::send_event(FrontendEvent::TimerUpdated { profile_index });
|
Self::send_event(FrontendEvent::TimerUpdated { profile_index });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notify_profile_update_started(uid: &String) {
|
pub fn notify_profile_update_started(uid: String) {
|
||||||
Self::send_event(FrontendEvent::ProfileUpdateStarted { uid });
|
Self::send_event(FrontendEvent::ProfileUpdateStarted { uid });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notify_profile_update_completed(uid: &String) {
|
pub fn notify_profile_update_completed(uid: String) {
|
||||||
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notice_message<S: AsRef<str>, M: Into<String>>(status: S, msg: M) {
|
// TODO 利用 &str 等缩短 Clone
|
||||||
let status_str = status.as_ref();
|
pub fn notice_message<S: Into<String>, M: Into<String>>(status: S, msg: M) {
|
||||||
|
let handle = Self::global();
|
||||||
|
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only send notice when main window exists
|
||||||
|
if WindowManager::get_main_window().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status_str = status.into();
|
||||||
let msg_str = msg.into();
|
let msg_str = msg.into();
|
||||||
|
|
||||||
Self::send_event(FrontendEvent::NoticeMessage {
|
Self::send_event(FrontendEvent::NoticeMessage {
|
||||||
@@ -72,21 +123,29 @@ impl Handle {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_is_exiting(&self) {
|
|
||||||
self.is_exiting.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_exiting(&self) -> bool {
|
|
||||||
self.is_exiting.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_event(event: FrontendEvent) {
|
fn send_event(event: FrontendEvent) {
|
||||||
let handle = Self::global();
|
let handle = Self::global();
|
||||||
if handle.is_exiting() {
|
if handle.is_exiting() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationSystem::send_event(event);
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_is_exiting(&self) {
|
||||||
|
self.is_exiting.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
let mut system_opt = self.notification_system.write();
|
||||||
|
if let Some(system) = system_opt.as_mut() {
|
||||||
|
system.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_exiting(&self) -> bool {
|
||||||
|
self.is_exiting.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::process::AsyncHandler;
|
use crate::process::AsyncHandler;
|
||||||
use crate::singleton;
|
use crate::singleton;
|
||||||
use crate::utils::notification::{NotificationEvent, notify_event};
|
use crate::utils::notification::{NotificationEvent, notify_event};
|
||||||
use crate::utils::window_manager::WindowManager;
|
|
||||||
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
|
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
@@ -244,7 +243,7 @@ impl Hotkey {
|
|||||||
logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey_event);
|
logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey_event);
|
||||||
let hotkey = hotkey_event.key;
|
let hotkey = hotkey_event.key;
|
||||||
if hotkey == Code::KeyQ && is_quit {
|
if hotkey == Code::KeyQ && is_quit {
|
||||||
if let Some(window) = WindowManager::get_main_window()
|
if let Some(window) = handle::Handle::get_window()
|
||||||
&& window.is_focused().unwrap_or(false)
|
&& window.is_focused().unwrap_or(false)
|
||||||
{
|
{
|
||||||
logging!(debug, Type::Hotkey, "Executing quit function");
|
logging!(debug, Type::Hotkey, "Executing quit function");
|
||||||
@@ -261,9 +260,8 @@ impl Hotkey {
|
|||||||
Self::execute_function(function);
|
Self::execute_function(function);
|
||||||
} else {
|
} else {
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
let window = WindowManager::get_main_window();
|
let is_visible = WindowManager::is_main_window_visible();
|
||||||
let is_visible = WindowManager::is_main_window_visible(window.as_ref());
|
let is_focused = WindowManager::is_main_window_focused();
|
||||||
let is_focused = WindowManager::is_main_window_focused(window.as_ref());
|
|
||||||
|
|
||||||
if is_focused && is_visible {
|
if is_focused && is_visible {
|
||||||
Self::execute_function(function);
|
Self::execute_function(function);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ impl Logger {
|
|||||||
self.log_max_size.store(log_max_size, Ordering::SeqCst);
|
self.log_max_size.store(log_max_size, Ordering::SeqCst);
|
||||||
self.log_max_count.store(log_max_count, Ordering::SeqCst);
|
self.log_max_count.store(log_max_count, Ordering::SeqCst);
|
||||||
|
|
||||||
#[cfg(not(any(feature = "tauri-dev", feature = "tokio-trace")))]
|
#[cfg(not(feature = "tauri-dev"))]
|
||||||
{
|
{
|
||||||
let log_spec = Self::generate_log_spec(log_level);
|
let log_spec = Self::generate_log_spec(log_level);
|
||||||
let log_dir = dirs::app_logs_dir()?;
|
let log_dir = dirs::app_logs_dir()?;
|
||||||
@@ -88,9 +88,9 @@ impl Logger {
|
|||||||
|
|
||||||
let mut filter_modules = vec!["wry", "tokio_tungstenite", "tungstenite"];
|
let mut filter_modules = vec!["wry", "tokio_tungstenite", "tungstenite"];
|
||||||
#[cfg(not(feature = "tracing"))]
|
#[cfg(not(feature = "tracing"))]
|
||||||
filter_modules.push("tauri::ipc");
|
filter_modules.push("tauri");
|
||||||
#[cfg(feature = "tracing")]
|
#[cfg(feature = "tracing")]
|
||||||
filter_modules.extend(["kode_bridge"]);
|
filter_modules.extend(["tauri_plugin_mihomo", "kode_bridge"]);
|
||||||
let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules)));
|
let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules)));
|
||||||
|
|
||||||
let handle = logger.start()?;
|
let handle = logger.start()?;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use super::CoreManager;
|
use super::CoreManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, ConfigType, runtime::IRuntime},
|
config::{Config, ConfigType},
|
||||||
constants::timing,
|
constants::timing,
|
||||||
core::{handle, validate::CoreConfigValidator},
|
core::{handle, validate::CoreConfigValidator},
|
||||||
utils::{dirs, help},
|
utils::{dirs, help},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use clash_verge_types::runtime::IRuntime;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{collections::HashSet, path::PathBuf, time::Instant};
|
use std::{collections::HashSet, path::PathBuf, time::Instant};
|
||||||
use tauri_plugin_mihomo::Error as MihomoError;
|
use tauri_plugin_mihomo::Error as MihomoError;
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ impl CoreManager {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
async fn wait_for_service_if_needed(&self) {
|
async fn wait_for_service_if_needed(&self) {
|
||||||
use crate::{config::Config, constants::timing, core::service};
|
use crate::{config::Config, constants::timing};
|
||||||
use backoff::{Error as BackoffError, ExponentialBackoff};
|
use backoff::{Error as BackoffError, ExponentialBackoff};
|
||||||
|
|
||||||
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||||
@@ -108,12 +108,6 @@ impl CoreManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the service IPC path is not ready yet, treat it as transient and retry.
|
|
||||||
// Running init/refresh too early can mark service state unavailable and break later config reloads.
|
|
||||||
if !service::is_service_ipc_path_exists() {
|
|
||||||
return Err(BackoffError::transient(anyhow::anyhow!("Service IPC not ready")));
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.init().await.map_err(BackoffError::transient)?;
|
manager.init().await.map_err(BackoffError::transient)?;
|
||||||
let _ = manager.refresh().await;
|
let _ = manager.refresh().await;
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ impl CoreManager {
|
|||||||
match event {
|
match event {
|
||||||
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
||||||
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
||||||
let message = CompactString::from(&*String::from_utf8_lossy(&line));
|
let message = CompactString::from(String::from_utf8_lossy(&line).as_ref());
|
||||||
Logger::global().writer_sidecar_log(Level::Error, &message);
|
Logger::global().writer_sidecar_log(Level::Error, &message);
|
||||||
CLASH_LOGGER.append_log(message).await;
|
CLASH_LOGGER.append_log(message).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,100 @@
|
|||||||
use crate::utils::window_manager::WindowManager;
|
use super::handle::Handle;
|
||||||
|
use crate::constants::timing;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use serde_json::json;
|
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
|
use std::{sync::mpsc, thread};
|
||||||
use tauri::{Emitter as _, WebviewWindow};
|
use tauri::{Emitter as _, WebviewWindow};
|
||||||
|
|
||||||
#[derive(Debug)]
|
// TODO 重构或优化,避免 Clone 过多
|
||||||
pub enum FrontendEvent<'a> {
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum FrontendEvent {
|
||||||
RefreshClash,
|
RefreshClash,
|
||||||
RefreshVerge,
|
RefreshVerge,
|
||||||
NoticeMessage { status: &'a str, message: String },
|
NoticeMessage { status: String, message: String },
|
||||||
ProfileChanged { current_profile_id: &'a String },
|
ProfileChanged { current_profile_id: String },
|
||||||
TimerUpdated { profile_index: &'a String },
|
TimerUpdated { profile_index: String },
|
||||||
ProfileUpdateStarted { uid: &'a String },
|
ProfileUpdateStarted { uid: String },
|
||||||
ProfileUpdateCompleted { uid: &'a String },
|
ProfileUpdateCompleted { uid: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct NotificationSystem {}
|
pub struct NotificationSystem {
|
||||||
|
sender: Option<mpsc::Sender<FrontendEvent>>,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
worker_handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NotificationSystem {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl NotificationSystem {
|
impl NotificationSystem {
|
||||||
fn emit_to_window(window: &WebviewWindow, event: FrontendEvent) {
|
pub const fn new() -> Self {
|
||||||
let (event_name, Ok(payload)) = Self::serialize_event(event) else {
|
Self {
|
||||||
|
sender: None,
|
||||||
|
worker_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_running(&self) -> bool {
|
||||||
|
self.sender.is_some() && self.worker_handle.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self) {
|
||||||
|
if self.is_running() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.sender = Some(tx);
|
||||||
|
|
||||||
|
//? Do we have to create a new thread for this?
|
||||||
|
let result = thread::Builder::new()
|
||||||
|
.name("frontend-notifier".into())
|
||||||
|
.spawn(move || Self::worker_loop(rx));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(handle) => self.worker_handle = Some(handle),
|
||||||
|
Err(e) => logging!(error, Type::System, "Failed to start notification worker: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_loop(rx: mpsc::Receiver<FrontendEvent>) {
|
||||||
|
let handle = Handle::global();
|
||||||
|
loop {
|
||||||
|
if handle.is_exiting() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match rx.recv() {
|
||||||
|
Ok(event) => Self::process_event(handle, event),
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::System, "Notification System will exit, recv error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_event(handle: &super::handle::Handle, event: FrontendEvent) {
|
||||||
|
let binding = handle.notification_system.read();
|
||||||
|
let system = match binding.as_ref() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(window) = super::handle::Handle::get_window() {
|
||||||
|
system.emit_to_window(&window, event);
|
||||||
|
drop(binding);
|
||||||
|
thread::sleep(timing::EVENT_EMIT_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_to_window(&self, window: &WebviewWindow, event: FrontendEvent) {
|
||||||
|
let (event_name, payload) = self.serialize_event(event);
|
||||||
|
|
||||||
|
let Ok(payload) = payload else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,7 +103,9 @@ impl NotificationSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serialize_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
|
fn serialize_event(&self, event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))),
|
FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))),
|
||||||
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
|
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
|
||||||
@@ -44,9 +119,25 @@ impl NotificationSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn send_event(event: FrontendEvent) {
|
pub fn send_event(&self, event: FrontendEvent) -> bool {
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
if !self.is_running() {
|
||||||
Self::emit_to_window(&window, event);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sender) = &self.sender {
|
||||||
|
sender.send(event).is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(&mut self) {
|
||||||
|
if let Some(sender) = self.sender.take() {
|
||||||
|
drop(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = self.worker_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-112
@@ -3,19 +3,18 @@ use crate::{
|
|||||||
core::{logger::Logger, tray::Tray},
|
core::{logger::Logger, tray::Tray},
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
use anyhow::{Context as _, Result, anyhow, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use clash_verge_service_ipc::CoreConfig;
|
use clash_verge_service_ipc::CoreConfig;
|
||||||
use compact_str::CompactString;
|
use compact_str::CompactString;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
|
||||||
env::current_exe,
|
env::current_exe,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command as StdCommand,
|
process::Command as StdCommand,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::{sync::Mutex, time::sleep};
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ServiceStatus {
|
pub enum ServiceStatus {
|
||||||
@@ -65,7 +64,6 @@ fn uninstall_service() -> Result<()> {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn install_service() -> Result<()> {
|
fn install_service() -> Result<()> {
|
||||||
use std::process::Output;
|
|
||||||
logging!(info, Type::Service, "install service");
|
logging!(info, Type::Service, "install service");
|
||||||
|
|
||||||
use deelevate::{PrivilegeLevel, Token};
|
use deelevate::{PrivilegeLevel, Token};
|
||||||
@@ -81,30 +79,13 @@ fn install_service() -> Result<()> {
|
|||||||
|
|
||||||
let token = Token::with_current_process()?;
|
let token = Token::with_current_process()?;
|
||||||
let level = token.privilege_level()?;
|
let level = token.privilege_level()?;
|
||||||
let output = match level {
|
let status = match level {
|
||||||
PrivilegeLevel::NotPrivileged => {
|
PrivilegeLevel::NotPrivileged => RunasCommand::new(install_path).show(false).status()?,
|
||||||
let status = RunasCommand::new(&install_path).show(false).status()?;
|
_ => StdCommand::new(install_path).creation_flags(0x08000000).status()?,
|
||||||
Output {
|
|
||||||
status,
|
|
||||||
stdout: Vec::new(),
|
|
||||||
stderr: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// StdCommand returns Output directly
|
|
||||||
StdCommand::new(&install_path).creation_flags(0x08000000).output()?
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
if !status.success() {
|
||||||
logging!(
|
bail!("failed to install service with status {}", status.code().unwrap_or(-1));
|
||||||
error,
|
|
||||||
Type::Service,
|
|
||||||
"failed to install service code: {}, details: {}",
|
|
||||||
code,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
bail!("failed to install service code: {}, details: {}", code, err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -179,42 +160,41 @@ fn install_service() -> Result<()> {
|
|||||||
let install_shell: String = install_path.to_string_lossy().replace(" ", "\\ ");
|
let install_shell: String = install_path.to_string_lossy().replace(" ", "\\ ");
|
||||||
|
|
||||||
let elevator = crate::utils::help::linux_elevator();
|
let elevator = crate::utils::help::linux_elevator();
|
||||||
let output = if linux_running_as_root() {
|
let status = if linux_running_as_root() {
|
||||||
StdCommand::new(&install_path).output()?
|
StdCommand::new(&install_path).status()?
|
||||||
} else {
|
} else {
|
||||||
let result = StdCommand::new(&elevator)
|
let result = StdCommand::new(&elevator)
|
||||||
.arg("sh")
|
.arg("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&install_shell)
|
.arg(&install_shell)
|
||||||
.output()?;
|
.status()?;
|
||||||
|
|
||||||
// 如果 pkexec 执行失败,回退到 sudo
|
// 如果 pkexec 执行失败,回退到 sudo
|
||||||
if !result.status.success() && elevator.contains("pkexec") {
|
if !result.success() && elevator.contains("pkexec") {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
"pkexec failed with code {}, falling back to sudo",
|
"pkexec failed with code {}, falling back to sudo",
|
||||||
result.status.code().unwrap_or(-1)
|
result.code().unwrap_or(-1)
|
||||||
);
|
);
|
||||||
StdCommand::new("sudo")
|
StdCommand::new("sudo")
|
||||||
.arg("sh")
|
.arg("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&install_shell)
|
.arg(&install_shell)
|
||||||
.output()?
|
.status()?
|
||||||
} else {
|
} else {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
"install status code:{}",
|
||||||
|
status.code().unwrap_or(-1)
|
||||||
|
);
|
||||||
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
if !status.success() {
|
||||||
logging!(
|
bail!("failed to install service with status {}", status.code().unwrap_or(-1));
|
||||||
error,
|
|
||||||
Type::Service,
|
|
||||||
"failed to install service code: {}, details: {}",
|
|
||||||
code,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
bail!("failed to install service code: {}, details: {}", code, err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -276,43 +256,19 @@ fn install_service() -> Result<()> {
|
|||||||
|
|
||||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
// 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 prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
||||||
let command = format!(
|
let command =
|
||||||
r#"do shell script "sudo CLASH_VERGE_SERVICE_GID={gid} '{install_shell}'" with administrator privileges with prompt "{prompt}""#
|
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
|
||||||
);
|
|
||||||
|
|
||||||
let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
|
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
logging!(
|
if !status.success() {
|
||||||
error,
|
bail!("failed to install service with status {}", status.code().unwrap_or(-1));
|
||||||
Type::Service,
|
|
||||||
"failed to install service code: {}, details: {}",
|
|
||||||
code,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
bail!("failed to install service code: {}, details: {}", code, err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> {
|
|
||||||
if output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let code = output.status.code().unwrap_or(-1);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
if !stderr.is_empty() {
|
|
||||||
return Some((code, stderr));
|
|
||||||
}
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
if !stdout.is_empty() {
|
|
||||||
return Some((code, stdout));
|
|
||||||
}
|
|
||||||
Some((code, Cow::Borrowed("Unknown error")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinstall_service() -> Result<()> {
|
fn reinstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "reinstall service");
|
logging!(info, Type::Service, "reinstall service");
|
||||||
|
|
||||||
@@ -378,7 +334,10 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
|
|||||||
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
||||||
logging!(info, Type::Service, "正在尝试通过服务启动核心");
|
logging!(info, Type::Service, "正在尝试通过服务启动核心");
|
||||||
|
|
||||||
SERVICE_MANAGER.lock().await.refresh().await?;
|
let mut manager = SERVICE_MANAGER.lock().await;
|
||||||
|
let status = manager.check_service_comprehensive().await;
|
||||||
|
manager.handle_service_status(&status).await?;
|
||||||
|
drop(manager);
|
||||||
|
|
||||||
logging!(info, Type::Service, "服务已运行且版本匹配,直接使用");
|
logging!(info, Type::Service, "服务已运行且版本匹配,直接使用");
|
||||||
start_with_existing_service(config_file).await
|
start_with_existing_service(config_file).await
|
||||||
@@ -422,50 +381,20 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
|
|||||||
/// 检查服务是否正在运行
|
/// 检查服务是否正在运行
|
||||||
pub async fn is_service_available() -> Result<()> {
|
pub async fn is_service_available() -> Result<()> {
|
||||||
if let Err(e) = Path::metadata(clash_verge_service_ipc::IPC_PATH.as_ref()) {
|
if let Err(e) = Path::metadata(clash_verge_service_ipc::IPC_PATH.as_ref()) {
|
||||||
let verge = Config::verge().await;
|
logging!(warn, Type::Service, "Some issue with service IPC Path: {}", e);
|
||||||
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
clash_verge_service_ipc::connect().await?;
|
clash_verge_service_ipc::connect().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 等待一会,再检查服务是否正在运行
|
||||||
|
/// TODO 使用 tokio select 之类机制并结合 timeout 实现更优雅的等待机制,期望等待文件出现,再尝试连接
|
||||||
pub async fn wait_and_check_service_available(status: &mut ServiceManager) -> Result<()> {
|
pub async fn wait_and_check_service_available(status: &mut ServiceManager) -> Result<()> {
|
||||||
wait_for_service_ipc(status, "Waiting for service to be available").await
|
status.0 = ServiceStatus::Unavailable("Waiting for service to be available".into());
|
||||||
}
|
clash_verge_service_ipc::connect().await?;
|
||||||
|
status.0 = ServiceStatus::Ready;
|
||||||
async fn wait_for_service_ipc(status: &mut ServiceManager, reason: &str) -> Result<()> {
|
Ok(())
|
||||||
status.0 = ServiceStatus::Unavailable(reason.into());
|
|
||||||
let config = ServiceManager::config();
|
|
||||||
let mut attempts = 0u32;
|
|
||||||
#[allow(unused_assignments)]
|
|
||||||
let mut last_err = anyhow!("service not ready");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if Path::new(clash_verge_service_ipc::IPC_PATH).exists() {
|
|
||||||
match clash_verge_service_ipc::connect().await {
|
|
||||||
Ok(_) => {
|
|
||||||
status.0 = ServiceStatus::Ready;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(e) => last_err = e,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
last_err = anyhow!("IPC path not ready");
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempts >= config.max_retries as u32 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
attempts += 1;
|
|
||||||
sleep(config.retry_delay).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_service_ipc_path_exists() -> bool {
|
pub fn is_service_ipc_path_exists() -> bool {
|
||||||
@@ -479,9 +408,9 @@ impl ServiceManager {
|
|||||||
|
|
||||||
pub const fn config() -> clash_verge_service_ipc::IpcConfig {
|
pub const fn config() -> clash_verge_service_ipc::IpcConfig {
|
||||||
clash_verge_service_ipc::IpcConfig {
|
clash_verge_service_ipc::IpcConfig {
|
||||||
default_timeout: Duration::from_millis(150),
|
default_timeout: Duration::from_millis(100),
|
||||||
retry_delay: Duration::from_millis(250),
|
retry_delay: Duration::from_millis(200),
|
||||||
max_retries: 20,
|
max_retries: 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub struct TimerTask {
|
|||||||
pub last_run: i64, // Timestamp of last execution
|
pub last_run: i64, // Timestamp of last execution
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO 一个 Timer 负责轻量, 一个 Timer 负责订阅更新。当前会生产 N(订阅数量) + 1 个定时任务
|
||||||
pub struct Timer {
|
pub struct Timer {
|
||||||
/// cron manager
|
/// cron manager
|
||||||
pub delay_timer: Arc<RwLock<DelayTimer>>,
|
pub delay_timer: Arc<RwLock<DelayTimer>>,
|
||||||
@@ -409,12 +410,12 @@ impl Timer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Emit update events for frontend notification
|
/// Emit update events for frontend notification
|
||||||
fn emit_update_event(uid: &String, is_start: bool) {
|
fn emit_update_event(_uid: &str, _is_start: bool) {
|
||||||
{
|
{
|
||||||
if is_start {
|
if _is_start {
|
||||||
super::handle::Handle::notify_profile_update_started(uid);
|
super::handle::Handle::notify_profile_update_started(_uid.into());
|
||||||
} else {
|
} else {
|
||||||
super::handle::Handle::notify_profile_update_completed(uid);
|
super::handle::Handle::notify_profile_update_completed(_uid.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,7 +431,7 @@ impl Timer {
|
|||||||
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
||||||
logging!(info, Type::Timer, "配置 {} 是否为当前激活配置: {}", uid, is_current);
|
logging!(info, Type::Timer, "配置 {} 是否为当前激活配置: {}", uid, is_current);
|
||||||
|
|
||||||
feat::update_profile(uid, None, is_current, false, false).await
|
feat::update_profile(uid, None, is_current, false).await
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ pub(crate) enum TrayAction {
|
|||||||
SystemProxy,
|
SystemProxy,
|
||||||
TunMode,
|
TunMode,
|
||||||
MainWindow,
|
MainWindow,
|
||||||
TrayMenu,
|
TrayMenue,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ impl From<&str> for TrayAction {
|
|||||||
"system_proxy" => Self::SystemProxy,
|
"system_proxy" => Self::SystemProxy,
|
||||||
"tun_mode" => Self::TunMode,
|
"tun_mode" => Self::TunMode,
|
||||||
"main_window" => Self::MainWindow,
|
"main_window" => Self::MainWindow,
|
||||||
"tray_menu" => Self::TrayMenu,
|
"tray_menue" => Self::TrayMenue,
|
||||||
_ => Self::Unknown,
|
_ => Self::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-121
@@ -9,9 +9,8 @@ use crate::{
|
|||||||
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
|
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
|
||||||
utils::dirs::find_target_icons,
|
utils::dirs::find_target_icons,
|
||||||
};
|
};
|
||||||
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
|
||||||
use clash_verge_logging::logging_error;
|
use tauri::tray::TrayIconBuilder;
|
||||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
|
||||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||||
use tauri_plugin_mihomo::models::Proxies;
|
use tauri_plugin_mihomo::models::Proxies;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
@@ -20,10 +19,12 @@ use super::handle;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::num::NonZeroU32;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Wry,
|
AppHandle, Wry,
|
||||||
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconEvent},
|
||||||
};
|
};
|
||||||
mod menu_def;
|
mod menu_def;
|
||||||
use menu_def::{MenuIds, MenuTexts};
|
use menu_def::{MenuIds, MenuTexts};
|
||||||
@@ -32,13 +33,13 @@ use menu_def::{MenuIds, MenuTexts};
|
|||||||
|
|
||||||
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
|
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
|
||||||
|
|
||||||
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
|
const TRAY_CLICK_DEBOUNCE_MS: u64 = 1_275;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TrayState {}
|
struct TrayState {}
|
||||||
|
|
||||||
pub struct Tray {
|
pub struct Tray {
|
||||||
limiter: SystemLimiter,
|
limiter: DefaultDirectRateLimiter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayState {
|
impl TrayState {
|
||||||
@@ -135,7 +136,11 @@ impl Default for Tray {
|
|||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
|
limiter: RateLimiter::direct(
|
||||||
|
Quota::with_period(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS))
|
||||||
|
.unwrap()
|
||||||
|
.allow_burst(NonZeroU32::new(1).unwrap()),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +190,7 @@ impl Tray {
|
|||||||
.tray_by_id("main")
|
.tray_by_id("main")
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?;
|
.ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?;
|
||||||
match tray_event {
|
match tray_event {
|
||||||
TrayAction::TrayMenu => tray.set_show_menu_on_left_click(true)?,
|
TrayAction::TrayMenue => tray.set_show_menu_on_left_click(true)?,
|
||||||
_ => tray.set_show_menu_on_left_click(false)?,
|
_ => tray.set_show_menu_on_left_click(false)?,
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -202,11 +207,6 @@ impl Tray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
|
async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
|
||||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
|
||||||
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let verge = Config::verge().await.latest_arc();
|
let verge = Config::verge().await.latest_arc();
|
||||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||||
@@ -227,24 +227,28 @@ impl Tray {
|
|||||||
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
||||||
let is_lightweight_mode = is_in_lightweight_mode();
|
let is_lightweight_mode = is_in_lightweight_mode();
|
||||||
|
|
||||||
logging_error!(
|
match app_handle.tray_by_id("main") {
|
||||||
Type::Tray,
|
Some(tray) => {
|
||||||
tray.set_menu(Some(
|
let _ = tray.set_menu(Some(
|
||||||
create_tray_menu(
|
create_tray_menu(
|
||||||
app_handle,
|
app_handle,
|
||||||
Some(mode.as_str()),
|
Some(mode.as_str()),
|
||||||
*system_proxy,
|
*system_proxy,
|
||||||
*tun_mode,
|
*tun_mode,
|
||||||
tun_mode_available,
|
tun_mode_available,
|
||||||
profiles_preview,
|
profiles_preview,
|
||||||
is_lightweight_mode,
|
is_lightweight_mode,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
))
|
));
|
||||||
);
|
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
||||||
|
Ok(())
|
||||||
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
}
|
||||||
Ok(())
|
None => {
|
||||||
|
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新托盘图标
|
/// 更新托盘图标
|
||||||
@@ -257,9 +261,12 @@ impl Tray {
|
|||||||
|
|
||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
|
|
||||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
let tray = match app_handle.tray_by_id("main") {
|
||||||
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
|
Some(tray) => tray,
|
||||||
return Ok(());
|
None => {
|
||||||
|
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
|
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
|
||||||
@@ -267,11 +274,8 @@ impl Tray {
|
|||||||
let colorful = verge.tray_icon.clone().unwrap_or_else(|| "monochrome".into());
|
let colorful = verge.tray_icon.clone().unwrap_or_else(|| "monochrome".into());
|
||||||
let is_colorful = colorful == "colorful";
|
let is_colorful = colorful == "colorful";
|
||||||
|
|
||||||
logging_error!(
|
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
|
||||||
Type::Tray,
|
let _ = tray.set_icon_as_template(!is_colorful);
|
||||||
tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?))
|
|
||||||
);
|
|
||||||
logging_error!(Type::Tray, tray.set_icon_as_template(!is_colorful));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,17 +288,17 @@ impl Tray {
|
|||||||
|
|
||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
|
|
||||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
let tray = match app_handle.tray_by_id("main") {
|
||||||
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
|
Some(tray) => tray,
|
||||||
return Ok(());
|
None => {
|
||||||
|
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
|
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
|
||||||
|
|
||||||
logging_error!(
|
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
|
||||||
Type::Tray,
|
|
||||||
tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?))
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,11 +312,14 @@ impl Tray {
|
|||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
|
|
||||||
let verge = Config::verge().await.latest_arc();
|
let verge = Config::verge().await.latest_arc();
|
||||||
let system_proxy = verge.enable_system_proxy.unwrap_or(false);
|
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||||
let tun_mode = verge.enable_tun_mode.unwrap_or(false);
|
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||||
|
|
||||||
let switch_str = |flag: bool| {
|
let switch_map = {
|
||||||
if flag { "on" } else { "off" }
|
let mut map = std::collections::HashMap::new();
|
||||||
|
map.insert(true, "on");
|
||||||
|
map.insert(false, "off");
|
||||||
|
map
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut current_profile_name = "None".into();
|
let mut current_profile_name = "None".into();
|
||||||
@@ -344,19 +351,18 @@ impl Tray {
|
|||||||
"Clash Verge {}\n{}: {}\n{}: {}\n{}: {}",
|
"Clash Verge {}\n{}: {}\n{}: {}\n{}: {}",
|
||||||
reassembled_version,
|
reassembled_version,
|
||||||
sys_proxy_text,
|
sys_proxy_text,
|
||||||
switch_str(system_proxy),
|
switch_map[system_proxy],
|
||||||
tun_text,
|
tun_text,
|
||||||
switch_str(tun_mode),
|
switch_map[tun_mode],
|
||||||
profile_text,
|
profile_text,
|
||||||
current_profile_name
|
current_profile_name
|
||||||
);
|
);
|
||||||
|
|
||||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||||
|
let _ = tray.set_tooltip(Some(&tooltip));
|
||||||
|
} else {
|
||||||
logging!(warn, Type::Tray, "Failed to update tray tooltip: tray not found");
|
logging!(warn, Type::Tray, "Failed to update tray tooltip: tray not found");
|
||||||
return Ok(());
|
}
|
||||||
};
|
|
||||||
|
|
||||||
logging_error!(Type::Tray, tray.set_tooltip(Some(&tooltip)));
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -408,17 +414,56 @@ impl Tray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tray = builder.build(app_handle)?;
|
let tray = builder.build(app_handle)?;
|
||||||
tray.on_tray_icon_event(on_tray_icon_event);
|
let tray_event = verge.tray_event.clone().unwrap_or_else(|| "main_window".into());
|
||||||
|
let tray_action = TrayAction::from(tray_event.as_str());
|
||||||
|
|
||||||
|
tray.on_tray_icon_event(move |_app_handle, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Down,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
// 添加防抖检查,防止快速连击
|
||||||
|
#[allow(clippy::use_self)]
|
||||||
|
if !Tray::global().should_handle_tray_click() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logging!(debug, Type::Tray, "tray event: {tray_action:?}");
|
||||||
|
match tray_action {
|
||||||
|
TrayAction::SystemProxy => {
|
||||||
|
AsyncHandler::spawn(|| async move {
|
||||||
|
let _ = feat::toggle_system_proxy().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
TrayAction::TunMode => {
|
||||||
|
AsyncHandler::spawn(|| async move {
|
||||||
|
let _ = feat::toggle_tun_mode(None).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
TrayAction::MainWindow => {
|
||||||
|
AsyncHandler::spawn(|| async move {
|
||||||
|
if !lightweight::exit_lightweight_mode().await {
|
||||||
|
WindowManager::show_main_window().await;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
logging!(warn, Type::Tray, "invalid tray event: {}", tray_event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
tray.on_menu_event(on_menu_event);
|
tray.on_menu_event(on_menu_event);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_handle_tray_click(&self) -> bool {
|
fn should_handle_tray_click(&self) -> bool {
|
||||||
let allow = self.limiter.check();
|
let res = self.limiter.check().is_ok();
|
||||||
if !allow {
|
if !res {
|
||||||
logging!(debug, Type::Tray, "tray click rate limited");
|
logging!(debug, Type::Tray, "tray click rate limited");
|
||||||
}
|
}
|
||||||
allow
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,68 +925,14 @@ async fn create_tray_menu(
|
|||||||
Ok(menu)
|
Ok(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
|
|
||||||
if matches!(
|
|
||||||
tray_event,
|
|
||||||
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let TrayIconEvent::Click {
|
|
||||||
button: MouseButton::Left,
|
|
||||||
button_state: MouseButtonState::Down,
|
|
||||||
..
|
|
||||||
} = tray_event
|
|
||||||
{
|
|
||||||
// 添加防抖检查,防止快速连击
|
|
||||||
#[allow(clippy::use_self)]
|
|
||||||
if !Tray::global().should_handle_tray_click() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncHandler::spawn(|| async move {
|
|
||||||
let verge = Config::verge().await.data_arc();
|
|
||||||
let verge_tray_event = verge.tray_event.clone().unwrap_or_else(|| "main_window".into());
|
|
||||||
let verge_tray_action = TrayAction::from(verge_tray_event.as_str());
|
|
||||||
logging!(debug, Type::Tray, "tray event: {verge_tray_action:?}");
|
|
||||||
match verge_tray_action {
|
|
||||||
TrayAction::SystemProxy => {
|
|
||||||
let _ = feat::toggle_system_proxy().await;
|
|
||||||
}
|
|
||||||
TrayAction::TunMode => {
|
|
||||||
let _ = feat::toggle_tun_mode(None).await;
|
|
||||||
}
|
|
||||||
TrayAction::MainWindow => {
|
|
||||||
if !lightweight::exit_lightweight_mode().await {
|
|
||||||
WindowManager::show_main_window().await;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
logging!(warn, Type::Tray, "invalid tray event: {}", verge_tray_event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||||
if !Tray::global().should_handle_tray_click() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if event.id.as_ref().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
AsyncHandler::spawn(|| async move {
|
AsyncHandler::spawn(|| async move {
|
||||||
match event.id.as_ref() {
|
match event.id.as_ref() {
|
||||||
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
|
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
|
||||||
// Removing the the "tray_" prefix and "_mode" suffix
|
// Removing the the "tray_" prefix and "_mode" suffix
|
||||||
if let Some(stripped) = mode.strip_prefix("tray_")
|
let mode = &mode[5..mode.len() - 5];
|
||||||
&& let Some(final_mode) = stripped.strip_suffix("_mode")
|
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", mode);
|
||||||
{
|
feat::change_clash_mode(mode.into()).await;
|
||||||
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
|
|
||||||
feat::change_clash_mode(final_mode.into()).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MenuIds::DASHBOARD => {
|
MenuIds::DASHBOARD => {
|
||||||
logging!(info, Type::Tray, "托盘菜单点击: 打开窗口");
|
logging!(info, Type::Tray, "托盘菜单点击: 打开窗口");
|
||||||
@@ -989,10 +980,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
|||||||
feat::quit().await;
|
feat::quit().await;
|
||||||
}
|
}
|
||||||
id if id.starts_with("profiles_") => {
|
id if id.starts_with("profiles_") => {
|
||||||
let profile_index = match id.strip_prefix("profiles_") {
|
let profile_index = &id["profiles_".len()..];
|
||||||
Some(index_str) => index_str,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
feat::toggle_proxy_profile(profile_index.into()).await;
|
feat::toggle_proxy_profile(profile_index.into()).await;
|
||||||
}
|
}
|
||||||
id if id.starts_with("proxy_") => {
|
id if id.starts_with("proxy_") => {
|
||||||
|
|||||||
@@ -61,11 +61,14 @@ pub fn use_sort(config: Mapping) -> Mapping {
|
|||||||
ret
|
ret
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
pub fn use_keys(config: &Mapping) -> Vec<String> {
|
||||||
pub fn use_keys<'a>(config: &'a Mapping) -> impl Iterator<Item = String> + 'a {
|
config
|
||||||
config.iter().filter_map(|(key, _)| key.as_str()).map(|s: &str| {
|
.iter()
|
||||||
let mut s: String = s.into();
|
.filter_map(|(key, _)| key.as_str())
|
||||||
s.make_ascii_lowercase();
|
.map(|s: &str| {
|
||||||
s
|
let mut s: String = s.into();
|
||||||
})
|
s.make_ascii_lowercase();
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ fn process_global_items(
|
|||||||
profile_name: &String,
|
profile_name: &String,
|
||||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||||
let mut result_map = HashMap::new();
|
let mut result_map = HashMap::new();
|
||||||
let mut exists_keys = use_keys(&config).collect::<Vec<_>>();
|
let mut exists_keys = use_keys(&config);
|
||||||
|
|
||||||
if let ChainType::Merge(merge) = global_merge.data {
|
if let ChainType::Merge(merge) = global_merge.data {
|
||||||
exists_keys.extend(use_keys(&merge));
|
exists_keys.extend(use_keys(&merge));
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
|||||||
{
|
{
|
||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
crate::utils::resolve::dns::restore_public_dns().await;
|
crate::utils::resolve::dns::restore_public_dns().await;
|
||||||
crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
|
crate::utils::resolve::dns::set_public_dns("223.6.6.6".to_string()).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use crate::{
|
|||||||
cmd,
|
cmd,
|
||||||
config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe},
|
config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe},
|
||||||
core::{CoreManager, handle, tray},
|
core::{CoreManager, handle, tray},
|
||||||
utils::help::{mask_err, mask_url},
|
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
@@ -84,11 +83,9 @@ async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result
|
|||||||
Type::Config,
|
Type::Config,
|
||||||
"[订阅更新] {} 是远程订阅,URL: {}",
|
"[订阅更新] {} 是远程订阅,URL: {}",
|
||||||
uid,
|
uid,
|
||||||
mask_url(
|
item.url
|
||||||
item.url
|
.as_ref()
|
||||||
.as_ref()
|
.ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?
|
||||||
.ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
Ok(Some((
|
Ok(Some((
|
||||||
item.url.clone().ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?,
|
item.url.clone().ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?,
|
||||||
@@ -102,7 +99,6 @@ async fn perform_profile_update(
|
|||||||
url: &String,
|
url: &String,
|
||||||
opt: Option<&PrfOption>,
|
opt: Option<&PrfOption>,
|
||||||
option: Option<&PrfOption>,
|
option: Option<&PrfOption>,
|
||||||
is_mannual_trigger: bool,
|
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
logging!(info, Type::Config, "[订阅更新] 开始下载新的订阅内容");
|
logging!(info, Type::Config, "[订阅更新] 开始下载新的订阅内容");
|
||||||
let mut merged_opt = PrfOption::merge(opt, option);
|
let mut merged_opt = PrfOption::merge(opt, option);
|
||||||
@@ -129,8 +125,7 @@ async fn perform_profile_update(
|
|||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Config,
|
Type::Config,
|
||||||
"Warning: [订阅更新] 正常更新失败: {},尝试使用Clash代理更新",
|
"Warning: [订阅更新] 正常更新失败: {err},尝试使用Clash代理更新"
|
||||||
mask_err(&err.to_string())
|
|
||||||
);
|
);
|
||||||
last_err = err;
|
last_err = err;
|
||||||
}
|
}
|
||||||
@@ -151,8 +146,7 @@ async fn perform_profile_update(
|
|||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Config,
|
Type::Config,
|
||||||
"Warning: [订阅更新] Clash代理更新失败: {},尝试使用系统代理更新",
|
"Warning: [订阅更新] 正常更新失败: {err},尝试使用Clash代理更新"
|
||||||
mask_err(&err.to_string())
|
|
||||||
);
|
);
|
||||||
last_err = err;
|
last_err = err;
|
||||||
}
|
}
|
||||||
@@ -173,16 +167,13 @@ async fn perform_profile_update(
|
|||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Config,
|
Type::Config,
|
||||||
"Warning: [订阅更新] 系统代理更新失败: {},所有重试均已失败",
|
"Warning: [订阅更新] 正常更新失败: {err},尝试使用系统代理更新"
|
||||||
mask_err(&err.to_string())
|
|
||||||
);
|
);
|
||||||
last_err = err;
|
last_err = err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_mannual_trigger {
|
handle::Handle::notice_message("update_failed_even_with_clash", format!("{profile_name} - {last_err}"));
|
||||||
handle::Handle::notice_message("update_failed_even_with_clash", format!("{profile_name} - {last_err}"));
|
|
||||||
}
|
|
||||||
Ok(is_current)
|
Ok(is_current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,15 +182,12 @@ pub async fn update_profile(
|
|||||||
option: Option<&PrfOption>,
|
option: Option<&PrfOption>,
|
||||||
auto_refresh: bool,
|
auto_refresh: bool,
|
||||||
ignore_auto_update: bool,
|
ignore_auto_update: bool,
|
||||||
is_mannual_trigger: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
logging!(info, Type::Config, "[订阅更新] 开始更新订阅 {}", uid);
|
logging!(info, Type::Config, "[订阅更新] 开始更新订阅 {}", uid);
|
||||||
let url_opt = should_update_profile(uid, ignore_auto_update).await?;
|
let url_opt = should_update_profile(uid, ignore_auto_update).await?;
|
||||||
|
|
||||||
let should_refresh = match url_opt {
|
let should_refresh = match url_opt {
|
||||||
Some((url, opt)) => {
|
Some((url, opt)) => perform_profile_update(uid, &url, opt.as_ref(), option).await? && auto_refresh,
|
||||||
perform_profile_update(uid, &url, opt.as_ref(), option, is_mannual_trigger).await? && auto_refresh
|
|
||||||
}
|
|
||||||
None => auto_refresh,
|
None => auto_refresh,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ pub async fn hide() {
|
|||||||
add_light_weight_timer().await;
|
add_light_weight_timer().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(window) = WindowManager::get_main_window()
|
if let Some(window) = handle::Handle::get_window()
|
||||||
&& window.is_visible().unwrap_or(false)
|
&& window.is_visible().unwrap_or(false)
|
||||||
{
|
{
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
|
|||||||
+17
-1
@@ -19,10 +19,12 @@ use crate::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Manager as _};
|
use tauri::{AppHandle, Manager as _};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tauri_plugin_deep_link::DeepLinkExt as _;
|
use tauri_plugin_deep_link::DeepLinkExt as _;
|
||||||
|
use tauri_plugin_mihomo::RejectPolicy;
|
||||||
|
|
||||||
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
|
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
|
||||||
/// Application initialization helper functions
|
/// Application initialization helper functions
|
||||||
@@ -57,6 +59,15 @@ mod app_init {
|
|||||||
tauri_plugin_mihomo::Builder::new()
|
tauri_plugin_mihomo::Builder::new()
|
||||||
.protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket)
|
.protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket)
|
||||||
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
|
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
|
||||||
|
.pool_config(
|
||||||
|
tauri_plugin_mihomo::IpcPoolConfigBuilder::new()
|
||||||
|
.min_connections(1)
|
||||||
|
.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)))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -240,6 +251,7 @@ pub fn run() {
|
|||||||
logging!(error, Type::Setup, "Failed to setup window state: {}", e);
|
logging!(error, Type::Setup, "Failed to setup window state: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolve::resolve_setup_handle();
|
||||||
resolve::resolve_setup_async();
|
resolve::resolve_setup_async();
|
||||||
resolve::resolve_setup_sync();
|
resolve::resolve_setup_sync();
|
||||||
resolve::init_signal();
|
resolve::init_signal();
|
||||||
@@ -253,6 +265,7 @@ pub fn run() {
|
|||||||
mod event_handlers {
|
mod event_handlers {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use crate::module::lightweight;
|
use crate::module::lightweight;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
@@ -271,6 +284,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logging!(info, Type::System, "应用就绪");
|
logging!(info, Type::System, "应用就绪");
|
||||||
|
handle::Handle::global().init();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let Some(window) = _app_handle.get_webview_window("main") {
|
if let Some(window) = _app_handle.get_webview_window("main") {
|
||||||
@@ -280,6 +294,8 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub async fn handle_reopen(has_visible_windows: bool) {
|
pub async fn handle_reopen(has_visible_windows: bool) {
|
||||||
|
handle::Handle::global().init();
|
||||||
|
|
||||||
if lightweight::is_in_lightweight_mode() {
|
if lightweight::is_in_lightweight_mode() {
|
||||||
lightweight::exit_lightweight_mode().await;
|
lightweight::exit_lightweight_mode().await;
|
||||||
return;
|
return;
|
||||||
@@ -301,7 +317,7 @@ pub fn run() {
|
|||||||
|
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = api {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = api {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
if let Some(window) = core::handle::Handle::get_window() {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
core::{timer::Timer, tray::Tray},
|
core::{handle, timer::Timer, tray::Tray},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ pub async fn add_light_weight_timer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup_window_close_listener() {
|
fn setup_window_close_listener() {
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
if let Some(window) = handle::Handle::get_window() {
|
||||||
let handler_id = window.listen("tauri://close-requested", move |_event| {
|
let handler_id = window.listen("tauri://close-requested", move |_event| {
|
||||||
std::mem::drop(AsyncHandler::spawn(|| async {
|
std::mem::drop(AsyncHandler::spawn(|| async {
|
||||||
if let Err(e) = setup_light_weight_timer().await {
|
if let Err(e) = setup_light_weight_timer().await {
|
||||||
@@ -161,7 +161,7 @@ fn setup_window_close_listener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_window_close_listener() {
|
fn cancel_window_close_listener() {
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
if let Some(window) = handle::Handle::get_window() {
|
||||||
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
|
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
window.unlisten(id);
|
window.unlisten(id);
|
||||||
@@ -171,7 +171,7 @@ fn cancel_window_close_listener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup_webview_focus_listener() {
|
fn setup_webview_focus_listener() {
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
if let Some(window) = handle::Handle::get_window() {
|
||||||
let handler_id = window.listen("tauri://focus", move |_event| {
|
let handler_id = window.listen("tauri://focus", move |_event| {
|
||||||
logging_error!(Type::Lightweight, cancel_light_weight_timer());
|
logging_error!(Type::Lightweight, cancel_light_weight_timer());
|
||||||
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
|
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
|
||||||
@@ -181,7 +181,7 @@ fn setup_webview_focus_listener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_webview_focus_listener() {
|
fn cancel_webview_focus_listener() {
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
if let Some(window) = handle::Handle::get_window() {
|
||||||
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
|
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
window.unlisten(id);
|
window.unlisten(id);
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ use clash_verge_logging::{Type, logging};
|
|||||||
use nanoid::nanoid;
|
use nanoid::nanoid;
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use std::path::Path;
|
|
||||||
use std::{path::PathBuf, str::FromStr};
|
use std::{path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
/// read data from yaml as struct T
|
/// read data from yaml as struct T
|
||||||
@@ -98,92 +96,6 @@ pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mask sensitive parts of a subscription URL for safe logging.
|
|
||||||
/// Examples:
|
|
||||||
/// - `https://example.com/api/v1/clash?token=abc123` → `https://example.com/api/v1/clash?token=***`
|
|
||||||
/// - `https://example.com/abc123def456ghi789/clash` → `https://example.com/***/clash`
|
|
||||||
pub fn mask_url(url: &str) -> String {
|
|
||||||
// Split off query string
|
|
||||||
let (path_part, query_part) = match url.find('?') {
|
|
||||||
Some(pos) => (&url[..pos], Some(&url[pos + 1..])),
|
|
||||||
None => (url, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract scheme+host prefix (everything up to the first '/' after "://")
|
|
||||||
let host_end = path_part
|
|
||||||
.find("://")
|
|
||||||
.and_then(|scheme_end| {
|
|
||||||
path_part[scheme_end + 3..]
|
|
||||||
.find('/')
|
|
||||||
.map(|slash| scheme_end + 3 + slash)
|
|
||||||
})
|
|
||||||
.unwrap_or(path_part.len());
|
|
||||||
|
|
||||||
let scheme_and_host = &path_part[..host_end];
|
|
||||||
let path = &path_part[host_end..]; // starts with '/' or empty
|
|
||||||
|
|
||||||
let mut result = scheme_and_host.to_owned();
|
|
||||||
|
|
||||||
// Mask path segments that look like tokens (longer than 16 chars)
|
|
||||||
if !path.is_empty() {
|
|
||||||
let masked: Vec<&str> = path
|
|
||||||
.split('/')
|
|
||||||
.map(|seg| if seg.len() > 16 { "***" } else { seg })
|
|
||||||
.collect();
|
|
||||||
result.push_str(&masked.join("/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep query param keys, mask values
|
|
||||||
if let Some(query) = query_part {
|
|
||||||
result.push('?');
|
|
||||||
let masked_query: Vec<String> = query
|
|
||||||
.split('&')
|
|
||||||
.map(|param| match param.find('=') {
|
|
||||||
Some(eq) => format!("{}=***", ¶m[..eq]),
|
|
||||||
None => param.to_owned(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
result.push_str(&masked_query.join("&"));
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mask all URLs embedded in an error/log string for safe logging.
|
|
||||||
///
|
|
||||||
/// Scans the string for `http://` or `https://` and replaces each URL
|
|
||||||
/// (terminated by whitespace or `)`, `]`, `"`, `'`) with its masked form.
|
|
||||||
/// Text between URLs is copied verbatim.
|
|
||||||
pub fn mask_err(err: &str) -> String {
|
|
||||||
let mut result = String::with_capacity(err.len());
|
|
||||||
let mut remaining = err;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let http = remaining.find("http://");
|
|
||||||
let https = remaining.find("https://");
|
|
||||||
let start = match (http, https) {
|
|
||||||
(None, None) => {
|
|
||||||
result.push_str(remaining);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
(Some(a), None) | (None, Some(a)) => a,
|
|
||||||
(Some(a), Some(b)) => a.min(b),
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push_str(&remaining[..start]);
|
|
||||||
remaining = &remaining[start..];
|
|
||||||
|
|
||||||
let url_end = remaining
|
|
||||||
.find(|c: char| c.is_whitespace() || matches!(c, ')' | ']' | '"' | '\''))
|
|
||||||
.unwrap_or(remaining.len());
|
|
||||||
|
|
||||||
result.push_str(&mask_url(&remaining[..url_end]));
|
|
||||||
remaining = &remaining[url_end..];
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// get the last part of the url, if not found, return empty string
|
/// get the last part of the url, if not found, return empty string
|
||||||
pub fn get_last_part_and_decode(url: &str) -> Option<String> {
|
pub fn get_last_part_and_decode(url: &str) -> Option<String> {
|
||||||
let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part
|
let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part
|
||||||
@@ -222,24 +134,3 @@ pub fn linux_elevator() -> String {
|
|||||||
Err(_) => "sudo".to_string(),
|
Err(_) => "sudo".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
/// copy the file to the dist path and return the dist path
|
|
||||||
pub fn snapshot_path(original_path: &Path) -> Result<PathBuf> {
|
|
||||||
let temp_dir = original_path
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| anyhow!("Invalid log path"))?
|
|
||||||
.join("temp");
|
|
||||||
|
|
||||||
std::fs::create_dir_all(&temp_dir)?;
|
|
||||||
|
|
||||||
let temp_path = temp_dir.join(format!(
|
|
||||||
"{}_{}.log",
|
|
||||||
original_path.file_stem().unwrap_or_default().to_string_lossy(),
|
|
||||||
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
|
|
||||||
));
|
|
||||||
|
|
||||||
std::fs::copy(original_path, &temp_path)?;
|
|
||||||
|
|
||||||
Ok(temp_path)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,35 +13,11 @@ use crate::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{Local, TimeZone as _};
|
use chrono::{Local, TimeZone as _};
|
||||||
use clash_verge_logging::Type;
|
use clash_verge_logging::Type;
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use std::path::Path;
|
|
||||||
use std::{path::PathBuf, str::FromStr as _};
|
use std::{path::PathBuf, str::FromStr as _};
|
||||||
use tauri_plugin_shell::ShellExt as _;
|
use tauri_plugin_shell::ShellExt as _;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::fs::DirEntry;
|
use tokio::fs::DirEntry;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
|
|
||||||
let temp_dirs = [
|
|
||||||
log_dir.join("temp"),
|
|
||||||
log_dir.join("service").join("temp"),
|
|
||||||
log_dir.join("sidecar").join("temp"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
|
|
||||||
let mut entries = fs::read_dir(temp_dir).await?;
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) == Some("log") {
|
|
||||||
let _ = path.remove_if_exists().await;
|
|
||||||
logging!(info, Type::Setup, "delete snapshot log file: {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件
|
// TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件
|
||||||
/// 删除log文件
|
/// 删除log文件
|
||||||
pub async fn delete_log() -> Result<()> {
|
pub async fn delete_log() -> Result<()> {
|
||||||
@@ -50,9 +26,6 @@ pub async fn delete_log() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
delete_snapshot_logs(&log_dir).await?;
|
|
||||||
|
|
||||||
let auto_log_clean = {
|
let auto_log_clean = {
|
||||||
let verge = Config::verge().await;
|
let verge = Config::verge().await;
|
||||||
let verge = verge.data_arc();
|
let verge = verge.data_arc();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use anyhow::Result;
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
core::{
|
core::{
|
||||||
CoreManager, Timer,
|
CoreManager, Timer, handle,
|
||||||
hotkey::Hotkey,
|
hotkey::Hotkey,
|
||||||
logger::Logger,
|
logger::Logger,
|
||||||
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
|
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
|
||||||
@@ -33,12 +33,15 @@ pub fn init_work_dir_and_logger() -> anyhow::Result<()> {
|
|||||||
init_work_config().await;
|
init_work_config().await;
|
||||||
init_resources().await;
|
init_resources().await;
|
||||||
logging!(info, Type::Setup, "Initializing logger");
|
logging!(info, Type::Setup, "Initializing logger");
|
||||||
// #[cfg(not(feature = "tokio-trace"))]
|
|
||||||
Logger::global().init().await?;
|
Logger::global().init().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_setup_handle() {
|
||||||
|
init_handle();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_setup_sync() {
|
pub fn resolve_setup_sync() {
|
||||||
AsyncHandler::spawn(|| async {
|
AsyncHandler::spawn(|| async {
|
||||||
AsyncHandler::spawn_blocking(init_scheme);
|
AsyncHandler::spawn_blocking(init_scheme);
|
||||||
@@ -91,6 +94,10 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn init_handle() {
|
||||||
|
handle::Handle::global().init();
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn init_scheme() {
|
pub(super) fn init_scheme() {
|
||||||
logging_error!(Type::Setup, init::init_scheme());
|
logging_error!(Type::Setup, init::init_scheme());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Result, bail};
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use tauri::Url;
|
use tauri::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, PrfItem, profiles},
|
config::{Config, PrfItem, profiles},
|
||||||
core::{CoreManager, handle},
|
core::handle,
|
||||||
};
|
};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
|
||||||
@@ -22,136 +22,90 @@ pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
|
|||||||
param
|
param
|
||||||
};
|
};
|
||||||
|
|
||||||
let link_parsed =
|
// 解析 URL
|
||||||
Url::parse(param_str).map_err(|e| anyhow::anyhow!("failed to parse deep link: {:?}, param: {:?}", e, param))?;
|
let link_parsed = match Url::parse(param_str) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => {
|
||||||
|
bail!("failed to parse deep link: {:?}, param: {:?}", e, param);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let Some((url, name)) = extract_subscription_info(&link_parsed) else {
|
let (url_param, name) = if link_parsed.scheme() == "clash" || link_parsed.scheme() == "clash-verge" {
|
||||||
|
let name_owned: Option<String> = link_parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(key, _)| key == "name")
|
||||||
|
.map(|(_, value)| value.into_owned().into());
|
||||||
|
|
||||||
|
let url_param = if let Some(query) = link_parsed.query() {
|
||||||
|
let prefix = "url=";
|
||||||
|
if let Some(pos) = query.find(prefix) {
|
||||||
|
let raw_url = query[pos + prefix.len()..].trim();
|
||||||
|
// Avoid double-decoding nested subscription URLs; decode only when needed.
|
||||||
|
let decoded = if Url::parse(raw_url).is_ok() {
|
||||||
|
raw_url.to_string()
|
||||||
|
} else {
|
||||||
|
let mut candidate = raw_url.to_string();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let next = percent_decode_str(&candidate).decode_utf8_lossy().to_string();
|
||||||
|
if next == candidate {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate = next;
|
||||||
|
if Url::parse(&candidate).is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
};
|
||||||
|
Some(decoded)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(url_param, name_owned)
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = if let Some(ref url) = url_param {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
logging!(error, Type::Config, "missing url parameter in deep link: {}", param_str);
|
logging!(error, Type::Config, "missing url parameter in deep link: {}", param_str);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
import_subscription(&url, name.as_ref()).await;
|
let mut item = match PrfItem::from_url(url, name.as_ref(), None, None).await {
|
||||||
Ok(())
|
Ok(item) => item,
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_subscription_info(link_parsed: &Url) -> Option<(std::string::String, Option<String>)> {
|
|
||||||
if !matches!(link_parsed.scheme(), "clash" | "clash-verge") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = link_parsed
|
|
||||||
.query_pairs()
|
|
||||||
.find(|(key, _)| key == "name")
|
|
||||||
.map(|(_, value)| value.into_owned().into());
|
|
||||||
let url = extract_subscription_url(link_parsed)?;
|
|
||||||
Some((url, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_subscription_url(link_parsed: &Url) -> Option<std::string::String> {
|
|
||||||
let query = link_parsed.query()?;
|
|
||||||
let prefix = "url=";
|
|
||||||
let pos = query.find(prefix)?;
|
|
||||||
let raw_url = query[pos + prefix.len()..].trim();
|
|
||||||
Some(decode_subscription_url(raw_url))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_subscription_url(raw_url: &str) -> std::string::String {
|
|
||||||
// Avoid double-decoding nested subscription URLs; decode only when needed.
|
|
||||||
if Url::parse(raw_url).is_ok() {
|
|
||||||
return raw_url.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut candidate = raw_url.to_string();
|
|
||||||
for _ in 0..2 {
|
|
||||||
let next = percent_decode_str(&candidate).decode_utf8_lossy().to_string();
|
|
||||||
if next == candidate {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
candidate = next;
|
|
||||||
if Url::parse(&candidate).is_ok() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn import_subscription(url: &str, name: Option<&String>) {
|
|
||||||
let had_current_profile = {
|
|
||||||
let profiles = Config::profiles().await;
|
|
||||||
profiles.latest_arc().current.is_some()
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(mut item) = fetch_profile_item(url, name).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let uid = item.uid.clone().unwrap_or_default();
|
|
||||||
if let Err(e) = profiles::profiles_append_item_safe(&mut item).await {
|
|
||||||
logging!(error, Type::Config, "failed to import subscription url: {:?}", e);
|
|
||||||
Config::profiles().await.discard();
|
|
||||||
handle::Handle::notice_message("import_sub_url::error", e.to_string());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Config::profiles().await.apply();
|
|
||||||
let _ = Config::profiles().await.data_arc().save_file().await;
|
|
||||||
handle::Handle::notice_message(
|
|
||||||
"import_sub_url::ok",
|
|
||||||
"", // 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
|
|
||||||
);
|
|
||||||
|
|
||||||
post_import_updates(&uid, had_current_profile).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_profile_item(url: &str, name: Option<&String>) -> Option<PrfItem> {
|
|
||||||
match PrfItem::from_url(url, name, None, None).await {
|
|
||||||
Ok(item) => Some(item),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logging!(error, Type::Config, "failed to parse profile from url: {:?}", e);
|
logging!(error, Type::Config, "failed to parse profile from url: {:?}", e);
|
||||||
handle::Handle::notice_message("import_sub_url::error", e.to_string());
|
handle::Handle::notice_message("import_sub_url::error", e.to_string());
|
||||||
None
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_import_updates(uid: &String, had_current_profile: bool) {
|
|
||||||
handle::Handle::refresh_verge();
|
|
||||||
handle::Handle::notify_profile_changed(uid);
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
|
|
||||||
let should_update_core = if uid.is_empty() || had_current_profile {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
let profiles = Config::profiles().await;
|
|
||||||
profiles.latest_arc().is_current_profile_index(uid)
|
|
||||||
};
|
};
|
||||||
handle::Handle::notify_profile_changed(uid);
|
|
||||||
|
|
||||||
if should_update_core {
|
let uid = item.uid.clone().unwrap_or_default();
|
||||||
refresh_core_config().await;
|
match profiles::profiles_append_item_safe(&mut item).await {
|
||||||
}
|
Ok(_) => {
|
||||||
}
|
Config::profiles().await.apply();
|
||||||
|
let _ = Config::profiles().await.data_arc().save_file().await;
|
||||||
async fn refresh_core_config() {
|
handle::Handle::notice_message(
|
||||||
logging!(
|
"import_sub_url::ok",
|
||||||
info,
|
"", // 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
|
||||||
Type::Config,
|
);
|
||||||
"Deep link import set current profile; refreshing core config"
|
handle::Handle::refresh_verge();
|
||||||
);
|
handle::Handle::notify_profile_changed(uid.clone());
|
||||||
match CoreManager::global().update_config().await {
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
Ok((true, _)) => handle::Handle::refresh_clash(),
|
handle::Handle::notify_profile_changed(uid);
|
||||||
Ok((false, msg)) => {
|
|
||||||
let message = if msg.is_empty() {
|
|
||||||
String::from("Failed to apply subscription configuration")
|
|
||||||
} else {
|
|
||||||
msg
|
|
||||||
};
|
|
||||||
logging!(warn, Type::Config, "Apply config failed: {}", message);
|
|
||||||
handle::Handle::notice_message("config_validate::error", message);
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(e) => {
|
||||||
logging!(error, Type::Config, "Apply config error: {}", err);
|
logging!(error, Type::Config, "failed to import subscription url: {:?}", e);
|
||||||
handle::Handle::notice_message("update_failed", format!("{err}"));
|
Config::profiles().await.discard();
|
||||||
|
handle::Handle::notice_message("import_sub_url::error", e.to_string());
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use std::fs;
|
|||||||
use std::os::windows::process::CommandExt as _;
|
use std::os::windows::process::CommandExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Output};
|
use std::process::{Command, Output};
|
||||||
use windows::Win32::Globalization::{GetACP, GetOEMCP, MULTI_BYTE_TO_WIDE_CHAR_FLAGS, MultiByteToWideChar};
|
use winapi::um::stringapiset::MultiByteToWideChar;
|
||||||
|
use winapi::um::winnls::{GetACP, GetOEMCP};
|
||||||
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
const TASK_NAME_USER: &str = "Clash Verge";
|
const TASK_NAME_USER: &str = "Clash Verge";
|
||||||
@@ -206,16 +207,34 @@ fn decode_with_code_page(bytes: &[u8], code_page: u32) -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let required = unsafe { MultiByteToWideChar(code_page, MULTI_BYTE_TO_WIDE_CHAR_FLAGS(0), bytes, None) };
|
let required = unsafe {
|
||||||
|
MultiByteToWideChar(
|
||||||
|
code_page,
|
||||||
|
0,
|
||||||
|
bytes.as_ptr() as *const i8,
|
||||||
|
len as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
if required <= 0 {
|
if required == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut wide = vec![0u16; required as usize];
|
let mut wide = vec![0u16; required as usize];
|
||||||
let written = unsafe { MultiByteToWideChar(code_page, MULTI_BYTE_TO_WIDE_CHAR_FLAGS(0), bytes, Some(&mut wide)) };
|
let written = unsafe {
|
||||||
|
MultiByteToWideChar(
|
||||||
|
code_page,
|
||||||
|
0,
|
||||||
|
bytes.as_ptr() as *const i8,
|
||||||
|
len as i32,
|
||||||
|
wide.as_mut_ptr(),
|
||||||
|
required,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
if written <= 0 {
|
if written == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crate::{core::handle, utils::resolve::window::build_new_window};
|
use crate::{core::handle, utils::resolve::window::build_new_window};
|
||||||
use clash_verge_limiter::Limiter;
|
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use std::num::NonZeroU32;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{Manager as _, WebviewWindow, Wry};
|
use tauri::{Manager as _, WebviewWindow, Wry};
|
||||||
@@ -39,48 +40,28 @@ pub enum WindowState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 窗口操作防抖机制
|
// 窗口操作防抖机制
|
||||||
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 625;
|
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 1_275;
|
||||||
static WINDOW_OPERATION_LIMITER: Lazy<Limiter> = Lazy::new(|| {
|
static WINDOW_OPERATION_LIMITER: Lazy<DefaultDirectRateLimiter> = Lazy::new(|| {
|
||||||
Limiter::new(
|
#[allow(clippy::unwrap_used)]
|
||||||
Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS),
|
RateLimiter::direct(
|
||||||
clash_verge_limiter::SystemClock,
|
Quota::with_period(Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS))
|
||||||
|
.unwrap()
|
||||||
|
.allow_burst(NonZeroU32::new(1).unwrap()),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
fn should_handle_window_operation() -> bool {
|
fn should_handle_window_operation() -> bool {
|
||||||
let allow = WINDOW_OPERATION_LIMITER.check();
|
let res = WINDOW_OPERATION_LIMITER.check().is_ok();
|
||||||
if !allow {
|
if !res {
|
||||||
logging!(debug, Type::Window, "window operation rate limited");
|
logging!(debug, Type::Window, "window operation rate limited");
|
||||||
}
|
}
|
||||||
allow
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统一的窗口管理器
|
/// 统一的窗口管理器
|
||||||
pub struct WindowManager;
|
pub struct WindowManager;
|
||||||
|
|
||||||
impl WindowManager {
|
impl WindowManager {
|
||||||
pub fn get_main_window_with_state() -> (Option<WebviewWindow<Wry>>, WindowState) {
|
|
||||||
let Some(window) = Self::get_main_window() else {
|
|
||||||
return (None, WindowState::NotExist);
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_minimized = window.is_minimized().unwrap_or(false);
|
|
||||||
let is_visible = window.is_visible().unwrap_or(false);
|
|
||||||
let is_focused = window.is_focused().unwrap_or(false);
|
|
||||||
|
|
||||||
let state = if is_minimized {
|
|
||||||
WindowState::Minimized
|
|
||||||
} else if !is_visible {
|
|
||||||
WindowState::Hidden
|
|
||||||
} else if is_focused {
|
|
||||||
WindowState::VisibleFocused
|
|
||||||
} else {
|
|
||||||
WindowState::VisibleUnfocused
|
|
||||||
};
|
|
||||||
|
|
||||||
(Some(window), state)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_main_window_state() -> WindowState {
|
pub fn get_main_window_state() -> WindowState {
|
||||||
match Self::get_main_window() {
|
match Self::get_main_window() {
|
||||||
Some(window) => {
|
Some(window) => {
|
||||||
@@ -141,12 +122,12 @@ impl WindowManager {
|
|||||||
WindowOperationResult::NoAction
|
WindowOperationResult::NoAction
|
||||||
}
|
}
|
||||||
WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => {
|
WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => {
|
||||||
let (window, state_after_check) = Self::get_main_window_with_state();
|
if let Some(window) = Self::get_main_window() {
|
||||||
if state_after_check == WindowState::VisibleFocused {
|
let state_after_check = Self::get_main_window_state();
|
||||||
logging!(info, Type::Window, "窗口在检查期间已变为可见和有焦点状态");
|
if state_after_check == WindowState::VisibleFocused {
|
||||||
return WindowOperationResult::NoAction;
|
logging!(info, Type::Window, "窗口在检查期间已变为可见和有焦点状态");
|
||||||
}
|
return WindowOperationResult::NoAction;
|
||||||
if let Some(window) = window {
|
}
|
||||||
Self::activate_window(&window)
|
Self::activate_window(&window)
|
||||||
} else {
|
} else {
|
||||||
WindowOperationResult::Failed
|
WindowOperationResult::Failed
|
||||||
@@ -157,18 +138,25 @@ impl WindowManager {
|
|||||||
|
|
||||||
/// 切换主窗口显示状态(显示/隐藏)
|
/// 切换主窗口显示状态(显示/隐藏)
|
||||||
pub async fn toggle_main_window() -> WindowOperationResult {
|
pub async fn toggle_main_window() -> WindowOperationResult {
|
||||||
|
// 防抖检查
|
||||||
if !should_handle_window_operation() {
|
if !should_handle_window_operation() {
|
||||||
return WindowOperationResult::NoAction;
|
return WindowOperationResult::NoAction;
|
||||||
}
|
};
|
||||||
|
logging!(info, Type::Window, "开始切换主窗口显示状态");
|
||||||
|
|
||||||
let (window, state) = Self::get_main_window_with_state();
|
let current_state = Self::get_main_window_state();
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Window,
|
||||||
|
"当前窗口状态: {:?} | 详细状态: {}",
|
||||||
|
current_state,
|
||||||
|
Self::get_window_status_info()
|
||||||
|
);
|
||||||
|
|
||||||
logging!(debug, Type::Window, "当前状态: {:?}", state);
|
match current_state {
|
||||||
|
|
||||||
match state {
|
|
||||||
WindowState::NotExist => Self::handle_not_exist_toggle().await,
|
WindowState::NotExist => Self::handle_not_exist_toggle().await,
|
||||||
WindowState::VisibleFocused | WindowState::VisibleUnfocused => Self::hide_main_window(window.as_ref()),
|
WindowState::VisibleFocused | WindowState::VisibleUnfocused => Self::hide_main_window(),
|
||||||
WindowState::Minimized | WindowState::Hidden => Self::activate_existing_main_window(window.as_ref()),
|
WindowState::Minimized | WindowState::Hidden => Self::activate_existing_main_window(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +172,9 @@ impl WindowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏主窗口
|
// 隐藏主窗口
|
||||||
fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
|
fn hide_main_window() -> WindowOperationResult {
|
||||||
logging!(info, Type::Window, "窗口可见,将隐藏窗口");
|
logging!(info, Type::Window, "窗口可见,将隐藏窗口");
|
||||||
if let Some(window) = window {
|
if let Some(window) = Self::get_main_window() {
|
||||||
match window.hide() {
|
match window.hide() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
logging!(info, Type::Window, "窗口已成功隐藏");
|
logging!(info, Type::Window, "窗口已成功隐藏");
|
||||||
@@ -204,10 +192,10 @@ impl WindowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 激活已存在的主窗口
|
// 激活已存在的主窗口
|
||||||
fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
|
fn activate_existing_main_window() -> WindowOperationResult {
|
||||||
logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
|
logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
|
||||||
if let Some(window) = window {
|
if let Some(window) = Self::get_main_window() {
|
||||||
Self::activate_window(window)
|
Self::activate_window(&window)
|
||||||
} else {
|
} else {
|
||||||
logging!(warn, Type::Window, "无法获取窗口实例");
|
logging!(warn, Type::Window, "无法获取窗口实例");
|
||||||
WindowOperationResult::Failed
|
WindowOperationResult::Failed
|
||||||
@@ -270,18 +258,24 @@ impl WindowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 检查窗口是否可见
|
/// 检查窗口是否可见
|
||||||
pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool {
|
pub fn is_main_window_visible() -> bool {
|
||||||
window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
|
Self::get_main_window()
|
||||||
|
.map(|window| window.is_visible().unwrap_or(false))
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查窗口是否有焦点
|
/// 检查窗口是否有焦点
|
||||||
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
|
pub fn is_main_window_focused() -> bool {
|
||||||
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
|
Self::get_main_window()
|
||||||
|
.map(|window| window.is_focused().unwrap_or(false))
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查窗口是否最小化
|
/// 检查窗口是否最小化
|
||||||
pub fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
|
pub fn is_main_window_minimized() -> bool {
|
||||||
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
|
Self::get_main_window()
|
||||||
|
.map(|window| window.is_minimized().unwrap_or(false))
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建新窗口,防抖避免重复调用
|
/// 创建新窗口,防抖避免重复调用
|
||||||
@@ -329,11 +323,11 @@ impl WindowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取详细的窗口状态信息
|
/// 获取详细的窗口状态信息
|
||||||
fn get_window_status_info() -> String {
|
pub fn get_window_status_info() -> String {
|
||||||
let (window, state) = Self::get_main_window_with_state();
|
let state = Self::get_main_window_state();
|
||||||
let is_visible = Self::is_main_window_visible(window.as_ref());
|
let is_visible = Self::is_main_window_visible();
|
||||||
let is_focused = Self::is_main_window_focused(window.as_ref());
|
let is_focused = Self::is_main_window_focused();
|
||||||
let is_minimized = Self::is_main_window_minimized(window.as_ref());
|
let is_minimized = Self::is_main_window_minimized();
|
||||||
|
|
||||||
format!("窗口状态: {state:?} | 可见: {is_visible} | 有焦点: {is_focused} | 最小化: {is_minimized}")
|
format!("窗口状态: {state:?} | 可见: {is_visible} | 有焦点: {is_focused} | 最小化: {is_minimized}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.4.7",
|
"version": "2.4.5-rc.1",
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"./sidecar/clash-verge-service",
|
"./resources/clash-verge-service",
|
||||||
"./sidecar/clash-verge-service-install",
|
"./resources/clash-verge-service-install",
|
||||||
"./sidecar/clash-verge-service-uninstall",
|
"./resources/clash-verge-service-uninstall",
|
||||||
"./sidecar/verge-mihomo",
|
"./sidecar/verge-mihomo",
|
||||||
"./sidecar/verge-mihomo-alpha"
|
"./sidecar/verge-mihomo-alpha"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,18 +2,15 @@ import { ReactNode } from "react";
|
|||||||
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
|
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
function ErrorFallback({ error }: FallbackProps) {
|
function ErrorFallback({ error }: FallbackProps) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="alert" style={{ padding: 16 }}>
|
<div role="alert" style={{ padding: 16 }}>
|
||||||
<h4>Something went wrong:(</h4>
|
<h4>Something went wrong:(</h4>
|
||||||
|
|
||||||
<pre>{errorMessage}</pre>
|
<pre>{error.message}</pre>
|
||||||
|
|
||||||
<details title="Error Stack">
|
<details title="Error Stack">
|
||||||
<summary>Error Stack</summary>
|
<summary>Error Stack</summary>
|
||||||
<pre>{errorStack}</pre>
|
<pre>{error.stack}</pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ClearRounded } from "@mui/icons-material";
|
import { Box, SvgIcon, TextField, styled } from "@mui/material";
|
||||||
import { Box, SvgIcon, TextField, styled, IconButton } from "@mui/material";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import {
|
import {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
@@ -169,11 +168,6 @@ export const BaseSearchBox = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearInput = () => {
|
|
||||||
setText("");
|
|
||||||
emitSearch({ text: "", matchCase, matchWholeWord, useRegularExpression });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleMatchCase = () => {
|
const handleToggleMatchCase = () => {
|
||||||
const next = !matchCase;
|
const next = !matchCase;
|
||||||
setMatchCase(next);
|
setMatchCase(next);
|
||||||
@@ -206,40 +200,35 @@ export const BaseSearchBox = ({
|
|||||||
sx: { pr: 1 },
|
sx: { pr: 1 },
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<Box display="flex">
|
<Box display="flex">
|
||||||
{!!text && (
|
|
||||||
<Tooltip title={t("shared.placeholders.resetInput")}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
{...iconStyle}
|
|
||||||
onClick={handleClearInput}
|
|
||||||
>
|
|
||||||
<ClearRounded fontSize="inherit" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip title={t("shared.placeholders.matchCase")}>
|
<Tooltip title={t("shared.placeholders.matchCase")}>
|
||||||
<SvgIcon
|
<div>
|
||||||
component={matchCaseIcon}
|
<SvgIcon
|
||||||
{...iconStyle}
|
component={matchCaseIcon}
|
||||||
aria-label={matchCase ? "active" : "inactive"}
|
{...iconStyle}
|
||||||
onClick={handleToggleMatchCase}
|
aria-label={matchCase ? "active" : "inactive"}
|
||||||
/>
|
onClick={handleToggleMatchCase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t("shared.placeholders.matchWholeWord")}>
|
<Tooltip title={t("shared.placeholders.matchWholeWord")}>
|
||||||
<SvgIcon
|
<div>
|
||||||
component={matchWholeWordIcon}
|
<SvgIcon
|
||||||
{...iconStyle}
|
component={matchWholeWordIcon}
|
||||||
aria-label={matchWholeWord ? "active" : "inactive"}
|
{...iconStyle}
|
||||||
onClick={handleToggleMatchWholeWord}
|
aria-label={matchWholeWord ? "active" : "inactive"}
|
||||||
/>
|
onClick={handleToggleMatchWholeWord}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t("shared.placeholders.useRegex")}>
|
<Tooltip title={t("shared.placeholders.useRegex")}>
|
||||||
<SvgIcon
|
<div>
|
||||||
component={UseRegularExpressionIcon}
|
<SvgIcon
|
||||||
aria-label={useRegularExpression ? "active" : "inactive"}
|
component={UseRegularExpressionIcon}
|
||||||
{...iconStyle}
|
aria-label={useRegularExpression ? "active" : "inactive"}
|
||||||
onClick={handleToggleUseRegularExpression}
|
{...iconStyle}
|
||||||
/>
|
onClick={handleToggleUseRegularExpression}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,14 +21,20 @@ import {
|
|||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { Column } from "@tanstack/react-table";
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface ColumnOption {
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
columns: Column<IConnectionsItem, unknown>[];
|
columns: ColumnOption[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onToggle: (field: string, visible: boolean) => void;
|
||||||
onOrderChange: (order: string[]) => void;
|
onOrderChange: (order: string[]) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}
|
}
|
||||||
@@ -37,6 +43,7 @@ export const ConnectionColumnManager = ({
|
|||||||
open,
|
open,
|
||||||
columns,
|
columns,
|
||||||
onClose,
|
onClose,
|
||||||
|
onToggle,
|
||||||
onOrderChange,
|
onOrderChange,
|
||||||
onReset,
|
onReset,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
@@ -47,9 +54,9 @@ export const ConnectionColumnManager = ({
|
|||||||
);
|
);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const items = useMemo(() => columns.map((column) => column.id), [columns]);
|
const items = useMemo(() => columns.map((column) => column.field), [columns]);
|
||||||
const visibleCount = useMemo(
|
const visibleCount = useMemo(
|
||||||
() => columns.filter((column) => column.getIsVisible()).length,
|
() => columns.filter((column) => column.visible).length,
|
||||||
[columns],
|
[columns],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,7 +65,7 @@ export const ConnectionColumnManager = ({
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const order = columns.map((column) => column.id);
|
const order = columns.map((column) => column.field);
|
||||||
const oldIndex = order.indexOf(active.id as string);
|
const oldIndex = order.indexOf(active.id as string);
|
||||||
const newIndex = order.indexOf(over.id as string);
|
const newIndex = order.indexOf(over.id as string);
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
@@ -87,16 +94,13 @@ export const ConnectionColumnManager = ({
|
|||||||
>
|
>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<SortableColumnItem
|
<SortableColumnItem
|
||||||
key={column.id}
|
key={column.field}
|
||||||
column={column}
|
column={column}
|
||||||
label={getColumnLabel(column)}
|
onToggle={onToggle}
|
||||||
dragHandleLabel={t(
|
dragHandleLabel={t(
|
||||||
"connections.components.columnManager.dragHandle",
|
"connections.components.columnManager.dragHandle",
|
||||||
)}
|
)}
|
||||||
disableToggle={
|
disableToggle={column.visible && visibleCount <= 1}
|
||||||
!column.getCanHide() ||
|
|
||||||
(column.getIsVisible() && visibleCount <= 1)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
@@ -116,15 +120,15 @@ export const ConnectionColumnManager = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface SortableColumnItemProps {
|
interface SortableColumnItemProps {
|
||||||
column: Column<IConnectionsItem, unknown>;
|
column: ColumnOption;
|
||||||
label: string;
|
onToggle: (field: string, visible: boolean) => void;
|
||||||
dragHandleLabel: string;
|
dragHandleLabel: string;
|
||||||
disableToggle?: boolean;
|
disableToggle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableColumnItem = ({
|
const SortableColumnItem = ({
|
||||||
column,
|
column,
|
||||||
label,
|
onToggle,
|
||||||
dragHandleLabel,
|
dragHandleLabel,
|
||||||
disableToggle = false,
|
disableToggle = false,
|
||||||
}: SortableColumnItemProps) => {
|
}: SortableColumnItemProps) => {
|
||||||
@@ -135,7 +139,7 @@ const SortableColumnItem = ({
|
|||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id: column.id });
|
} = useSortable({ id: column.field });
|
||||||
|
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -163,12 +167,12 @@ const SortableColumnItem = ({
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
edge="start"
|
edge="start"
|
||||||
checked={column.getIsVisible()}
|
checked={column.visible}
|
||||||
disabled={disableToggle}
|
disabled={disableToggle}
|
||||||
onChange={(event) => column.toggleVisibility(event.target.checked)}
|
onChange={(event) => onToggle(column.field, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={label}
|
primary={column.label}
|
||||||
slotProps={{ primary: { variant: "body2" } }}
|
slotProps={{ primary: { variant: "body2" } }}
|
||||||
sx={{ mr: 1 }}
|
sx={{ mr: 1 }}
|
||||||
/>
|
/>
|
||||||
@@ -185,11 +189,3 @@ const SortableColumnItem = ({
|
|||||||
</ListItem>
|
</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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ViewColumnRounded } from "@mui/icons-material";
|
|||||||
import { Box, IconButton, Tooltip } from "@mui/material";
|
import { Box, IconButton, Tooltip } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnOrderState,
|
|
||||||
ColumnSizingState,
|
ColumnSizingState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -44,57 +43,50 @@ const reconcileColumnOrder = (
|
|||||||
return [...filtered, ...missing];
|
return [...filtered, ...missing];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ColumnField =
|
const createConnectionRow = (each: IConnectionsItem) => {
|
||||||
| "host"
|
|
||||||
| "download"
|
|
||||||
| "upload"
|
|
||||||
| "dlSpeed"
|
|
||||||
| "ulSpeed"
|
|
||||||
| "chains"
|
|
||||||
| "rule"
|
|
||||||
| "process"
|
|
||||||
| "time"
|
|
||||||
| "source"
|
|
||||||
| "remoteDestination"
|
|
||||||
| "type";
|
|
||||||
|
|
||||||
const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => {
|
|
||||||
const { metadata, rulePayload } = each;
|
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}`;
|
||||||
|
|
||||||
switch (field) {
|
return {
|
||||||
case "host":
|
id: each.id,
|
||||||
return metadata.host
|
host: metadata.host
|
||||||
? `${metadata.host}:${metadata.destinationPort}`
|
? `${metadata.host}:${metadata.destinationPort}`
|
||||||
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
|
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
|
||||||
case "download":
|
download: each.download,
|
||||||
return each.download;
|
upload: each.upload,
|
||||||
case "upload":
|
dlSpeed: each.curDownload,
|
||||||
return each.upload;
|
ulSpeed: each.curUpload,
|
||||||
case "dlSpeed":
|
chains,
|
||||||
return each.curDownload;
|
rule,
|
||||||
case "ulSpeed":
|
process: truncateStr(metadata.process || metadata.processPath),
|
||||||
return each.curUpload;
|
time: each.start,
|
||||||
case "chains":
|
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
|
||||||
return [...each.chains].reverse().join(" / ");
|
remoteDestination: destination,
|
||||||
case "rule":
|
type: `${metadata.type}(${metadata.network})`,
|
||||||
return rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
|
connectionData: each,
|
||||||
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 {
|
interface Props {
|
||||||
connections: IConnectionsItem[];
|
connections: IConnectionsItem[];
|
||||||
onShowDetail: (data: IConnectionsItem) => void;
|
onShowDetail: (data: IConnectionsItem) => void;
|
||||||
@@ -112,30 +104,33 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
onCloseColumnManager,
|
onCloseColumnManager,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
|
const [columnWidths, setColumnWidths] = useLocalStorage<
|
||||||
|
Record<string, number>
|
||||||
|
>(
|
||||||
"connection-table-widths",
|
"connection-table-widths",
|
||||||
// server-side value, this is the default value used by server-side rendering (if any)
|
// 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)
|
// Do not omit (otherwise a Suspense boundary will be triggered)
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const [columnVisibilityModel, setColumnVisibilityModel] =
|
const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage<
|
||||||
useLocalStorage<VisibilityState>(
|
Partial<Record<string, boolean>>
|
||||||
"connection-table-visibility",
|
>(
|
||||||
{},
|
"connection-table-visibility",
|
||||||
{
|
{},
|
||||||
serializer: JSON.stringify,
|
{
|
||||||
deserializer: (value) => {
|
serializer: JSON.stringify,
|
||||||
try {
|
deserializer: (value) => {
|
||||||
const parsed = JSON.parse(value);
|
try {
|
||||||
if (parsed && typeof parsed === "object") return parsed;
|
const parsed = JSON.parse(value);
|
||||||
} catch (err) {
|
if (parsed && typeof parsed === "object") return parsed;
|
||||||
console.warn("Failed to parse connection-table-visibility", err);
|
} catch (err) {
|
||||||
}
|
console.warn("Failed to parse connection-table-visibility", err);
|
||||||
return {};
|
}
|
||||||
},
|
return {};
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
|
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
|
||||||
"connection-table-order",
|
"connection-table-order",
|
||||||
@@ -154,13 +149,15 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
|
||||||
|
|
||||||
interface BaseColumn {
|
interface BaseColumn {
|
||||||
field: ColumnField;
|
field: ColumnField;
|
||||||
headerName: string;
|
headerName: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
cell?: (row: IConnectionsItem) => ReactNode;
|
cell?: (row: ConnectionRow) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns = useMemo<BaseColumn[]>(() => {
|
const baseColumns = useMemo<BaseColumn[]>(() => {
|
||||||
@@ -193,7 +190,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
width: 76,
|
width: 76,
|
||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
align: "right",
|
align: "right",
|
||||||
cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`,
|
cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "ulSpeed",
|
field: "ulSpeed",
|
||||||
@@ -201,7 +198,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
width: 76,
|
width: 76,
|
||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
align: "right",
|
align: "right",
|
||||||
cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`,
|
cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "chains",
|
field: "chains",
|
||||||
@@ -265,76 +262,177 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
});
|
});
|
||||||
}, [baseColumns, setColumnOrder]);
|
}, [baseColumns, setColumnOrder]);
|
||||||
|
|
||||||
const handleColumnVisibilityChange = useCallback(
|
const columns = useMemo<BaseColumn[]>(() => {
|
||||||
(update: Updater<VisibilityState>) => {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
setColumnVisibilityModel((prev) => {
|
setColumnVisibilityModel((prev) => {
|
||||||
const current = prev ?? {};
|
const next = { ...(prev ?? {}) };
|
||||||
const nextState =
|
if (visible) {
|
||||||
typeof update === "function" ? update(current) : update;
|
delete next[field];
|
||||||
|
} else {
|
||||||
const visibleCount = baseColumns.reduce((count, column) => {
|
next[field] = false;
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[baseColumns, setColumnVisibilityModel],
|
[setColumnVisibilityModel, visibleColumnsCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleColumnOrderChange = useCallback(
|
const handleManagerOrderChange = useCallback(
|
||||||
(update: Updater<ColumnOrderState>) => {
|
(order: string[]) => {
|
||||||
setColumnOrder((prev) => {
|
setColumnOrder(() => {
|
||||||
const current = Array.isArray(prev) ? prev : [];
|
|
||||||
const nextState =
|
|
||||||
typeof update === "function" ? update(current) : update;
|
|
||||||
const baseFields = baseColumns.map((col) => col.field);
|
const baseFields = baseColumns.map((col) => col.field);
|
||||||
return reconcileColumnOrder(nextState, baseFields);
|
return reconcileColumnOrder(order, baseFields);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[baseColumns, setColumnOrder],
|
[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 [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
|
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
|
||||||
return baseColumns.map((column) => {
|
return columns.map((column) => {
|
||||||
const baseCell: ColumnDef<IConnectionsItem>["cell"] = column.cell
|
const baseCell: ColumnDef<ConnectionRow>["cell"] = column.cell
|
||||||
? (ctx) => column.cell?.(ctx.row.original)
|
? (ctx) => column.cell?.(ctx.row.original)
|
||||||
: (ctx) => ctx.getValue() as ReactNode;
|
: (ctx) => ctx.getValue() as ReactNode;
|
||||||
|
|
||||||
const cell: ColumnDef<IConnectionsItem>["cell"] =
|
const cell: ColumnDef<ConnectionRow>["cell"] =
|
||||||
column.field === "time"
|
column.field === "time"
|
||||||
? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow)
|
? (ctx) => dayjs(ctx.row.original.time).from(relativeNow)
|
||||||
: baseCell;
|
: baseCell;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: column.field,
|
id: column.field,
|
||||||
accessorFn: (row) => getConnectionCellValue(column.field, row),
|
accessorKey: column.field,
|
||||||
header: column.headerName,
|
header: column.headerName,
|
||||||
size: column.width,
|
size: column.width,
|
||||||
minSize: column.minWidth,
|
minSize: column.minWidth ?? 80,
|
||||||
|
enableResizing: true,
|
||||||
meta: {
|
meta: {
|
||||||
align: column.align ?? "left",
|
align: column.align ?? "left",
|
||||||
field: column.field,
|
field: column.field,
|
||||||
label: column.headerName,
|
|
||||||
},
|
},
|
||||||
cell,
|
cell,
|
||||||
} satisfies ColumnDef<IConnectionsItem>;
|
} satisfies ColumnDef<ConnectionRow>;
|
||||||
});
|
});
|
||||||
}, [baseColumns, relativeNow]);
|
}, [columns, relativeNow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
@@ -352,7 +450,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
const prevState = prev ?? {};
|
const prevState = prev ?? {};
|
||||||
const nextState =
|
const nextState =
|
||||||
typeof updater === "function" ? updater(prevState) : updater;
|
typeof updater === "function" ? updater(prevState) : updater;
|
||||||
const sanitized: ColumnSizingState = {};
|
const sanitized: Record<string, number> = {};
|
||||||
Object.entries(nextState).forEach(([key, size]) => {
|
Object.entries(nextState).forEach(([key, size]) => {
|
||||||
if (typeof size === "number" && Number.isFinite(size)) {
|
if (typeof size === "number" && Number.isFinite(size)) {
|
||||||
sanitized[key] = size;
|
sanitized[key] = size;
|
||||||
@@ -365,45 +463,22 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: connections,
|
data: connRows,
|
||||||
state: {
|
state: {
|
||||||
columnVisibility: columnVisibilityModel ?? {},
|
columnVisibility: columnVisibilityState,
|
||||||
columnSizing: columnWidths,
|
columnSizing: columnWidths,
|
||||||
columnOrder,
|
|
||||||
sorting,
|
sorting,
|
||||||
},
|
},
|
||||||
initialState: {
|
|
||||||
columnOrder: baseColumns.map((col) => col.field),
|
|
||||||
},
|
|
||||||
defaultColumn: {
|
|
||||||
minSize: 80,
|
|
||||||
enableResizing: true,
|
|
||||||
},
|
|
||||||
columnResizeMode: "onChange",
|
columnResizeMode: "onChange",
|
||||||
enableSortingRemoval: true,
|
enableSortingRemoval: true,
|
||||||
getRowId: (row) => row.id,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
|
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnSizingChange: handleColumnSizingChange,
|
onColumnSizingChange: handleColumnSizingChange,
|
||||||
onColumnVisibilityChange: handleColumnVisibilityChange,
|
onColumnVisibilityChange: handleColumnVisibilityChange,
|
||||||
onColumnOrderChange: handleColumnOrderChange,
|
|
||||||
columns: columnDefs,
|
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 rows = table.getRowModel().rows;
|
||||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
@@ -416,7 +491,6 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
const totalSize = rowVirtualizer.getTotalSize();
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
const tableWidth = table.getTotalSize();
|
const tableWidth = table.getTotalSize();
|
||||||
const managerColumns = table.getAllLeafColumns();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -595,7 +669,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={() => onShowDetail(row.original)}
|
onClick={() => onShowDetail(row.original.connectionData)}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -652,8 +726,9 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
</Box>
|
</Box>
|
||||||
<ConnectionColumnManager
|
<ConnectionColumnManager
|
||||||
open={columnManagerOpen}
|
open={columnManagerOpen}
|
||||||
columns={managerColumns}
|
columns={columnOptions}
|
||||||
onClose={onCloseColumnManager}
|
onClose={onCloseColumnManager}
|
||||||
|
onToggle={handleToggleColumn}
|
||||||
onOrderChange={handleManagerOrderChange}
|
onOrderChange={handleManagerOrderChange}
|
||||||
onReset={handleResetColumns}
|
onReset={handleResetColumns}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useClash } from "@/hooks/use-clash";
|
import { useClash } from "@/hooks/use-clash";
|
||||||
import { useAppData } from "@/providers/app-data-context";
|
import {
|
||||||
|
useAppUptime,
|
||||||
|
useClashConfig,
|
||||||
|
useRulesData,
|
||||||
|
useSystemProxyAddress,
|
||||||
|
useSystemProxyData,
|
||||||
|
} from "@/hooks/use-clash-data";
|
||||||
|
|
||||||
import { EnhancedCard } from "./enhanced-card";
|
import { EnhancedCard } from "./enhanced-card";
|
||||||
|
|
||||||
@@ -19,7 +25,14 @@ const formatUptime = (uptimeMs: number) => {
|
|||||||
export const ClashInfoCard = () => {
|
export const ClashInfoCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { version: clashVersion } = useClash();
|
const { version: clashVersion } = useClash();
|
||||||
const { clashConfig, rules, uptime, systemProxyAddress } = useAppData();
|
const { clashConfig } = useClashConfig();
|
||||||
|
const { sysproxy } = useSystemProxyData();
|
||||||
|
const { rules } = useRulesData();
|
||||||
|
const { uptime } = useAppUptime();
|
||||||
|
const systemProxyAddress = useSystemProxyAddress({
|
||||||
|
clashConfig,
|
||||||
|
sysproxy,
|
||||||
|
});
|
||||||
|
|
||||||
// 使用useMemo缓存格式化后的uptime,避免频繁计算
|
// 使用useMemo缓存格式化后的uptime,避免频繁计算
|
||||||
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
|
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { closeAllConnections } from "tauri-plugin-mihomo-api";
|
import { closeAllConnections } from "tauri-plugin-mihomo-api";
|
||||||
|
|
||||||
|
import { useClashConfig } from "@/hooks/use-clash-data";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { useAppData } from "@/providers/app-data-context";
|
|
||||||
import { patchClashMode } from "@/services/cmds";
|
import { patchClashMode } from "@/services/cmds";
|
||||||
import type { TranslationKey } from "@/types/generated/i18n-keys";
|
import type { TranslationKey } from "@/types/generated/i18n-keys";
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ const MODE_META: Record<
|
|||||||
export const ClashModeCard = () => {
|
export const ClashModeCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const { clashConfig, refreshClashConfig } = useAppData();
|
const { clashConfig, refreshClashConfig } = useClashConfig();
|
||||||
|
|
||||||
// 支持的模式列表
|
// 支持的模式列表
|
||||||
const modeList = CLASH_MODES;
|
const modeList = CLASH_MODES;
|
||||||
|
|||||||
@@ -27,22 +27,21 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import React, {
|
import React from "react";
|
||||||
useCallback,
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
|
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
|
||||||
|
|
||||||
import { EnhancedCard } from "@/components/home/enhanced-card";
|
import { EnhancedCard } from "@/components/home/enhanced-card";
|
||||||
|
import {
|
||||||
|
useClashConfig,
|
||||||
|
useProxiesData,
|
||||||
|
useRulesData,
|
||||||
|
} from "@/hooks/use-clash-data";
|
||||||
import { useProfiles } from "@/hooks/use-profiles";
|
import { useProfiles } from "@/hooks/use-profiles";
|
||||||
import { useProxySelection } from "@/hooks/use-proxy-selection";
|
import { useProxySelection } from "@/hooks/use-proxy-selection";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { useAppData } from "@/providers/app-data-context";
|
|
||||||
import delayManager from "@/services/delay";
|
import delayManager from "@/services/delay";
|
||||||
import { debugLog } from "@/utils/debug";
|
import { debugLog } from "@/utils/debug";
|
||||||
|
|
||||||
@@ -51,8 +50,8 @@ const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
|
|||||||
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
|
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
|
||||||
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
|
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
|
||||||
|
|
||||||
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5;
|
const AUTO_CHECK_INITIAL_DELAY_MS = 1500;
|
||||||
const AUTO_CHECK_INITIAL_DELAY_MS = 100;
|
const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
// 代理节点信息接口
|
// 代理节点信息接口
|
||||||
interface ProxyOption {
|
interface ProxyOption {
|
||||||
@@ -89,13 +88,9 @@ function getSignalIcon(delay: number): {
|
|||||||
text: string;
|
text: string;
|
||||||
color: string;
|
color: string;
|
||||||
} {
|
} {
|
||||||
if (delay === -2)
|
if (delay < 0)
|
||||||
return { icon: <SignalNone />, text: "测试中", color: "text.secondary" };
|
|
||||||
if (delay === -1)
|
|
||||||
return { icon: <SignalNone />, text: "未测试", color: "text.secondary" };
|
return { icon: <SignalNone />, text: "未测试", color: "text.secondary" };
|
||||||
if (delay > 1e5)
|
if (delay >= 10000)
|
||||||
return { icon: <SignalError />, text: "错误", color: "error.main" };
|
|
||||||
if (delay === 0 || delay >= 10000)
|
|
||||||
return { icon: <SignalError />, text: "超时", color: "error.main" };
|
return { icon: <SignalError />, text: "超时", color: "error.main" };
|
||||||
if (delay >= 500)
|
if (delay >= 500)
|
||||||
return { icon: <SignalWeak />, text: "延迟较高", color: "error.main" };
|
return { icon: <SignalWeak />, text: "延迟较高", color: "error.main" };
|
||||||
@@ -110,19 +105,13 @@ export const CurrentProxyCard = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { proxies, clashConfig, refreshProxy, rules } = useAppData();
|
const { proxies, refreshProxy } = useProxiesData();
|
||||||
|
const { clashConfig } = useClashConfig();
|
||||||
|
const { rules } = useRulesData();
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const { current: currentProfile } = useProfiles();
|
const { current: currentProfile } = useProfiles();
|
||||||
const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false;
|
const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false;
|
||||||
const defaultLatencyTimeout = verge?.default_latency_timeout;
|
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 currentProfileId = currentProfile?.uid || null;
|
||||||
|
|
||||||
const getProfileStorageKey = useCallback(
|
const getProfileStorageKey = useCallback(
|
||||||
@@ -609,13 +598,13 @@ export const CurrentProxyCard = () => {
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
await checkCurrentProxyDelay();
|
await checkCurrentProxyDelay();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs);
|
intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
initialTimer = setTimeout(async () => {
|
initialTimer = setTimeout(async () => {
|
||||||
await checkCurrentProxyDelay();
|
await checkCurrentProxyDelay();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs);
|
intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS);
|
||||||
}, AUTO_CHECK_INITIAL_DELAY_MS);
|
}, AUTO_CHECK_INITIAL_DELAY_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -625,7 +614,6 @@ export const CurrentProxyCard = () => {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
checkCurrentProxyDelay,
|
checkCurrentProxyDelay,
|
||||||
autoDelayIntervalMs,
|
|
||||||
isDirectMode,
|
isDirectMode,
|
||||||
state.selection.group,
|
state.selection.group,
|
||||||
state.selection.proxy,
|
state.selection.proxy,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
||||||
import React, { forwardRef, ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
// 自定义卡片组件接口
|
// 自定义卡片组件接口
|
||||||
interface EnhancedCardProps {
|
interface EnhancedCardProps {
|
||||||
@@ -19,109 +19,103 @@ interface EnhancedCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自定义卡片组件
|
// 自定义卡片组件
|
||||||
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
|
export const EnhancedCard = ({
|
||||||
(
|
title,
|
||||||
{
|
icon,
|
||||||
title,
|
action,
|
||||||
icon,
|
children,
|
||||||
action,
|
iconColor = "primary",
|
||||||
children,
|
minHeight,
|
||||||
iconColor = "primary",
|
noContentPadding = false,
|
||||||
minHeight,
|
}: EnhancedCardProps) => {
|
||||||
noContentPadding = false,
|
const theme = useTheme();
|
||||||
},
|
const isDark = theme.palette.mode === "dark";
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isDark = theme.palette.mode === "dark";
|
|
||||||
|
|
||||||
// 统一的标题截断样式
|
// 统一的标题截断样式
|
||||||
const titleTruncateStyle = {
|
const titleTruncateStyle = {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
display: "block",
|
display: "block",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: "100%",
|
px: 2,
|
||||||
|
py: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
alignItems: "center",
|
||||||
borderRadius: 2,
|
justifyContent: "space-between",
|
||||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
minWidth: 0,
|
||||||
borderBottom: 1,
|
flex: 1,
|
||||||
borderColor: "divider",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minWidth: 0,
|
justifyContent: "center",
|
||||||
flex: 1,
|
borderRadius: 1.5,
|
||||||
overflow: "hidden",
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
mr: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
||||||
|
color: theme.palette[iconColor].main,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{icon}
|
||||||
sx={{
|
</Box>
|
||||||
display: "flex",
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
alignItems: "center",
|
{typeof title === "string" ? (
|
||||||
justifyContent: "center",
|
<Typography
|
||||||
borderRadius: 1.5,
|
variant="h6"
|
||||||
width: 38,
|
fontWeight="medium"
|
||||||
height: 38,
|
fontSize={18}
|
||||||
mr: 1.5,
|
sx={titleTruncateStyle}
|
||||||
flexShrink: 0,
|
title={title}
|
||||||
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
>
|
||||||
color: theme.palette[iconColor].main,
|
{title}
|
||||||
}}
|
</Typography>
|
||||||
>
|
) : (
|
||||||
{icon}
|
<Box sx={titleTruncateStyle}>{title}</Box>
|
||||||
</Box>
|
)}
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
||||||
{typeof title === "string" ? (
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
fontWeight="medium"
|
|
||||||
fontSize={18}
|
|
||||||
sx={titleTruncateStyle}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box sx={titleTruncateStyle}>{title}</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
p: noContentPadding ? 0 : 2,
|
|
||||||
...(minHeight && { minHeight }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
<Box
|
||||||
},
|
sx={{
|
||||||
);
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
p: noContentPadding ? 0 : 2,
|
||||||
|
...(minHeight && { minHeight }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import { useAppData } from "@/providers/app-data-context";
|
import { useRefreshAll } from "@/hooks/use-clash-data";
|
||||||
import { openWebUrl, updateProfile } from "@/services/cmds";
|
import { openWebUrl, updateProfile } from "@/services/cmds";
|
||||||
import { showNotice } from "@/services/notice-service";
|
import { showNotice } from "@/services/notice-service";
|
||||||
import parseTraffic from "@/utils/parse-traffic";
|
import parseTraffic from "@/utils/parse-traffic";
|
||||||
@@ -281,7 +281,7 @@ export const HomeProfileCard = ({
|
|||||||
}: HomeProfileCardProps) => {
|
}: HomeProfileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refreshAll } = useAppData();
|
const refreshAll = useRefreshAll();
|
||||||
|
|
||||||
// 更新当前订阅
|
// 更新当前订阅
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|||||||
@@ -5,32 +5,19 @@ import {
|
|||||||
VisibilityOutlined,
|
VisibilityOutlined,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEffect } from "foxact/use-abortable-effect";
|
|
||||||
import { useIntersection } from "foxact/use-intersection";
|
|
||||||
import type { XOR } from "foxts/ts-xor";
|
|
||||||
import {
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useState,
|
|
||||||
useEffectEvent,
|
|
||||||
useMemo,
|
|
||||||
forwardRef,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWRImmutable from "swr/immutable";
|
|
||||||
|
|
||||||
import { getIpInfo } from "@/services/api";
|
import { getIpInfo } from "@/services/api";
|
||||||
import { SWR_EXTERNAL_API } from "@/services/config";
|
|
||||||
|
|
||||||
import { EnhancedCard } from "./enhanced-card";
|
import { EnhancedCard } from "./enhanced-card";
|
||||||
|
|
||||||
// 定义刷新时间(秒)
|
// 定义刷新时间(秒)
|
||||||
const IP_REFRESH_SECONDS = 300;
|
const IP_REFRESH_SECONDS = 300;
|
||||||
const COUNTDOWN_TICK_INTERVAL = 5_000;
|
|
||||||
const IP_INFO_CACHE_KEY = "cv_ip_info_cache";
|
const IP_INFO_CACHE_KEY = "cv_ip_info_cache";
|
||||||
|
|
||||||
const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
|
// 提取InfoItem子组件并使用memo优化
|
||||||
|
const InfoItem = memo(({ label, value }: { label: string; value: string }) => (
|
||||||
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
|
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -56,7 +43,7 @@ const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
// 获取国旗表情
|
// 获取国旗表情
|
||||||
const getCountryFlag = (countryCode: string | undefined) => {
|
const getCountryFlag = (countryCode: string) => {
|
||||||
if (!countryCode) return "";
|
if (!countryCode) return "";
|
||||||
const codePoints = countryCode
|
const codePoints = countryCode
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
@@ -65,184 +52,162 @@ const getCountryFlag = (countryCode: string | undefined) => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
};
|
};
|
||||||
|
|
||||||
type CountDownState = XOR<
|
|
||||||
{
|
|
||||||
type: "countdown";
|
|
||||||
remainingSeconds: number;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "revalidating";
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
|
|
||||||
({ children }, ref) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { mutate } = useIPInfo();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnhancedCard
|
|
||||||
title={t("home.components.ipInfo.title")}
|
|
||||||
icon={<LocationOnOutlined />}
|
|
||||||
iconColor="info"
|
|
||||||
ref={ref}
|
|
||||||
action={
|
|
||||||
<IconButton size="small" onClick={() => mutate()}>
|
|
||||||
<RefreshOutlined />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</EnhancedCard>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// IP信息卡片组件
|
// IP信息卡片组件
|
||||||
export const IpInfoCard = () => {
|
export const IpInfoCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [ipInfo, setIpInfo] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
const [showIp, setShowIp] = useState(false);
|
const [showIp, setShowIp] = useState(false);
|
||||||
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
|
||||||
|
const lastFetchRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// track ip info card has been in viewport or not
|
const fetchIpInfo = useCallback(
|
||||||
// hasIntersected default to false, and will be true once the card is in viewport
|
async (force = false) => {
|
||||||
// and will never be false again afterwards (unless resetIntersected is called or
|
setError("");
|
||||||
// the component is unmounted)
|
|
||||||
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
|
|
||||||
rootMargin: "0px",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [countdown, setCountdown] = useState<CountDownState>({
|
try {
|
||||||
type: "countdown",
|
if (!force && typeof window !== "undefined" && window.sessionStorage) {
|
||||||
remainingSeconds: IP_REFRESH_SECONDS,
|
const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY);
|
||||||
});
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
|
const now = Date.now();
|
||||||
|
if (
|
||||||
// function useEffectEvent
|
parsed?.ts &&
|
||||||
const onCountdownTick = useEffectEvent(async () => {
|
parsed?.data &&
|
||||||
const now = Date.now();
|
now - parsed.ts < IP_REFRESH_SECONDS * 1000
|
||||||
const ts = ipInfo?.lastFetchTs;
|
) {
|
||||||
if (!ts) {
|
setIpInfo(parsed.data);
|
||||||
return;
|
lastFetchRef.current = parsed.ts;
|
||||||
}
|
const elapsed = Math.floor((now - parsed.ts) / 1000);
|
||||||
|
setCountdown(Math.max(IP_REFRESH_SECONDS - elapsed, 0));
|
||||||
const elapsed = Math.floor((now - ts) / 1000);
|
setLoading(false);
|
||||||
const remaining = IP_REFRESH_SECONDS - elapsed;
|
return;
|
||||||
|
}
|
||||||
if (remaining <= 0) {
|
}
|
||||||
if (
|
|
||||||
// has intersected at least once
|
|
||||||
// this avoids unncessary revalidation if user never scrolls down,
|
|
||||||
// then we will only load initially once.
|
|
||||||
hasIntersected &&
|
|
||||||
// is online
|
|
||||||
navigator.onLine &&
|
|
||||||
// there is no ongoing revalidation already scheduled
|
|
||||||
countdown.type !== "revalidating" &&
|
|
||||||
// window is visible
|
|
||||||
(await appWindow.isVisible())
|
|
||||||
) {
|
|
||||||
setCountdown({ type: "revalidating" });
|
|
||||||
// we do not care about the result of mutate here. after mutate is done,
|
|
||||||
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
|
|
||||||
try {
|
|
||||||
await mutate();
|
|
||||||
} finally {
|
|
||||||
// in case mutate throws error, we still need to reset the countdown state
|
|
||||||
setCountdown({
|
|
||||||
type: "countdown",
|
|
||||||
remainingSeconds: IP_REFRESH_SECONDS,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) {
|
||||||
// do nothing. we even skip "setCountdown" to reduce re-renders
|
console.warn("Failed to read IP info from sessionStorage:", e);
|
||||||
//
|
|
||||||
// but the remaining time still <= 0, and setInterval is not stopped, this
|
|
||||||
// callback will still be regularly triggered, as soon as the window is visible
|
|
||||||
// or network online again, we mutate() immediately in the following tick.
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setCountdown({
|
|
||||||
type: "countdown",
|
|
||||||
remainingSeconds: remaining,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getIpInfo();
|
||||||
|
setIpInfo(data);
|
||||||
|
const ts = Date.now();
|
||||||
|
lastFetchRef.current = ts;
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined" && window.sessionStorage) {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
IP_INFO_CACHE_KEY,
|
||||||
|
JSON.stringify({ data, ts }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to write IP info to sessionStorage:", e);
|
||||||
|
}
|
||||||
|
setCountdown(IP_REFRESH_SECONDS);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t("home.components.ipInfo.errors.load"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件加载时获取IP信息并启动基于上次请求时间的倒计时
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchIpInfo();
|
||||||
|
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
|
|
||||||
// Do not add document.hidden check here as it is not reliable in Tauri.
|
const startCountdown = () => {
|
||||||
//
|
timer = window.setInterval(() => {
|
||||||
// Thank god IntersectionObserver is a DOM API that relies on DOM/webview
|
const now = Date.now();
|
||||||
// instead of Tauri, which is reliable enough.
|
let ts = lastFetchRef.current;
|
||||||
if (hasIntersected) {
|
try {
|
||||||
console.debug(
|
if (!ts && typeof window !== "undefined" && window.sessionStorage) {
|
||||||
"IP info card has entered the viewport, starting the countdown interval.",
|
const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY);
|
||||||
);
|
if (raw) {
|
||||||
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL);
|
const parsed = JSON.parse(raw);
|
||||||
} else {
|
ts = parsed?.ts || null;
|
||||||
console.debug(
|
}
|
||||||
"IP info card has not yet entered the viewport, no counting down.",
|
}
|
||||||
);
|
} catch (e) {
|
||||||
}
|
console.warn("Failed to read IP info from sessionStorage:", e);
|
||||||
|
ts = ts || null;
|
||||||
// This will fire when the window is minimized or restored
|
|
||||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
||||||
// Tauri's visibility change detection is actually broken on some platforms:
|
|
||||||
// https://github.com/tauri-apps/tauri/issues/10592
|
|
||||||
//
|
|
||||||
// It is working on macOS though (tested).
|
|
||||||
// So at least we should try to pause countdown on supported platforms to
|
|
||||||
// reduce power consumption.
|
|
||||||
function onVisibilityChange() {
|
|
||||||
if (document.hidden) {
|
|
||||||
console.debug("Document hidden, pause the interval");
|
|
||||||
// Pause the timer
|
|
||||||
if (timer != null) {
|
|
||||||
clearInterval(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
}
|
||||||
} else if (hasIntersected) {
|
|
||||||
console.debug("Document visible, resume the interval");
|
|
||||||
// Resume the timer only when previous one is cleared
|
|
||||||
if (timer == null) {
|
|
||||||
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.debug(
|
|
||||||
"Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
const elapsed = ts ? Math.floor((now - ts) / 1000) : 0;
|
||||||
if (timer != null) clearInterval(timer);
|
let remaining = IP_REFRESH_SECONDS - elapsed;
|
||||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
fetchIpInfo();
|
||||||
|
remaining = IP_REFRESH_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每5秒或倒计时结束时才更新UI
|
||||||
|
if (remaining % 5 === 0 || remaining <= 0) {
|
||||||
|
setCountdown(remaining);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
};
|
};
|
||||||
}, [hasIntersected]);
|
|
||||||
|
startCountdown();
|
||||||
|
return () => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [fetchIpInfo]);
|
||||||
|
|
||||||
const toggleShowIp = useCallback(() => {
|
const toggleShowIp = useCallback(() => {
|
||||||
setShowIp((prev) => !prev);
|
setShowIp((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
let mainElement: React.ReactElement;
|
// 渲染加载状态
|
||||||
|
if (loading) {
|
||||||
switch (true) {
|
return (
|
||||||
case isLoading:
|
<EnhancedCard
|
||||||
mainElement = (
|
title={t("home.components.ipInfo.title")}
|
||||||
|
icon={<LocationOnOutlined />}
|
||||||
|
iconColor="info"
|
||||||
|
action={
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => fetchIpInfo(true)}
|
||||||
|
disabled={true}
|
||||||
|
>
|
||||||
|
<RefreshOutlined />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
<Skeleton variant="text" width="60%" height={30} />
|
<Skeleton variant="text" width="60%" height={30} />
|
||||||
<Skeleton variant="text" width="80%" height={24} />
|
<Skeleton variant="text" width="80%" height={24} />
|
||||||
<Skeleton variant="text" width="70%" height={24} />
|
<Skeleton variant="text" width="70%" height={24} />
|
||||||
<Skeleton variant="text" width="50%" height={24} />
|
<Skeleton variant="text" width="50%" height={24} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
</EnhancedCard>
|
||||||
break;
|
);
|
||||||
case !!error:
|
}
|
||||||
mainElement = (
|
|
||||||
|
// 渲染错误状态
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<EnhancedCard
|
||||||
|
title={t("home.components.ipInfo.title")}
|
||||||
|
icon={<LocationOnOutlined />}
|
||||||
|
iconColor="info"
|
||||||
|
action={
|
||||||
|
<IconButton size="small" onClick={() => fetchIpInfo(true)}>
|
||||||
|
<RefreshOutlined />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -254,173 +219,170 @@ export const IpInfoCard = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" color="error">
|
<Typography variant="body1" color="error">
|
||||||
{error instanceof Error
|
{error}
|
||||||
? error.message
|
|
||||||
: t("home.components.ipInfo.errors.load")}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button onClick={() => mutate()} sx={{ mt: 2 }}>
|
<Button onClick={() => fetchIpInfo(true)} sx={{ mt: 2 }}>
|
||||||
{t("shared.actions.retry")}
|
{t("shared.actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
</EnhancedCard>
|
||||||
break;
|
);
|
||||||
default: // Normal render
|
}
|
||||||
mainElement = (
|
|
||||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
// 渲染正常数据
|
||||||
<Box
|
return (
|
||||||
sx={{
|
<EnhancedCard
|
||||||
display: "flex",
|
title={t("home.components.ipInfo.title")}
|
||||||
flexDirection: "row",
|
icon={<LocationOnOutlined />}
|
||||||
flex: 1,
|
iconColor="info"
|
||||||
overflow: "hidden",
|
action={
|
||||||
}}
|
<IconButton size="small" onClick={() => fetchIpInfo(true)}>
|
||||||
>
|
<RefreshOutlined />
|
||||||
{/* 左侧:国家和IP地址 */}
|
</IconButton>
|
||||||
<Box sx={{ width: "40%", overflow: "hidden" }}>
|
}
|
||||||
|
>
|
||||||
|
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 左侧:国家和IP地址 */}
|
||||||
|
<Box sx={{ width: "40%", overflow: "hidden" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
mr: 1,
|
||||||
|
display: "inline-block",
|
||||||
|
width: 28,
|
||||||
|
textAlign: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontFamily: '"twemoji mozilla", sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getCountryFlag(ipInfo?.country_code)}
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "medium",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{t("home.components.ipInfo.labels.ip")}:
|
||||||
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
mb: 1,
|
ml: 1,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
maxWidth: "calc(100% - 30px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
mr: 1,
|
|
||||||
display: "inline-block",
|
|
||||||
width: 28,
|
|
||||||
textAlign: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
fontFamily: '"twemoji mozilla", sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getCountryFlag(ipInfo?.country_code)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{
|
|
||||||
fontWeight: "medium",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
maxWidth: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ipInfo?.country ||
|
|
||||||
t("home.components.ipInfo.labels.unknown")}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{t("home.components.ipInfo.labels.ip")}:
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
fontFamily: "monospace",
|
||||||
alignItems: "center",
|
fontSize: "0.75rem",
|
||||||
ml: 1,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
maxWidth: "calc(100% - 30px)",
|
textOverflow: "ellipsis",
|
||||||
|
wordBreak: "break-all",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
{showIp ? ipInfo?.ip : "••••••••••"}
|
||||||
variant="body2"
|
</Typography>
|
||||||
sx={{
|
<IconButton size="small" onClick={toggleShowIp}>
|
||||||
fontFamily: "monospace",
|
{showIp ? (
|
||||||
fontSize: "0.75rem",
|
<VisibilityOffOutlined fontSize="small" />
|
||||||
overflow: "hidden",
|
) : (
|
||||||
textOverflow: "ellipsis",
|
<VisibilityOutlined fontSize="small" />
|
||||||
wordBreak: "break-all",
|
)}
|
||||||
}}
|
</IconButton>
|
||||||
>
|
|
||||||
{showIp ? ipInfo?.ip : "••••••••••"}
|
|
||||||
</Typography>
|
|
||||||
<IconButton size="small" onClick={toggleShowIp}>
|
|
||||||
{showIp ? (
|
|
||||||
<VisibilityOffOutlined fontSize="small" />
|
|
||||||
) : (
|
|
||||||
<VisibilityOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.asn")}
|
|
||||||
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 右侧:组织、ISP和位置信息 */}
|
<InfoItem
|
||||||
<Box sx={{ width: "60%", overflow: "auto" }}>
|
label={t("home.components.ipInfo.labels.asn")}
|
||||||
<InfoItem
|
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
|
||||||
label={t("home.components.ipInfo.labels.isp")}
|
/>
|
||||||
value={ipInfo?.organization}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.org")}
|
|
||||||
value={ipInfo?.asn_organization}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.location")}
|
|
||||||
value={[ipInfo?.city, ipInfo?.region]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.timezone")}
|
|
||||||
value={ipInfo?.timezone}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
{/* 右侧:组织、ISP和位置信息 */}
|
||||||
sx={{
|
<Box sx={{ width: "60%", overflow: "auto" }}>
|
||||||
mt: "auto",
|
<InfoItem
|
||||||
pt: 0.5,
|
label={t("home.components.ipInfo.labels.isp")}
|
||||||
borderTop: 1,
|
value={ipInfo?.isp}
|
||||||
borderColor: "divider",
|
/>
|
||||||
display: "flex",
|
<InfoItem
|
||||||
justifyContent: "space-between",
|
label={t("home.components.ipInfo.labels.org")}
|
||||||
alignItems: "center",
|
value={ipInfo?.asn_organization}
|
||||||
opacity: 0.7,
|
/>
|
||||||
fontSize: "0.7rem",
|
<InfoItem
|
||||||
}}
|
label={t("home.components.ipInfo.labels.location")}
|
||||||
>
|
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
|
||||||
<Typography variant="caption">
|
/>
|
||||||
{t("home.components.ipInfo.labels.autoRefresh")}
|
<InfoItem
|
||||||
{countdown.type === "countdown"
|
label={t("home.components.ipInfo.labels.timezone")}
|
||||||
? `: ${countdown.remainingSeconds}s`
|
value={ipInfo?.timezone}
|
||||||
: "..."}
|
/>
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
overflow: "hidden",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<Box
|
||||||
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
|
sx={{
|
||||||
|
mt: "auto",
|
||||||
|
pt: 0.5,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "}
|
||||||
|
{ipInfo?.latitude?.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</EnhancedCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function useIPInfo() {
|
|
||||||
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import { useLockFn } from "ahooks";
|
|||||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { useServiceInstaller } from "@/hooks/use-service-installer";
|
import { useServiceInstaller } from "@/hooks/use-service-installer";
|
||||||
import { useSystemState } from "@/hooks/use-system-state";
|
import { useSystemState } from "@/hooks/use-system-state";
|
||||||
import { useUpdate } from "@/hooks/use-update";
|
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { getSystemInfo } from "@/services/cmds";
|
import { getSystemInfo } from "@/services/cmds";
|
||||||
import { showNotice } from "@/services/notice-service";
|
import { showNotice } from "@/services/notice-service";
|
||||||
|
import { checkUpdateSafe as checkUpdate } from "@/services/update";
|
||||||
import { version as appVersion } from "@root/package.json";
|
import { version as appVersion } from "@root/package.json";
|
||||||
|
|
||||||
import { EnhancedCard } from "./enhanced-card";
|
import { EnhancedCard } from "./enhanced-card";
|
||||||
@@ -51,18 +52,6 @@ export const SystemInfoCard = () => {
|
|||||||
const { isAdminMode, isSidecarMode } = useSystemState();
|
const { isAdminMode, isSidecarMode } = useSystemState();
|
||||||
const { installServiceAndRestartCore } = useServiceInstaller();
|
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, {
|
const [systemState, dispatchSystemState] = useReducer(systemStateReducer, {
|
||||||
osInfo: "",
|
osInfo: "",
|
||||||
@@ -120,7 +109,7 @@ export const SystemInfoCard = () => {
|
|||||||
|
|
||||||
timeoutId = window.setTimeout(() => {
|
timeoutId = window.setTimeout(() => {
|
||||||
if (verge?.auto_check_update) {
|
if (verge?.auto_check_update) {
|
||||||
triggerCheckUpdate().catch(console.error);
|
checkUpdate().catch(console.error);
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
@@ -129,7 +118,26 @@ export const SystemInfoCard = () => {
|
|||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]);
|
}, [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小时内不重复检查
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 导航到设置页面
|
// 导航到设置页面
|
||||||
const goToSettings = useCallback(() => {
|
const goToSettings = useCallback(() => {
|
||||||
@@ -156,7 +164,7 @@ export const SystemInfoCard = () => {
|
|||||||
// 检查更新
|
// 检查更新
|
||||||
const onCheckUpdate = useLockFn(async () => {
|
const onCheckUpdate = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
const info = await triggerCheckUpdate();
|
const info = await checkUpdate();
|
||||||
if (!info?.available) {
|
if (!info?.available) {
|
||||||
showNotice.success(
|
showNotice.success(
|
||||||
"settings.components.verge.advanced.notifications.latestVersion",
|
"settings.components.verge.advanced.notifications.latestVersion",
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
|
|||||||
countY(y),
|
countY(y),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
context.moveTo(points[0][0], points[0][1]);
|
let x = points[0][0];
|
||||||
|
let y = points[0][1];
|
||||||
|
|
||||||
|
context.moveTo(x, y);
|
||||||
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
for (let i = 1; i < points.length; i++) {
|
||||||
const p1 = points[i];
|
const p1 = points[i];
|
||||||
@@ -117,6 +120,8 @@ export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
|
|||||||
const y1 = (p1[1] + p2[1]) / 2;
|
const y1 = (p1[1] + p2[1]) / 2;
|
||||||
|
|
||||||
context.quadraticCurveTo(p1[0], p1[1], x1, y1);
|
context.quadraticCurveTo(p1[0], p1[1], x1, y1);
|
||||||
|
x = x1;
|
||||||
|
y = y1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { DialogRef } from "@/components/base";
|
import { DialogRef } from "@/components/base";
|
||||||
import { useUpdate } from "@/hooks/use-update";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
|
import { checkUpdateSafe } from "@/services/update";
|
||||||
|
|
||||||
import { UpdateViewer } from "../setting/mods/update-viewer";
|
import { UpdateViewer } from "../setting/mods/update-viewer";
|
||||||
|
|
||||||
@@ -12,9 +14,20 @@ interface Props {
|
|||||||
|
|
||||||
export const UpdateButton = (props: Props) => {
|
export const UpdateButton = (props: Props) => {
|
||||||
const { className } = props;
|
const { className } = props;
|
||||||
|
const { verge } = useVerge();
|
||||||
|
const { auto_check_update } = verge || {};
|
||||||
|
|
||||||
const viewerRef = useRef<DialogRef>(null);
|
const viewerRef = useRef<DialogRef>(null);
|
||||||
|
|
||||||
const { updateInfo } = useUpdate();
|
const { data: updateInfo } = useSWR(
|
||||||
|
auto_check_update || auto_check_update === null ? "checkUpdate" : null,
|
||||||
|
checkUpdateSafe,
|
||||||
|
{
|
||||||
|
errorRetryCount: 2,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
focusThrottleInterval: 36e5, // 1 hour
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!updateInfo?.available) return null;
|
if (!updateInfo?.available) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ export const GroupItem = (props: Props) => {
|
|||||||
<img
|
<img
|
||||||
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`}
|
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`}
|
||||||
width="32px"
|
width="32px"
|
||||||
style={{
|
|
||||||
marginRight: "12px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ListItemText
|
<ListItemText
|
||||||
|
|||||||
@@ -490,18 +490,11 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
]}
|
]}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
getOptionLabel={translateStrategy}
|
getOptionLabel={translateStrategy}
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => (
|
||||||
const { key, ...optionProps } = props;
|
<li {...props} title={translateStrategy(option)}>
|
||||||
return (
|
{translateStrategy(option)}
|
||||||
<li
|
</li>
|
||||||
key={key}
|
)}
|
||||||
{...optionProps}
|
|
||||||
title={translateStrategy(option)}
|
|
||||||
>
|
|
||||||
{translateStrategy(option)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onChange={(_, value) => value && field.onChange(value)}
|
onChange={(_, value) => value && field.onChange(value)}
|
||||||
renderInput={(params) => <TextField {...params} />}
|
renderInput={(params) => <TextField {...params} />}
|
||||||
/>
|
/>
|
||||||
@@ -564,18 +557,11 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
disableCloseOnSelect
|
disableCloseOnSelect
|
||||||
onChange={(_, value) => value && field.onChange(value)}
|
onChange={(_, value) => value && field.onChange(value)}
|
||||||
renderInput={(params) => <TextField {...params} />}
|
renderInput={(params) => <TextField {...params} />}
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => (
|
||||||
const { key, ...optionProps } = props;
|
<li {...props} title={translatePolicy(option)}>
|
||||||
return (
|
{translatePolicy(option)}
|
||||||
<li
|
</li>
|
||||||
key={key}
|
)}
|
||||||
{...optionProps}
|
|
||||||
title={translatePolicy(option)}
|
|
||||||
>
|
|
||||||
{translatePolicy(option)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
getOptionLabel={translatePolicy}
|
getOptionLabel={translatePolicy}
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -615,7 +601,7 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="http://cp.cloudflare.com"
|
placeholder="https://cp.cloudflare.com/generate_204"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ width: "calc(100% - 150px)" }}
|
sx={{ width: "calc(100% - 150px)" }}
|
||||||
{...field}
|
{...field}
|
||||||
@@ -841,7 +827,6 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
"WireGuard",
|
"WireGuard",
|
||||||
"Tuic",
|
"Tuic",
|
||||||
"Mieru",
|
"Mieru",
|
||||||
"Masque",
|
|
||||||
"AnyTLS",
|
"AnyTLS",
|
||||||
"Sudoku",
|
"Sudoku",
|
||||||
"Relay",
|
"Relay",
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
||||||
const proxies: IProxyConfig[] = [];
|
const proxies: IProxyConfig[] = [];
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
let uris: string;
|
let uris = "";
|
||||||
try {
|
try {
|
||||||
uris = atob(proxyUri);
|
uris = atob(proxyUri);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -557,12 +557,11 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name)
|
t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name)
|
||||||
}
|
}
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => {
|
||||||
const { key, ...optionProps } = props;
|
|
||||||
const label = t(
|
const label = t(
|
||||||
RULE_TYPE_LABEL_KEYS[option.name] ?? option.name,
|
RULE_TYPE_LABEL_KEYS[option.name] ?? option.name,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<li key={key} {...optionProps} title={label}>
|
<li {...props} title={label}>
|
||||||
{label}
|
{label}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -625,10 +624,9 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
t(PROXY_POLICY_LABEL_KEYS[option] ?? option)
|
t(PROXY_POLICY_LABEL_KEYS[option] ?? option)
|
||||||
}
|
}
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => {
|
||||||
const { key, ...optionProps } = props;
|
|
||||||
const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option);
|
const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option);
|
||||||
return (
|
return (
|
||||||
<li key={key} {...optionProps} title={label}>
|
<li {...props} title={label}>
|
||||||
{label}
|
{label}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user