Compare commits

..
Author SHA1 Message Date
Tunglies 2fd6be642e refactor(services): drop the bespoke retry engine from the query wrapper
The custom onErrorRetry existed only to honor a function-form
retryDelay, which exactly one caller (the app data provider) used for
exponential backoff. Fall back to SWR's built-in error retry and
express that caller's cadence with constant options.
2026-10-28 10:52:18 +08:00
Tunglies 433230aedc refactor(hooks): trim dead surface from proxy selection
Drop the connection-cleanup option nobody ever disabled (behavior now
unconditional), the returned changeProxy no consumer called and the two
one-caller argument adapters; both call sites use the shared select
entry directly.
2026-10-28 10:43:01 +08:00
Tunglies 05475c7bb5 refactor(hooks): drop the write-only error channel and key-rotating refresh
The mihomo ws subscription's next callback accepted an error half that
baseNext silently discarded while traffic, memory and log consumers
kept constructing fallback payloads for it. Refresh minted a fresh
SWR key per call via a localStorage timestamp, abandoning old keys in
the cache forever. Reduce next to its data half and reset the snapshot
under a stable key instead.
2026-10-28 10:31:43 +08:00
Tunglies eb60c21c62 refactor(shared): replace the traffic error boundary with ErrorBoundary
TrafficErrorBoundary maintained a second boundary system beside
BaseErrorBoundary: a report builder that only console.logged constants
in the Tauri webview, an onError that re-logged what componentDidCatch
already logged, and a fallback wrapper with a single consumer. Fold
both mount points into react-error-boundary (the primitive behind
BaseErrorBoundary) with local fallbacks that keep the panel styling,
message text and retry reset, log each error once, and drop the retry
attempt cap and the stack-details collapse along with it.
2026-10-28 10:17:37 +08:00
Tunglies 4489c9a26f refactor(traffic): drop unreachable inline monitor fallback
The inline traffic monitor duplicated the worker's message loop,
snapshot throttling and aging logic but only runs when Worker is
undefined or its constructor throws, which no supported Tauri webview
or browser hits. Keep the worker as the single implementation and
remove the mode switch and the pending-message queue that could only
ever buffer the init message.
2026-10-28 09:59:44 +08:00
Tunglies 98a7932742 fix(services): make retries viable and align polling freshness window
Share no abort signal across retry attempts: after a timeout abort the
old signal stays dead and every later fetch rejects immediately, so
retries only burned backoff time. Also drop the system state query's
staleTime: the wrapper maps it to SWR's dedupingInterval, which
swallowed every other 3s poll tick while it exceeded the interval.
2026-10-28 09:42:04 +08:00
Tunglies c678d60964 fix(delay): write pending state once per node in batch checks
checkListDelay pre-marked every node as checking, help() re-marked it
when picked up and measureDelay marked it a third time on entry, so
each batch run queued three identical pending updates per node. Keep
the pre-mark (immediate feedback for queued nodes) and drop the
redundant marks along the chain.
2026-10-28 09:27:29 +08:00
Tunglies d5bc4ff58b fix(setting): mount only reachable proxy control viewers
ProxyControlSwitches unconditionally mounted both SysproxyViewer and
TunViewer while its switch rows are mutually exclusive, so every
instance carried a dialog that could never open, each firing
getEmbeddedServerPort or a clash query subscription on mount. Mount
each viewer under the same condition as the switch row that opens it.

SettingSystem also mounted its own SysproxyViewer and TunViewer whose
refs were never opened there; the reachable entries are the
ProxyControlSwitches rows, so the dead pair is removed. Viewer mounts
across the app drop from eight (setting-system: two own + two per
instance, proxy-tun-card: two) to three (one per rendered switch
variant).
2026-10-28 09:19:23 +08:00
Tunglies b81981162b fix(setting): build viewer form defaults from a single source
sysproxy, misc and tunnels viewers duplicated their form default
literals in useState and open(), and the two copies in misc-viewer had
already diverged (app log max size, app log max count, auto log clean).
Extract a buildInitialValues factory per viewer and unify on the open()
values, which are the only ones users could ever see.
2026-10-28 09:13:14 +08:00
Tunglies 6fa6d7664f fix(setting): write auto launch patch once per toggle
GuardState applies the optimistic onChange write before onGuard runs,
so repeating the same onChangeData patch inside the guard handler
duplicated every cache write and subscriber notification for the auto
launch switch.
2026-10-28 06:41:18 +08:00
Tunglies 4967ad347e fix(rules): refresh once per visibility trigger
The mount effect fired refreshRules and refreshRuleProviders
unconditionally and again under the page-visible flag, double-firing
both queries on mount and on every hide-to-show transition. Gate the
refresh on visibility once so each trigger performs exactly one
revalidation.
2026-10-28 06:36:33 +08:00
Tunglies 6394f347f5 refactor(types): remove dead provider view types superseded by bindings
Drop the handwritten IProxyProviderItem and IRuleProviderItem
interfaces: IProxyProviderItem is a stale duplicate of the generated
ProxyProviderView contract, and neither has any references left in
the tree.

Keep IProxyItem and IProxyGroupItem: IConfigData still declares
'proxy-groups'?: IProxyGroupItem[], which builds on IProxyItem.
2026-10-28 06:33:08 +08:00
Tunglies e56711b2db refactor(frontend): drop unused params, props and api surface
Remove the never-read _revalidate argument of mutateVerge and its 16
call-site placeholders, seven never-passed props of the split chip
editor, the unconsumed refreshDecorated context member, the now-orphaned
toggleFullscreen control, the unused translate argument of the notice
message handler, the reserved-but-unset ServiceConfig.timeout field and
unreachable branches in parseNoticeExtras.
2026-10-28 06:30:46 +08:00
Tunglies 3e28b287d1 refactor(frontend): remove dead states, refs and unreachable branches
Drop state that can only hold its initial value (profiles page disabled
flag, traffic graph currentFPS), a write-only window controls ref with
its unconsumed imperative handle, a no-op appendData ref method, an
unreachable gradient branch, an invisible fadeIn shell, constant-true
subexpressions in the system info card, an empty click handler and a
pointless useMemo.
2026-10-28 06:08:44 +08:00
Tunglies 51a4aa80dd docs(agents): add frontend liveness constraints
Add six liveness rules to the frontend guardrails covering dead state,
unused exposed surface, unreachable fallback paths, duplicate side
effects per interaction, unreachable mounts and unbounded resource
lifecycles. These target defect classes that the existing CI gates
(knip, eslint, arch:check) cannot detect mechanically.
2026-10-28 06:03:11 +08:00
77 changed files with 901 additions and 2314 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "26.13.0",
"pnpmVersion": "12.16.1"
"version": "24.22.1",
"pnpmVersion": "12.15.0"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1",
+5 -5
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -185,7 +185,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
@@ -282,7 +282,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
@@ -426,7 +426,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
@@ -524,7 +524,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6.1.0
name: Install pnpm
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Restore pnpm cache
-1
View File
@@ -8,7 +8,6 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".cargo/config.toml"
- ".github/workflows/lint-clippy.yml"
workflow_dispatch:
env:
-79
View File
@@ -1,79 +0,0 @@
name: Prepare Release
on:
pull_request_target:
types: [edited, closed]
branches: [dev]
workflow_dispatch:
inputs:
channel:
description: Release channel
required: true
type: choice
options: [rc, stable]
default: rc
release_tag:
description: 'Optional tag: vX.Y.Z-rc[.N] for RC, vX.Y.Z for stable'
required: false
type: string
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
jobs:
prepare:
if: >-
${{ github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
((startsWith(github.event.pull_request.head.ref, 'release/v') &&
((github.event.action == 'closed' && github.event.pull_request.merged == true) ||
(github.event.changes.body && github.event.pull_request.state == 'open' &&
contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev')))) ||
(startsWith(github.event.pull_request.head.ref, 'next-dev/v') &&
github.event.changes.body && github.event.pull_request.state == 'open' &&
(contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev') ||
contains(github.event.pull_request.body, '[x] Use the next minor version') !=
contains(github.event.changes.body.from, '[x] Use the next minor version'))))) }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Require the dev branch
if: github.event_name == 'workflow_dispatch'
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != 'refs/heads/dev' ]]; then
echo '::error::Run Prepare Release from the dev branch.'
exit 1
fi
- uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
if: github.event.action != 'closed'
with:
run_install: false
- run: pnpm install --frozen-lockfile --ignore-scripts
if: github.event.action != 'closed'
- name: Create or refresh version or publication PR
env:
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_CHANNEL: ${{ inputs.channel }}
RELEASE_COMMAND: ${{ github.event.action == 'closed' && 'publish-pr' || startsWith(github.event.pull_request.head.ref, 'next-dev/') && 'next-dev' || 'prepare' }}
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
+76 -123
View File
@@ -1,9 +1,6 @@
name: Release Build
on:
pull_request:
types: [edited]
branches: [main]
push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags:
@@ -14,59 +11,75 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
group: release-publication
cancel-in-progress: false
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
prepare_release:
name: Approve Snapshot and Prepare Draft Release
if: >-
${{ github.event_name == 'push' ||
(github.event.changes.body &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
startsWith(github.event.pull_request.head.ref, 'publish/v') &&
contains(github.event.pull_request.body, '[x] Publish this approved release')) }}
check_tag_version:
name: Check Release Tag and package.json Version Consistency
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
outputs:
release_tag: ${{ steps.source.outputs.release_tag }}
source_sha: ${{ steps.source.outputs.source_sha }}
channel: ${{ steps.source.outputs.channel }}
release_id: ${{ steps.draft.outputs.id }}
steps:
- name: Checkout trusted release tooling
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- name: Validate source and publish the approved snapshot
id: source
env:
RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }}
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
- name: Create or reuse draft release
id: draft
env:
RELEASE_TAG: ${{ steps.source.outputs.release_tag }}
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: node scripts-workflow/release.mjs prepare-draft
- name: Check if tag is from main branch
run: |
TAG_REF="${GITHUB_REF##*/}"
echo "Checking if tag $TAG_REF is from main branch..."
TAG_COMMIT=$(git rev-list -n 1 $TAG_REF)
MAIN_COMMITS=$(git rev-list origin/main)
if echo "$MAIN_COMMITS" | grep -q "$TAG_COMMIT"; then
echo "✅ Tag $TAG_REF is from main branch"
else
echo "❌ Tag $TAG_REF is not from main branch"
echo "This release workflow only accepts tags from main branch."
exit 1
fi
- name: Check tag and package.json version
run: |
TAG_REF="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
echo "Current tag: $TAG_REF"
PKG_VERSION=$(jq -r .version package.json)
echo "package.json version: $PKG_VERSION"
EXPECTED_TAG="v$PKG_VERSION"
if [[ "$TAG_REF" != "$EXPECTED_TAG" ]]; then
echo "❌ Version mismatch:"
echo " Git tag : $TAG_REF"
echo " package.json : $EXPECTED_TAG"
exit 1
fi
echo "✅ Tag and package.json version are consistent."
prepare_release:
name: Prepare Draft Release
runs-on: ubuntu-latest
needs: [check_tag_version]
outputs:
release_id: ${{ steps.prepare_release.outputs.id }}
steps:
- name: Create or update draft release
id: prepare_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
name: 'Clash Verge Rev ${{ github.ref_name }}'
body: 'Draft release, will be updated later.'
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
update_tag:
name: Publish Completed Release
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
outputs:
source_sha: ${{ needs.prepare_release.outputs.source_sha }}
channel: ${{ needs.prepare_release.outputs.channel }}
name: Update tag
runs-on: ubuntu-latest
needs:
[
@@ -78,8 +91,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh
@@ -92,7 +103,7 @@ jobs:
- name: Set Env
run: |
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
TAG_REF="$RELEASE_TAG"
TAG_REF="${GITHUB_REF##*/}"
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
VERSION=$(echo "$TAG_REF" | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -138,16 +149,10 @@ jobs:
Created at ${{ env.BUILDTIME }}.
EOF
- name: Verify release tag still points to the approved source
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
run: node scripts-workflow/release.mjs verify-target
- name: Publish Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }}
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
run: |
release_id="${{ needs.prepare_release.outputs.release_id }}"
@@ -176,8 +181,6 @@ jobs:
release:
name: Release Build
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -197,8 +200,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -229,7 +230,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -248,6 +249,7 @@ jobs:
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v1.0.0
env:
NODE_OPTIONS: '--max_old_space_size=4096'
@@ -262,14 +264,11 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
releaseId: ${{ needs.prepare_release.outputs.release_id }}
tagName: ${{ env.RELEASE_TAG }}
tagName: ${{ github.ref_name }}
releaseBody: 'Draft release, will be updated later.'
releaseDraft: true
prerelease: ${{ needs.prepare_release.outputs.channel == 'rc' }}
releaseCommitish: ${{ needs.prepare_release.outputs.source_sha }}
tauriScript: pnpm
args: --target ${{ matrix.target }}
uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }}
includeUpdaterJson: true
- name: Attest Windows bundles
if: matrix.os == 'windows-latest'
@@ -294,8 +293,6 @@ jobs:
release-for-linux-arm:
name: Release Build for Linux ARM
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -310,8 +307,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -336,7 +331,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- name: Install pnpm
uses: pnpm/action-setup@v6
@@ -425,7 +420,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" \
gh release upload "${{ github.ref_name }}" \
target/${{ matrix.target }}/release/bundle/deb/*.deb \
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
--clobber
@@ -433,8 +428,6 @@ jobs:
release-for-fixed-webview2:
name: Release Build for Fixed WebView2
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -449,8 +442,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -475,7 +466,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -541,55 +532,22 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
gh release upload "$env:RELEASE_TAG" $files --clobber
gh release upload "${{ github.ref_name }}" $files --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
prepare-next-dev:
name: Prepare Next Dev Version
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
needs: [prepare_release, update_tag]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
steps:
- uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
with:
run_install: false
- name: Prepare version and changelog PR
run: |
pnpm install --frozen-lockfile --ignore-scripts
node scripts-workflow/release.mjs next-dev
release-update:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
name: Release Update
runs-on: ubuntu-latest
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -605,19 +563,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-update-for-fixed-webview2:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
runs-on: ubuntu-latest
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -633,7 +589,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
name: Submit to Winget
runs-on: ubuntu-latest
needs: [update_tag, release-update]
@@ -641,7 +597,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
fetch-depth: 0
- name: Get Version
run: |
@@ -658,7 +613,7 @@ jobs:
token: ${{ secrets.WINGET_TOKEN }}
notify-telegram:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
name: Notify Telegram
runs-on: ubuntu-latest
needs:
@@ -671,8 +626,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh
@@ -685,7 +638,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
-2
View File
@@ -9,8 +9,6 @@ on:
paths:
- "src-tauri/**"
- "**/*.rs"
- "rustfmt.toml"
- "rust-toolchain.toml"
- ".github/workflows/rustfmt.yml"
permissions:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 26.13.0
pnpm 12.16.1
nodejs 24.22.1
pnpm 12.15.0
rust 1.99.1
+6 -19
View File
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
### Prerequisites
1. **Install Rust and Node.js**
Follow the [Tauri prerequisites](https://tauri.app/start/prerequisites/) for your operating system. Use the Node.js version in [`.tool-versions`](.tool-versions); [`rust-toolchain.toml`](rust-toolchain.toml) selects the Rust version and required components when you run Cargo from this repository.
Our project requires both Rust and Node.js. Follow the official installation instructions [here](https://tauri.app/start/prerequisites/).
### Windows Users
@@ -51,21 +51,18 @@ rustup set default-host x86_64-pc-windows-msvc
### Install Node.js Package Manager
[Corepack is no longer bundled with Node.js starting with Node.js 25](https://nodejs.org/api/corepack.html). Install it globally if it is not already available, then enable it:
Enable `corepack`:
```bash
npm install --global corepack
corepack enable
```
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
### Install Project Dependencies
Node.js dependencies:
```bash
pnpm install --frozen-lockfile
pnpm install
```
Ubuntu-only system packages:
@@ -81,24 +78,14 @@ pnpm run prebuild
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries
```
### Prepare the Development Service Source
`pnpm dev` builds the development service from a sibling checkout of [`clash-verge-service-ipc`](https://github.com/clash-verge-rev/clash-verge-service-ipc). The binaries downloaded by `pnpm prebuild` do not replace this source checkout.
Check the `clash_verge_service_ipc` dependency in [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml), then clone the service repository alongside `clash-verge-rev` and check out the matching tag. If the sibling directory already exists, reuse it and check its version rather than overwriting it. Without this checkout, `pnpm dev` fails when it tries to access `../clash-verge-service-ipc/Cargo.toml`.
### Run the Development Server
Run these commands from the root of `clash-verge-rev`:
```bash
pnpm dev # Build the development service and start the desktop app
pnpm dev:diff # Alias of pnpm dev
pnpm dev:tauri # Run Tauri directly with the tauri-dev feature
pnpm dev # Standard
pnpm dev:diff # If an app instance already exists
pnpm dev:tauri # Run Tauri development mode
```
For frontend-only development, use `pnpm web:dev`. It does not start the Rust backend or build the development service.
### Build the Project
Standard build:
Generated
+70 -39
View File
@@ -33,7 +33,6 @@ dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.0",
"zeroize",
]
[[package]]
@@ -136,6 +135,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -1081,7 +1089,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.5.9-rc"
version = "2.5.9"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1167,7 +1175,7 @@ dependencies = [
"windows-service",
"windows-sys 0.61.2",
"winreg 0.56.0",
"zip",
"zip 9.0.4",
]
[[package]]
@@ -1334,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1862,6 +1870,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -2015,7 +2034,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2274,7 +2293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2790,7 +2809,7 @@ dependencies = [
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.8",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3344,7 +3363,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -4032,9 +4051,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.23.0"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
dependencies = [
"sha2 0.11.1",
]
@@ -4215,7 +4234,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4382,7 +4401,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4859,7 +4878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5524,7 +5543,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6028,7 +6047,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6041,7 +6060,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6100,7 +6119,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6720,7 +6739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7069,9 +7088,9 @@ checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
[[package]]
name = "tao"
version = "0.37.2"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [
"bitflags 2.13.2",
"block2",
@@ -7556,11 +7575,11 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.14.1"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"dirs 7.0.0",
"flate2",
"futures-util",
@@ -7585,7 +7604,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -7786,10 +7805,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8455,7 +8474,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8534,9 +8553,9 @@ dependencies = [
[[package]]
name = "typed-path"
version = "0.13.0"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
@@ -8564,7 +8583,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9106,7 +9125,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9127,7 +9146,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"windows-version",
]
@@ -9702,7 +9721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9743,11 +9762,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wry"
version = "0.57.1"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"block2",
"cookie",
"crossbeam-channel",
@@ -9768,6 +9787,7 @@ dependencies = [
"objc2-foundation",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2 0.10.9",
@@ -10034,9 +10054,21 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.3"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zip"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"aes",
"bzip2",
@@ -10055,7 +10087,6 @@ dependencies = [
"sha1 0.11.0",
"time",
"typed-path",
"windows-sys 0.61.2",
"zeroize",
"zopfli",
"zstd",
-8
View File
@@ -1,23 +1,15 @@
## v2.5.9
- **Mihomo(Meta) 内核升级至 v1.19.33**
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
- 修复 重启应用后定时自动备份不执行的问题
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
- 修复 WebDAV 设置清空后无法保存
**🍎 macOS**
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
**🐧 Linux**
-8
View File
@@ -129,7 +129,6 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'scripts-workflow/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
@@ -239,13 +238,6 @@ export default defineConfig([
},
},
},
{
files: ['scripts-workflow/*.mjs'],
languageOptions: {
globals: globals.node,
},
rules: { 'es-x/no-object-groupby': 'off' },
},
{
files: ['scripts/perf/*.{mjs,mts}'],
languageOptions: {
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.9-rc",
"version": "2.5.9",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -60,7 +60,7 @@
"@tauri-apps/plugin-fs": "2.7.1",
"@tauri-apps/plugin-http": "2.8.3",
"@tauri-apps/plugin-opener": "2.7.2",
"@tauri-apps/plugin-updater": "2.14.1",
"@tauri-apps/plugin-updater": "2.14.0",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
@@ -79,11 +79,12 @@
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.1.9",
"react-i18next": "17.1.8",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
@@ -96,7 +97,7 @@
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.0.0",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
@@ -117,12 +118,12 @@
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^14.0.0",
"glob": "^13.0.6",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.44.1",
"knip": "6.43.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
@@ -134,7 +135,7 @@
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.1.0",
"ws": "8.22.1"
"ws": "8.22.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
@@ -146,5 +147,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.16.1"
"packageManager": "pnpm@12.15.0"
}
+319 -151
View File
@@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.16.1
version: 12.16.1
specifier: 12.15.0
version: 12.15.0
packages:
'@pnpm/exe.android-arm64@12.16.1':
resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==}
'@pnpm/exe.android-arm64@12.15.0':
resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.16.1':
resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==}
'@pnpm/exe.android-x64@12.15.0':
resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.16.1':
resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==}
'@pnpm/exe.darwin-arm64@12.15.0':
resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.16.1':
resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==}
'@pnpm/exe.darwin-x64@12.15.0':
resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.16.1':
resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==}
'@pnpm/exe.freebsd-x64@12.15.0':
resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.16.1':
resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==}
'@pnpm/exe.linux-arm64-musl@12.15.0':
resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.16.1':
resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==}
'@pnpm/exe.linux-arm64@12.15.0':
resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.16.1':
resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==}
'@pnpm/exe.linux-ppc64@12.15.0':
resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.16.1':
resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==}
'@pnpm/exe.linux-riscv64@12.15.0':
resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.16.1':
resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==}
'@pnpm/exe.linux-s390x@12.15.0':
resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.16.1':
resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==}
'@pnpm/exe.linux-x64-musl@12.15.0':
resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.16.1':
resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==}
'@pnpm/exe.linux-x64@12.15.0':
resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.16.1':
resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==}
'@pnpm/exe.win32-arm64@12.15.0':
resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.16.1':
resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==}
'@pnpm/exe.win32-x64@12.15.0':
resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==}
cpu: [x64]
os: [win32]
pnpm@12.16.1:
resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==}
pnpm@12.15.0:
resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.16.1':
'@pnpm/exe.android-arm64@12.15.0':
optional: true
'@pnpm/exe.android-x64@12.16.1':
'@pnpm/exe.android-x64@12.15.0':
optional: true
'@pnpm/exe.darwin-arm64@12.16.1':
'@pnpm/exe.darwin-arm64@12.15.0':
optional: true
'@pnpm/exe.darwin-x64@12.16.1':
'@pnpm/exe.darwin-x64@12.15.0':
optional: true
'@pnpm/exe.freebsd-x64@12.16.1':
'@pnpm/exe.freebsd-x64@12.15.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.16.1':
'@pnpm/exe.linux-arm64-musl@12.15.0':
optional: true
'@pnpm/exe.linux-arm64@12.16.1':
'@pnpm/exe.linux-arm64@12.15.0':
optional: true
'@pnpm/exe.linux-ppc64@12.16.1':
'@pnpm/exe.linux-ppc64@12.15.0':
optional: true
'@pnpm/exe.linux-riscv64@12.16.1':
'@pnpm/exe.linux-riscv64@12.15.0':
optional: true
'@pnpm/exe.linux-s390x@12.16.1':
'@pnpm/exe.linux-s390x@12.15.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.16.1':
'@pnpm/exe.linux-x64-musl@12.15.0':
optional: true
'@pnpm/exe.linux-x64@12.16.1':
'@pnpm/exe.linux-x64@12.15.0':
optional: true
'@pnpm/exe.win32-arm64@12.16.1':
'@pnpm/exe.win32-arm64@12.15.0':
optional: true
'@pnpm/exe.win32-x64@12.16.1':
'@pnpm/exe.win32-x64@12.15.0':
optional: true
pnpm@12.16.1:
pnpm@12.15.0:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.16.1
'@pnpm/exe.android-x64': 12.16.1
'@pnpm/exe.darwin-arm64': 12.16.1
'@pnpm/exe.darwin-x64': 12.16.1
'@pnpm/exe.freebsd-x64': 12.16.1
'@pnpm/exe.linux-arm64': 12.16.1
'@pnpm/exe.linux-arm64-musl': 12.16.1
'@pnpm/exe.linux-ppc64': 12.16.1
'@pnpm/exe.linux-riscv64': 12.16.1
'@pnpm/exe.linux-s390x': 12.16.1
'@pnpm/exe.linux-x64': 12.16.1
'@pnpm/exe.linux-x64-musl': 12.16.1
'@pnpm/exe.win32-arm64': 12.16.1
'@pnpm/exe.win32-x64': 12.16.1
'@pnpm/exe.android-arm64': 12.15.0
'@pnpm/exe.android-x64': 12.15.0
'@pnpm/exe.darwin-arm64': 12.15.0
'@pnpm/exe.darwin-x64': 12.15.0
'@pnpm/exe.freebsd-x64': 12.15.0
'@pnpm/exe.linux-arm64': 12.15.0
'@pnpm/exe.linux-arm64-musl': 12.15.0
'@pnpm/exe.linux-ppc64': 12.15.0
'@pnpm/exe.linux-riscv64': 12.15.0
'@pnpm/exe.linux-s390x': 12.15.0
'@pnpm/exe.linux-x64': 12.15.0
'@pnpm/exe.linux-x64-musl': 12.15.0
'@pnpm/exe.win32-arm64': 12.15.0
'@pnpm/exe.win32-x64': 12.15.0
---
lockfileVersion: '9.0'
@@ -218,8 +218,8 @@ importers:
specifier: 2.7.2
version: 2.7.2
'@tauri-apps/plugin-updater':
specifier: 2.14.1
version: 2.14.1
specifier: 2.14.0
version: 2.14.0
ahooks:
specifier: ^3.10.0
version: 3.10.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
@@ -275,8 +275,8 @@ importers:
specifier: ^7.88.0
version: 7.88.0(react@19.3.1)
react-i18next:
specifier: 17.1.9
version: 17.1.9(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
specifier: 17.1.8
version: 17.1.8(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
react-markdown:
specifier: 10.1.0
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
@@ -289,6 +289,9 @@ importers:
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1(supports-color@7.2.0)
swr:
specifier: ^2.5.1
version: 2.5.1(react@19.3.1)
@@ -321,8 +324,8 @@ importers:
specifier: ^4.17.12
version: 4.17.12
'@types/node':
specifier: ^26.0.0
version: 26.6.5
specifier: ^24.13.6
version: 24.13.6
'@types/react':
specifier: 19.3.0
version: 19.3.0
@@ -334,10 +337,10 @@ importers:
version: 13.15.10
'@vitejs/plugin-legacy':
specifier: ^8.2.3
version: 8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 6.1.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@wdio/tauri-service':
specifier: 1.5.0
version: 1.5.0(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))
@@ -384,8 +387,8 @@ importers:
specifier: ^4.4.1
version: 4.4.1(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))
glob:
specifier: ^14.0.0
version: 14.0.3
specifier: ^13.0.6
version: 13.0.6
globals:
specifier: ^17.12.0
version: 17.12.0
@@ -399,8 +402,8 @@ importers:
specifier: ^2.7.0
version: 2.7.0
knip:
specifier: 6.44.1
version: 6.44.1
specifier: 6.43.0
version: 6.43.0
lint-staged:
specifier: ^17.5.1
version: 17.5.1
@@ -424,19 +427,19 @@ importers:
version: 8.70.0(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
vite:
specifier: ^8.3.0
version: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
version: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite-plugin-svgr:
specifier: ^5.2.0
version: 5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
vitest:
specifier: ^5.0.1
version: 5.0.1(@types/node@26.6.5)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
version: 5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
webdriverio:
specifier: 10.1.0
version: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
ws:
specifier: 8.22.1
version: 8.22.1
specifier: 8.22.0
version: 8.22.0
packages:
@@ -1384,9 +1387,6 @@ packages:
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@isaacs/nonrecursive-glob-match@1.0.2':
resolution: {integrity: sha512-GJPHIgPPtFl+nLhFHyF0zZoFcOFLsZWbHFFzo+Zr+PWDXJbl9hHxb2w+9SiF2fHmLgKaXIpUzQo3SbBBZ9WObw==}
'@jest/diff-sequences@30.5.0':
resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -2218,8 +2218,8 @@ packages:
'@tauri-apps/plugin-opener@2.7.2':
resolution: {integrity: sha512-LyybP1VSzTMQiaFYcsbAse47KpivlyXdLZMQzGLEnkgQacGESnpjAfFoQ6h5sqWgAdZDH9szYjPzHMpv0Sz+TQ==}
'@tauri-apps/plugin-updater@2.14.1':
resolution: {integrity: sha512-Hr7nlBm1wT6xbwzjZ/S36YULazu8MNlj5vvOwMl47fXI7HTa5Bjl1PbDEwVHaZTAKzE1VZsUy35UqnbrQxmYQw==}
'@tauri-apps/plugin-updater@2.14.0':
resolution: {integrity: sha512-gdylJ9tger4hkyOn24QZd6MNOKVo2Eh53iw5th2Qj1vKTR/+u4PIwJWuiis5hohYvobn6iOvlnALfrqIPSzh7Q==}
'@tsrs/darwin-arm64@0.20.0-ts7.1.0-beta':
resolution: {integrity: sha512-mhHj8QGUmdJDBvJEnMXx5P/jvAKXYU3hy/z3qxrWBo8XHlbLES32PgC0ZpTRbJA960ReTpVqqvCOYaC6EL32/Q==}
@@ -2321,8 +2321,8 @@ packages:
'@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
'@types/node@26.6.5':
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
'@types/node@24.13.6':
resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -3238,6 +3238,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-import-context@0.1.9:
resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -3590,10 +3594,6 @@ packages:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
glob@14.0.3:
resolution: {integrity: sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==}
engines: {node: 18 || 20 || >=22}
globals@17.12.0:
resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==}
engines: {node: '>=18'}
@@ -3861,8 +3861,8 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
knip@6.44.1:
resolution: {integrity: sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==}
knip@6.43.0:
resolution: {integrity: sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -4011,6 +4011,9 @@ packages:
magic-string@1.4.1:
resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==}
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
@@ -4020,9 +4023,30 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
mdast-util-from-markdown@2.0.3:
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.0:
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -4059,6 +4083,27 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.2:
resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -4131,10 +4176,6 @@ packages:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimatch@11.0.2:
resolution: {integrity: sha512-pWTgz5CN3tjqyZNSK2tePlSmu9IwM41q8yn8Usdqqwe9TIHh+9dEpLLjF4uApsfPEAZY71qtSiA2B2APEr+1Yw==}
engines: {node: 18 || 20 || >=22}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4296,10 +4337,6 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-scurry@3.0.0:
resolution: {integrity: sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==}
engines: {node: 18 || 20 || >=22}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -4406,8 +4443,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.1.9:
resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==}
react-i18next@17.1.8:
resolution: {integrity: sha512-vGH+Tbhqbv5LdN9KvC9GEmQ6ATxJAjyxPEK0zSCSRlkeDqCPNxVtEOvDNRAz+51HTxc6QPwhnHaFD7ICu6pD1A==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
@@ -4499,12 +4536,18 @@ packages:
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -4822,8 +4865,8 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@6.28.1:
resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==}
@@ -5074,8 +5117,8 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.22.1:
resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==}
ws@8.22.0:
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -6260,8 +6303,6 @@ snapshots:
dependencies:
minipass: 7.1.3
'@isaacs/nonrecursive-glob-match@1.0.2': {}
'@jest/diff-sequences@30.5.0': {}
'@jest/expect-utils@30.5.2':
@@ -6272,7 +6313,7 @@ snapshots:
'@jest/pattern@30.5.0':
dependencies:
'@types/node': 26.6.5
'@types/node': 24.13.6
jest-regex-util: 30.5.0
'@jest/schemas@30.5.0':
@@ -6285,7 +6326,7 @@ snapshots:
'@jest/schemas': 30.5.0
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 26.6.5
'@types/node': 24.13.6
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -6891,7 +6932,7 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.12.4
'@tauri-apps/plugin-updater@2.14.1':
'@tauri-apps/plugin-updater@2.14.0':
dependencies:
'@tauri-apps/api': 2.12.4
@@ -6976,9 +7017,9 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@26.6.5':
'@types/node@24.13.6':
dependencies:
undici-types: 8.9.0
undici-types: 7.18.2
'@types/parse-json@4.0.2': {}
@@ -7000,7 +7041,7 @@ snapshots:
'@types/split2@4.2.3':
dependencies:
'@types/node': 26.6.5
'@types/node': 24.13.6
'@types/stack-utils@2.0.3': {}
@@ -7015,7 +7056,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 26.6.5
'@types/node': 24.13.6
'@types/yargs-parser@21.0.3': {}
@@ -7186,7 +7227,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitejs/plugin-legacy@8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitejs/plugin-legacy@8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
@@ -7201,23 +7242,23 @@ snapshots:
regenerator-runtime: 0.14.1
systemjs: 6.15.1
terser: 5.51.2
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
'@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitest/mocker@5.0.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.4.1
optionalDependencies:
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -7948,6 +7989,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-import-context@0.1.9(unrs-resolver@1.12.2):
dependencies:
get-tsconfig: 4.14.3
@@ -8393,12 +8436,6 @@ snapshots:
minipass: 7.1.3
path-scurry: 2.0.2
glob@14.0.3:
dependencies:
minimatch: 11.0.2
minipass: 7.1.3
path-scurry: 3.0.0
globals@17.12.0: {}
gopd@1.2.0: {}
@@ -8648,7 +8685,7 @@ snapshots:
dependencies:
'@jest/expect-utils': 30.5.2
'@jest/types': 30.5.1
'@types/node': 26.6.5
'@types/node': 24.13.6
jest-util: 30.5.1
jest-regex-util@30.5.0: {}
@@ -8656,7 +8693,7 @@ snapshots:
jest-util@30.5.1:
dependencies:
'@jest/types': 30.5.1
'@types/node': 26.6.5
'@types/node': 24.13.6
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
@@ -8701,7 +8738,7 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
knip@6.44.1:
knip@6.43.0:
dependencies:
formatly: 0.11.4
get-tsconfig: 4.14.3
@@ -8838,10 +8875,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
markdown-table@3.0.4: {}
marked@14.0.0: {}
math-intrinsics@1.1.0: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.3(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
@@ -8859,6 +8905,63 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0(supports-color@7.2.0):
dependencies:
mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0)
mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0)
mdast-util-gfm-table: 2.0.0(supports-color@7.2.0)
mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1(supports-color@7.2.0):
dependencies:
'@types/estree-jsx': 1.0.5
@@ -8965,6 +9068,64 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-table@2.1.2:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.2
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.2
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -9089,11 +9250,6 @@ snapshots:
dependencies:
brace-expansion: 5.0.12
minimatch@11.0.2:
dependencies:
'@isaacs/nonrecursive-glob-match': 1.0.2
brace-expansion: 5.0.12
minipass@7.1.3: {}
minizlib@3.1.0:
@@ -9294,11 +9450,6 @@ snapshots:
lru-cache: 11.5.3
minipass: 7.1.3
path-scurry@3.0.0:
dependencies:
lru-cache: 11.5.3
minipass: 7.1.3
path-type@4.0.0: {}
pathe@2.0.3: {}
@@ -9378,7 +9529,7 @@ snapshots:
dependencies:
react: 19.3.1
react-i18next@17.1.9(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
react-i18next@17.1.8(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 4.0.1
@@ -9490,6 +9641,17 @@ snapshots:
'@types/hast': 3.0.5
hast-util-sanitize: 5.0.2
remark-gfm@4.0.1(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0(supports-color@7.2.0)
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0(supports-color@7.2.0)
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
@@ -9507,6 +9669,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -9815,7 +9983,7 @@ snapshots:
undici-types@6.21.0: {}
undici-types@8.9.0: {}
undici-types@7.18.2: {}
undici@6.28.1: {}
@@ -9933,18 +10101,18 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-svgr@5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
vite-plugin-svgr@5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
'@rollup/pluginutils': 5.4.0
'@svgr/core': 8.1.0(supports-color@7.2.0)(typescript@6.0.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1):
vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -9952,7 +10120,7 @@ snapshots:
rolldown: 1.2.9
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.5
'@types/node': 24.13.6
esbuild: 0.28.2
fsevents: 2.3.3
jiti: 2.7.0
@@ -9960,10 +10128,10 @@ snapshots:
terser: 5.51.2
yaml: 2.9.1
vitest@5.0.1(@types/node@26.6.5)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -9974,10 +10142,10 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
'@types/node': 24.13.6
transitivePeerDependencies:
- msw
@@ -10023,7 +10191,7 @@ snapshots:
deepmerge-ts: 8.0.2
https-proxy-agent: 9.1.0(supports-color@7.2.0)
undici: 8.11.2
ws: 8.22.1
ws: 8.22.0
transitivePeerDependencies:
- bufferutil
- kerberos
@@ -10091,7 +10259,7 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
ws@8.22.1: {}
ws@8.22.0: {}
y18n@5.0.8: {}
-761
View File
@@ -1,761 +0,0 @@
import { execFileSync } from 'node:child_process'
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
const env = process.env
const repository = env.GITHUB_REPOSITORY
const refreshLabel = 'Refresh this PR from latest dev'
const publishLabel = 'Publish this approved release'
const minorLabel = 'Use the next minor version'
const checked = (body, label = refreshLabel) =>
(body || '')
.split('\n')
.some((line) => line.toLowerCase() === `- [x] ${label}`.toLowerCase())
const versionPattern = '(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)'
const rcPattern = new RegExp(`^v${versionPattern}-rc(?:\\.(0|[1-9][0-9]*))?$`)
const stablePattern = new RegExp(`^v${versionPattern}$`)
const versionFiles = [
'package.json',
'src-tauri/tauri.conf.json',
'src-tauri/Cargo.toml',
'Cargo.lock',
]
const read = (path) => readFileSync(path, 'utf8')
const json = (path) => JSON.parse(read(path))
const run = (command, args, options = {}) =>
execFileSync(command, args, { encoding: 'utf8', ...options }).trim()
const git = (...args) => run('git', args)
const gitFile = (sha, path) =>
execFileSync('git', ['show', `${sha}:${path}`], { encoding: 'utf8' })
const isRc = (tag) => tag.match(rcPattern)?.[0] === tag
const isRelease = (tag) => isRc(tag) || stablePattern.test(tag)
const titleFor = (tag) => `${isRc(tag) ? 'Pre-Release' : 'Release'}: ${tag}`
const branchTag = (branch, prefix = 'release/') =>
branch.startsWith(prefix) && isRelease(branch.slice(prefix.length))
? branch.slice(prefix.length)
: undefined
const latest = (tags) =>
tags.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1)
const summary = (text) => appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`)
const endpoint = (path) =>
path === 'graphql' ? path : `repos/${repository}/${path}`
const list = (path) =>
JSON.parse(run('gh', ['api', '--paginate', '--slurp', endpoint(path)])).flat()
function api(path, body, method = 'POST') {
const response = run(
'gh',
['api', '--method', method, endpoint(path), '--input', '-'],
{
input: JSON.stringify(body),
},
)
const result = response ? JSON.parse(response) : null
if (result?.errors) throw new Error(JSON.stringify(result.errors))
return result
}
const get = (path) => JSON.parse(run('gh', ['api', endpoint(path)]))
const canWrite = (login) =>
['write', 'maintain', 'admin'].includes(
get(`collaborators/${login}/permission`).permission,
)
function output(values) {
appendFileSync(
env.GITHUB_OUTPUT,
Object.entries(values)
.map(([key, value]) => `${key}=${value}\n`)
.join(''),
)
}
function versionPatternFor(path) {
if (path.endsWith('.json')) return /("version"\s*:\s*")[^"]+/
return path === 'Cargo.lock'
? /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = ")[^"]+/
: /(^version\s*=\s*")[^"]+/m
}
function validateVersions(tag, sha) {
for (const path of versionFiles) {
const content = sha ? gitFile(sha, path) : read(path)
const version = path.endsWith('.json')
? JSON.parse(content).version
: content.match(versionPatternFor(path))?.[0].split('"').at(-1)
if (`v${version}` !== tag)
throw new Error(`${path} version does not match ${tag}.`)
}
}
function validateVersionCommit(tag, sha) {
validateVersions(tag, sha)
const commit = get(`commits/${sha}`)
if (!commit.commit.verification.verified || commit.parents.length !== 1)
throw new Error('The version commit must be signed and have one parent.')
const title = commit.commit.message.split('\n')[0]
if (title.replace(/ \(#\d+\)$/, '') !== titleFor(tag))
throw new Error('The final version commit has an unexpected title.')
const parent = commit.parents[0].sha
const changed = git('diff', '--name-only', parent, sha)
.split('\n')
.filter(Boolean)
if (
changed.some((path) => !versionFiles.includes(path)) ||
git('diff', '--summary', parent, sha)
)
throw new Error(
'The version commit changes files outside the four version files.',
)
for (const path of versionFiles) {
const before = gitFile(parent, path)
const after = gitFile(sha, path)
if (
before.replace(versionPatternFor(path), '$1VERSION') !==
after.replace(versionPatternFor(path), '$1VERSION')
)
throw new Error(`${path} contains changes other than its version.`)
}
}
function remoteRefs() {
return git('ls-remote', '--heads', '--tags', '--refs', 'origin')
.split('\n')
.filter(Boolean)
.map((line) => line.split('\t'))
}
function fetchBranches() {
git(
'fetch',
'--no-tags',
'origin',
'+refs/heads/dev:refs/remotes/origin/dev',
'+refs/heads/main:refs/remotes/origin/main',
)
}
function prepare() {
let refresh
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
const event = json(env.GITHUB_EVENT_PATH)
if (checked(event.changes?.body?.from) || !checked(event.pull_request.body))
return
if (!canWrite(event.sender.login))
throw new Error('Refreshing a version PR requires write access.')
refresh = get(`pulls/${event.pull_request.number}`)
if (refresh.state !== 'open' || !checked(refresh.body)) return
requireBotPr(refresh, 'dev', 'release/')
}
fetchBranches()
git('checkout', '--detach', 'origin/dev')
const channel = refresh
? isRc(branchTag(refresh.head.ref))
? 'rc'
: 'stable'
: env.RELEASE_CHANNEL || 'rc'
if (!['rc', 'stable'].includes(channel))
throw new Error('Unknown release channel.')
const requested = refresh
? branchTag(refresh.head.ref)
: env.RELEASE_TAG || ''
if (
requested &&
!(channel === 'rc' ? isRc(requested) : stablePattern.test(requested))
)
throw new Error(
channel === 'rc'
? 'Expected vX.Y.Z-rc or vX.Y.Z-rc.N.'
: 'Expected vX.Y.Z.',
)
const existing =
!refresh &&
list('pulls?state=open&base=dev&per_page=100').find(
(pr) =>
pr.head.repo?.full_name === repository &&
pr.user.login === 'github-actions[bot]' &&
branchTag(pr.head.ref) &&
isRc(branchTag(pr.head.ref)) === (channel === 'rc') &&
(!requested || branchTag(pr.head.ref) === requested),
)
if (existing) {
api(`issues/${existing.number}/labels`, { labels: ['release'] })
output({ existing_pr: existing.html_url })
summary(
`Existing version PR: ${existing.html_url}\nClose it before preparing a replacement; its candidate is unchanged.`,
)
return
}
const base = (requested || `v${json('package.json').version}`).split(
/[-+]/,
)[0]
if (!stablePattern.test(base))
throw new Error(`Invalid base version: ${base}`)
const refs = remoteRefs().map(([, ref]) => ref)
const tags = refs
.filter((ref) => ref.startsWith('refs/tags/'))
.map((ref) => ref.slice(10))
const reserved = refs
.filter((ref) => ref.startsWith('refs/heads/'))
.filter((ref) => !refresh || ref !== `refs/heads/${refresh.head.ref}`)
.map(
(ref) => branchTag(ref.slice(11)) || branchTag(ref.slice(11), 'publish/'),
)
.filter(Boolean)
const sameBase = (tag) => isRc(tag) && tag.startsWith(`${base}-rc`)
const occupied = [...tags, ...reserved]
const last = latest(occupied.filter(sameBase))
const tag =
requested ||
(channel === 'stable'
? base
: last
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
: `${base}-rc`)
if (occupied.includes(tag))
throw new Error(`Tag ${tag} already exists or is reserved.`)
const sha = git('rev-parse', 'HEAD')
const body = releaseBody(tag, sha, tags, false)
summary(body)
if (
remoteRefs().some(
([, ref]) =>
ref === `refs/tags/${tag}` || ref === `refs/heads/publish/${tag}`,
)
)
throw new Error(`Tag ${tag} already exists or is reserved for publication.`)
if (json('package.json').version !== tag.slice(1))
run('node', ['scripts/release-version.mjs', tag])
validateVersions(tag)
const title = titleFor(tag)
const branch = refresh
? `release-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
: `release/${tag}`
writeVersionPr({ sha, body, refresh, title, branch, files: versionFiles })
}
function releaseBody(tag, sha, tags, publishing) {
const previous =
(isRc(tag) &&
latest(
tags.filter(
(value) =>
isRc(value) &&
value.startsWith(`${tag.split('-')[0]}-rc`) &&
value !== tag,
),
)) ||
latest(tags.filter((value) => stablePattern.test(value) && value !== tag))
const url = `${env.GITHUB_SERVER_URL}/${repository}`
let range = sha
let comparison = 'No previous release tag'
if (previous) {
git('fetch', '--no-tags', 'origin', `refs/tags/${previous}`)
range = `${git('rev-parse', 'FETCH_HEAD^{commit}')}..${sha}`
comparison = `Changes since **${previous}** · [Compare changes](${url}/compare/${previous}...${sha})`
}
comparison += ` · **${git('rev-list', '--count', range)} commits**`
const commits = git('log', '--format=%h %s', range)
const groups = Object.groupBy(commits ? commits.split('\n') : [], (line) => {
if (/^\S+ chore\(deps\)!?: /i.test(line)) return 'deps'
const type = line.match(/^\S+ (\w+)(?:\([^)]*\))?!?: /)?.[1]?.toLowerCase()
return type === 'doc' ? 'docs' : type || 'other'
})
const labels = {
fix: 'Fixes',
feat: 'Features',
perf: 'Performance',
revert: 'Reverts',
refactor: 'Refactoring',
build: 'Build',
deps: 'Dependencies',
ci: 'CI',
docs: 'Documentation',
test: 'Tests',
chore: 'Maintenance',
style: 'Style',
other: 'Other',
}
const names = {
macos: 'macOS',
windows: 'Windows',
ui: 'UI',
ci: 'CI',
tun: 'TUN',
}
const order = Object.keys(labels)
const rank = (type) =>
order.includes(type) ? order.indexOf(type) : order.length
const scope =
Object.entries(groups)
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([type, lines]) => {
const entries = lines.map((line) => {
const hash = line.slice(0, line.indexOf(' '))
const title = line
.slice(hash.length + 1)
.replace(/\s*\((?:[\w.-]+\/[\w.-]+)?#\d+\)/g, '')
.replace(/(?:[\w.-]+\/[\w.-]+)?#\d+\b/g, '')
.replace(/\s{2,}/g, ' ')
.trim()
.replace(
/^\w+(?:\(([^)]*)\))?(!)?:\s*/,
(_, scope, breaking) =>
`${breaking ? 'Breaking: ' : ''}${scope ? `${Object.hasOwn(names, scope) ? names[scope] : scope}: ` : ''}`,
)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replace(/[\\`*_[\]]/g, '\\$&')
return `- ${title} [#${hash}](${url}/commit/${hash})`
})
return `### ${Object.hasOwn(labels, type) ? labels[type] : type} · ${lines.length}\n\n${entries.join('\n')}`
})
.join('\n\n') || 'No new commits.'
return `## ${titleFor(tag)}
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
${
publishing
? `<!-- release-source:${sha} -->
After approval, select the checkbox below to publish.
Do not use the normal Squash or Rebase merge buttons.
- [ ] ${publishLabel}`
: `
- [ ] ${refreshLabel}
Refreshing requires a new approval.`
}
## Changes
${scope}
`
}
function writeVersionPr({ sha, body, refresh, title, branch, files }) {
// Create-only ref and expectedHeadOid prevent replacing another candidate.
api('git/refs', { ref: `refs/heads/${branch}`, sha })
let pr
try {
const commit = api('graphql', {
query: `mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`,
variables: {
input: {
branch: { repositoryNameWithOwner: repository, branchName: branch },
expectedHeadOid: sha,
message: { headline: title },
fileChanges: {
additions: files.map((path) => ({
path,
contents: readFileSync(path).toString('base64'),
})),
},
},
},
})
if (!commit.data?.createCommitOnBranch?.commit?.oid)
throw new Error('Signed version commit failed.')
if (refresh) {
const current = get(`pulls/${refresh.number}`)
if (
current.state !== 'open' ||
current.head.sha !== refresh.head.sha ||
current.base.ref !== refresh.base.ref ||
current.body !== refresh.body
)
throw new Error(
'The version pull request changed while preparing the update.',
)
const oid = commit.data.createCommitOnBranch.commit.oid
updateRefs(refresh.base.repo.node_id, [
{
name: `refs/heads/${refresh.head.ref}`,
beforeOid: refresh.head.sha,
afterOid: oid,
force: true,
},
{
name: `refs/heads/${branch}`,
beforeOid: oid,
afterOid: '0'.repeat(40),
},
])
for (const review of list(
`pulls/${refresh.number}/reviews?per_page=100`,
)) {
if (review.state === 'APPROVED')
api(
`pulls/${refresh.number}/reviews/${review.id}/dismissals`,
{
message:
'The version PR was refreshed from dev; please review again.',
},
'PUT',
)
}
pr = api(`pulls/${refresh.number}`, { title, body }, 'PATCH')
} else {
pr = api('pulls', {
head: branch,
base: 'dev',
title,
body,
})
}
} catch (error) {
if (refresh) {
try {
api(`git/refs/heads/${branch}`, {}, 'DELETE')
} catch {}
}
throw error
}
api(`issues/${pr.number}/labels`, { labels: ['release'] })
summary(`Version pull request: ${pr.html_url}`)
}
function updateRefs(repositoryId, refUpdates) {
api('graphql', {
query: `mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }`,
variables: { input: { repositoryId, refUpdates } },
})
}
function requireBotPr(pr, base, prefix) {
const tag = branchTag(pr.head.ref, prefix)
if (
!tag ||
pr.base.ref !== base ||
pr.base.repo.full_name !== repository ||
pr.head.repo?.full_name !== repository ||
pr.user.login !== 'github-actions[bot]'
)
throw new Error(
'Expected a release PR created by this repository workflow.',
)
return tag
}
function publishPr() {
const event = json(env.GITHUB_EVENT_PATH)
const pr = get(`pulls/${event.pull_request.number}`)
const tag = requireBotPr(pr, 'dev', 'release/')
if (!pr.merged || pr.merge_commit_sha !== event.pull_request.merge_commit_sha)
throw new Error('Expected the final merged version commit on dev.')
const sha = pr.merge_commit_sha
fetchBranches()
git('fetch', '--no-tags', 'origin', sha)
git('merge-base', '--is-ancestor', sha, 'origin/dev')
validateVersionCommit(tag, sha)
const branch = `publish/${tag}`
const existing = list(
`pulls?state=all&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
if (existing.length) {
const publication = existing[0]
requireBotPr(publication, 'main', 'publish/')
if (publication.head.sha !== sha)
throw new Error(
'This tag is already reserved by a different publication snapshot.',
)
summary(`Existing publication PR: ${publication.html_url}`)
return
}
const refs = remoteRefs()
if (refs.some(([, ref]) => ref === `refs/tags/${tag}`))
throw new Error(`Tag ${tag} already exists.`)
git('merge-base', '--is-ancestor', 'origin/main', sha)
const head = refs.find(([, ref]) => ref === `refs/heads/${branch}`)?.[0]
if (head && head !== sha)
throw new Error('Publication branch already points to another commit.')
if (!head) api('git/refs', { ref: `refs/heads/${branch}`, sha })
const body = releaseBody(
tag,
sha,
refs
.filter(([, ref]) => ref.startsWith('refs/tags/'))
.map(([, ref]) => ref.slice(10)),
true,
)
const publication = api('pulls', {
head: branch,
base: 'main',
title: titleFor(tag),
body,
})
api(`issues/${publication.number}/labels`, { labels: ['release'] })
summary(`Publication PR: ${publication.html_url}\n\n${body}`)
}
function requireApproval(pr, sha) {
const reviews = new Map()
for (const review of list(`pulls/${pr.number}/reviews?per_page=100`)) {
if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state))
reviews.set(review.user.login, review)
}
let approved = false
for (const review of reviews.values()) {
if (!canWrite(review.user.login)) continue
if (review.state === 'CHANGES_REQUESTED')
throw new Error('A reviewer with write access has requested changes.')
if (review.state === 'APPROVED' && review.commit_id === sha) approved = true
}
if (!approved)
throw new Error(
'The current PR head needs approval from a reviewer with write access.',
)
}
function releaseOutput(tag, sha) {
output({
release_tag: tag,
source_sha: sha,
channel: isRc(tag) ? 'rc' : 'stable',
})
}
function publish() {
const event = json(env.GITHUB_EVENT_PATH)
const tag = requireBotPr(event.pull_request, 'main', 'publish/')
if (
checked(event.changes?.body?.from, publishLabel) ||
!checked(event.pull_request.body, publishLabel)
)
throw new Error('Publishing requires a new checkbox selection.')
if (!canWrite(event.sender.login))
throw new Error('Publishing requires write access.')
const sha = event.pull_request.head.sha
const pr = get(`pulls/${event.pull_request.number}`)
requireBotPr(pr, 'main', 'publish/')
if (
pr.head.sha !== sha ||
!checked(pr.body, publishLabel) ||
!pr.body.includes(`<!-- release-source:${sha} -->`)
)
throw new Error(
'The publication snapshot changed after the checkbox selection.',
)
fetchBranches()
git('fetch', '--no-tags', 'origin', sha)
git('merge-base', '--is-ancestor', sha, 'origin/dev')
validateVersionCommit(tag, sha)
const marker = `Release workflow run: ${env.GITHUB_RUN_ID}\nSource: ${sha}\nPR: ${pr.number}`
const existing = remoteRefs().find(([, ref]) => ref === `refs/tags/${tag}`)
if (existing) {
const ref = get(`git/ref/tags/${tag}`)
const object =
ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
if (
!object ||
object.object.type !== 'commit' ||
object.object.sha !== sha ||
object.message.trim() !== marker ||
Number(env.GITHUB_RUN_ATTEMPT) <= 1
)
throw new Error(
'An existing tag can only be recovered by its original release run.',
)
git('merge-base', '--is-ancestor', sha, 'origin/main')
summary(`Resuming ${tag} at ${sha} from this release run.`)
} else {
if (pr.state !== 'open' || pr.draft)
throw new Error('Expected an open, ready publication PR.')
requireApproval(pr, sha)
const main = git('rev-parse', 'origin/main')
git('merge-base', '--is-ancestor', main, sha)
const object = api('git/tags', {
tag,
message: marker,
object: sha,
type: 'commit',
})
// The tag records run ownership in the same transaction as the fast-forward.
updateRefs(pr.base.repo.node_id, [
{ name: 'refs/heads/main', beforeOid: main, afterOid: sha, force: false },
{
name: `refs/heads/${pr.head.ref}`,
beforeOid: sha,
afterOid: sha,
force: false,
},
{
name: `refs/tags/${tag}`,
beforeOid: '0'.repeat(40),
afterOid: object.sha,
force: false,
},
])
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
}
releaseOutput(tag, sha)
}
function validateTag() {
const tag = env.GITHUB_REF_NAME
if (!isRelease(tag)) throw new Error('Expected a stable or RC release tag.')
fetchBranches()
const sha = git('rev-parse', `${env.GITHUB_SHA}^{commit}`)
const ref = get(`git/ref/tags/${tag}`)
const object = ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
if (object && object.message.includes('Release workflow run:'))
throw new Error(
'Resume the original publication run for workflow-owned tags.',
)
git('fetch', '--no-tags', 'origin', `refs/tags/${tag}`)
if (git('rev-parse', 'FETCH_HEAD^{commit}') !== sha)
throw new Error('The tag moved after this run started.')
for (const branch of ['main', 'dev'])
git('merge-base', '--is-ancestor', sha, `origin/${branch}`)
validateVersions(tag, sha)
releaseOutput(tag, sha)
}
function prepareDraft() {
const tag = env.RELEASE_TAG
verifyTarget()
const release = list('releases?per_page=100').find(
(release) => release.tag_name === tag,
)
if (release && !release.draft)
throw new Error('This release is already public.')
const draft =
release ||
api('releases', {
tag_name: tag,
target_commitish: env.SOURCE_SHA,
name: `Clash Verge Rev ${tag}`,
body: 'Draft release, will be updated later.',
draft: true,
prerelease: isRc(tag),
})
output({ id: draft.id })
}
function verifyTarget(tag = env.RELEASE_TAG, sha = env.SOURCE_SHA) {
const ref = get(`git/ref/tags/${tag}`)
const target =
ref.object.type === 'tag'
? get(`git/tags/${ref.object.sha}`).object
: ref.object
if (target.type !== 'commit' || target.sha !== sha)
throw new Error('The release tag no longer points to the validated source.')
}
function nextDev() {
let refresh
let tag = env.RELEASE_TAG
let source = env.SOURCE_SHA
let minor = false
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
const event = json(env.GITHUB_EVENT_PATH)
const oldBody = event.changes?.body?.from
const body = event.pull_request.body
if (
checked(oldBody, minorLabel) === checked(body, minorLabel) &&
(checked(oldBody) || !checked(body))
)
return
if (!canWrite(event.sender.login))
throw new Error('Changing the next dev version requires write access.')
refresh = get(`pulls/${event.pull_request.number}`)
if (refresh.state !== 'open') return
tag = requireBotPr(refresh, 'dev', 'next-dev/')
if (
refresh.head.sha !== event.pull_request.head.sha ||
refresh.body !== body
)
throw new Error(
'The version update PR changed after the checkbox selection.',
)
minor = checked(body, minorLabel)
source = body.match(/<!-- next-dev-source:([a-f0-9]{40}) -->/)?.[1]
}
if (!stablePattern.test(tag) || !/^[a-f0-9]{40}$/.test(source))
throw new Error('The next dev version requires a stable release snapshot.')
verifyTarget(tag, source)
const release = get(`releases/tags/${tag}`)
if (release.draft || release.prerelease)
throw new Error('The stable release must be public before preparing dev.')
const branch = `next-dev/${tag}`
if (!refresh) {
const existing = list(
`pulls?state=all&base=dev&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)[0]
if (existing) {
requireBotPr(existing, 'dev', 'next-dev/')
summary(`Existing dev version PR: ${existing.html_url}`)
return
}
}
fetchBranches()
git('fetch', '--no-tags', 'origin', source)
git('merge-base', '--is-ancestor', source, 'origin/main')
git('merge-base', '--is-ancestor', source, 'origin/dev')
git('checkout', '--detach', 'origin/dev')
const sha = git('rev-parse', 'HEAD')
validateVersions(tag, source)
validateVersions(tag, sha)
if (read('Changelog.md') !== gitFile(source, 'Changelog.md'))
throw new Error(
'Changelog.md changed after this release. Preserve the unreleased entries and prepare the dev update manually.',
)
if (
!read('Changelog.md').split('\n').includes(`## ${tag}`) ||
read('docs/Changelog.history.md').split('\n').includes(`## ${tag}`)
)
throw new Error('The released changelog is missing or already archived.')
const [major, minorVersion, patch] = tag.slice(1).split('.').map(BigInt)
const patchTag = `v${major}.${minorVersion}.${patch + 1n}`
const minorTag = `v${major}.${minorVersion + 1n}.0`
const nextTag = minor ? minorTag : patchTag
run('pnpm', ['release-version', nextTag])
run('bash', ['scripts-workflow/bump_changelog.sh'])
const changelog = read('Changelog.md')
if (!changelog.includes('## v(Version Goes Here)'))
throw new Error('The changelog template has no version placeholder.')
writeFileSync(
'Changelog.md',
changelog.replace('## v(Version Goes Here)', `## ${nextTag}`),
)
validateVersions(nextTag)
const title = `Dev: ${nextTag}`
const body = `## ${title}
After [${tag}](${env.GITHUB_SERVER_URL}/${repository}/releases/tag/${tag}).
<!-- next-dev-source:${source} -->
Patch: \`${patchTag}\` · Minor: \`${minorTag}\`
- [${minor ? 'x' : ' '}] ${minorLabel}
- [ ] ${refreshLabel}
Changing the version or refreshing requires a new approval.
`
writeVersionPr({
sha,
body,
refresh,
title,
branch: refresh
? `next-dev-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
: branch,
files: [...versionFiles, 'Changelog.md', 'docs/Changelog.history.md'],
})
}
const commands = {
prepare,
'next-dev': nextDev,
'publish-pr': publishPr,
publish,
'validate-tag': validateTag,
'prepare-draft': prepareDraft,
'verify-target': verifyTarget,
}
const command = commands[process.argv[2]]
if (!command) throw new Error('Unknown release command.')
command()
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.9-rc"
version = "2.5.9"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -79,7 +79,7 @@ tauri-plugin-fs = "2.7.1"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.2"
tauri-plugin-window-state = "2.5.0"
zip = "9.1.3"
zip = "9.0.4"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
@@ -154,7 +154,7 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.1"
tauri-plugin-updater = "2.14.0"
[dev-dependencies]
criterion = { workspace = true }
+2 -2
View File
@@ -109,7 +109,7 @@ impl AutoBackupManager {
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send_replace(settings);
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings);
Ok(())
}
@@ -119,7 +119,7 @@ impl AutoBackupManager {
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send_replace(settings);
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings);
Ok(())
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
/// Apply a patch without post-update reconciliation.
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
let config_write = Config::lock_config_write().await;
let config_write = Config::try_lock_config_write()?;
apply_verge_patch_locked(&config_write, patch, not_save_file).await
}
+1
View File
@@ -316,6 +316,7 @@ pub async fn hide() {
{
let _ = window.hide();
}
handle::Handle::global().set_activation_policy_accessory();
}
#[cfg(test)]
+3
View File
@@ -289,6 +289,9 @@ pub fn run() -> std::process::ExitCode {
}
pub fn handle_window_close(api: &tauri::WindowEvent) {
#[cfg(target_os = "macos")]
handle::Handle::global().set_activation_policy_accessory();
if core::handle::Handle::global().is_exiting() {
return;
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.9-rc",
"version": "2.5.9",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+24
View File
@@ -21,3 +21,27 @@ These rules supplement the repository's root `AGENTS.md`.
- **Command ownership.** `services` owns backend command invocation and transport
wrappers. Components and hooks use those boundaries; plugin mutations follow
the same intent ownership as backend commands.
- **State liveness.** Every `useState`/`useRef` member has a reachable write
that differs from its initial value and a read that observes the difference.
A value that can only ever hold its initial value is a constant: declare the
constant and delete the state. A ref nothing reads is deleted, not kept.
- **Surface liveness.** Anything a module exposes — props, parameters, context
members, imperative-handle methods — ships with at least one external caller
in the same change. "Reserved for future use" fields, `_`-prefixed params
that callers still pass, and error or callback channels that every receiver
ignores are removed, not maintained.
- **Path liveness.** A fallback or branch names the concrete runtime condition
that reaches it. If no supported runtime can reach it, the branch is not
added — or it replaces the primary path. Constant subexpressions and cleanup
of resources that cannot exist yet are simplified away.
- **Effect economy.** One user interaction produces one state write and one
command. A handler does not re-apply a value its wrapper already applied,
and an effect does not re-run a refresh another branch of the same effect
just ran.
- **Mount economy.** Components that issue commands or subscriptions on mount
are mounted only where their UI is reachable. A dialog no rendered control
can open stays unmounted, not mounted-and-idle.
- **Resource bounds.** Every cache key, timer and subscription a feature
allocates is reused or released on refresh; refresh never allocates an
unbounded sequence of new keys. Retries re-create per-attempt resources so
a retry can actually succeed.
+13 -30
View File
@@ -22,23 +22,16 @@ interface BaseSplitChipEditorProps {
error?: boolean
helperText?: ReactNode
placeholder?: string
rows?: number
separator?: string
splitPattern?: RegExp
defaultMode?: BaseSplitChipEditorMode
showModeToggle?: boolean
ariaLabel?: string
addLabel?: ReactNode
emptyLabel?: ReactNode
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
renderHeader?: (modeToggle: ReactNode) => ReactNode
}
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
const splitValue = (value: string, splitPattern: RegExp) =>
const splitValue = (value: string) =>
value
.split(splitPattern)
.split(DEFAULT_SPLIT_PATTERN)
.map((item) => item.trim())
.filter(Boolean)
@@ -49,35 +42,25 @@ export const BaseSplitChipEditor = ({
error = false,
helperText,
placeholder,
rows = 4,
separator = ',',
splitPattern = DEFAULT_SPLIT_PATTERN,
defaultMode = 'visual',
showModeToggle = true,
ariaLabel,
addLabel,
emptyLabel,
modeLabels,
renderHeader,
}: BaseSplitChipEditorProps) => {
const { t } = useTranslation()
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
const [draft, setDraft] = useState('')
const resolvedLabels = useMemo(
() => ({
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
add: addLabel ?? t('shared.actions.new'),
empty: emptyLabel ?? t('shared.statuses.empty'),
visual: t('shared.editorModes.visualization'),
advanced: t('shared.editorModes.advanced'),
add: t('shared.actions.new'),
empty: t('shared.statuses.empty'),
}),
[t, modeLabels, addLabel, emptyLabel],
[t],
)
const values = useMemo(
() => splitValue(value, splitPattern),
[value, splitPattern],
)
const values = useMemo(() => splitValue(value), [value])
const items = useMemo(() => {
const counts = new Map<string, number>()
@@ -92,7 +75,7 @@ export const BaseSplitChipEditor = ({
}, [values])
const handleAddDraft = () => {
const nextValues = splitValue(draft, splitPattern)
const nextValues = splitValue(draft)
if (!nextValues.length) {
return
}
@@ -113,7 +96,7 @@ export const BaseSplitChipEditor = ({
const resolvedAriaLabel =
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
const modeToggle = showModeToggle ? (
const modeToggle = (
<Tooltip title={toggleLabel}>
<IconButton
size="small"
@@ -128,7 +111,7 @@ export const BaseSplitChipEditor = ({
<ToggleIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null
)
return (
<>
@@ -202,7 +185,7 @@ export const BaseSplitChipEditor = ({
disabled={disabled}
size="small"
multiline
rows={rows}
rows={4}
sx={{ width: '100%' }}
value={value}
helperText={helperText}
+3 -9
View File
@@ -596,7 +596,7 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey],
)
const { handleSelectChange } = useProxySelection({
const { select } = useProxySelection({
onSuccess: () => {
refreshProxy()
},
@@ -766,15 +766,9 @@ export const CurrentProxyCard = () => {
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes.
handleSelectChange(
selectedGroup.name,
previousProxy!,
selectedGroup.fixed!,
)({
target: { value: nextName },
})
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
},
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
)
const goToProxies = useCallback(() => {
@@ -14,7 +14,6 @@ import { useTranslation } from 'react-i18next'
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
import { useVerge } from '@/hooks/use-verge'
import { debugLog } from '@/utils/debug'
import parseTraffic from '@/utils/parse-traffic'
import {
formatTrafficHourMinute,
@@ -23,14 +22,7 @@ import {
isSameTrafficData,
} from '@/utils/traffic-sampler'
interface ITrafficItem {
up: number
down: number
timestamp?: number
}
export interface EnhancedCanvasTrafficGraphRef {
appendData: (data: ITrafficItem) => void
toggleStyle: () => void
}
@@ -142,7 +134,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[],
)
const debounceTimeoutRef = useRef<number | null>(null)
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
const colors = useMemo(
() => ({
@@ -183,8 +174,6 @@ export const EnhancedCanvasTrafficGraph = memo(
if (displayData.length === 0) {
lastDataTimestampRef.current = 0
dataStaleRef.current = false
// eslint-disable-next-line @eslint-react/set-state-in-effect
setCurrentFPS(GRAPH_CONFIG.targetFPS)
return
}
@@ -200,18 +189,11 @@ export const EnhancedCanvasTrafficGraph = memo(
}
}, [displayData])
const handleFocusStateChange = useCallback(
(focused: boolean) => {
isWindowFocusedRef.current = focused
const handleFocusStateChange = useCallback((focused: boolean) => {
isWindowFocusedRef.current = focused
if (focused || !pause_render_traffic_stats_on_blur) {
setCurrentFPS(GRAPH_CONFIG.targetFPS)
}
scheduleDrawGraphRef.current()
},
[pause_render_traffic_stats_on_blur],
)
scheduleDrawGraphRef.current()
}, [])
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
@@ -684,19 +666,13 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.beginPath()
ctx.moveTo(getX(0), getY(0))
if (chartStyle === 'bezier') {
for (let i = 1; i < data.length; i++) {
const currentX = getX(i)
const currentY = getY(i)
const nextIndex = Math.min(i + 1, lastIndex)
const controlX = (currentX + getX(nextIndex)) / 2
const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
}
} else {
for (let i = 1; i < data.length; i++) {
ctx.lineTo(getX(i), getY(i))
}
for (let i = 1; i < data.length; i++) {
const currentX = getX(i)
const currentY = getY(i)
const nextIndex = Math.min(i + 1, lastIndex)
const controlX = (currentX + getX(nextIndex)) / 2
const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
}
ctx.lineTo(getX(lastIndex), height - padding.bottom)
@@ -984,21 +960,7 @@ export const EnhancedCanvasTrafficGraph = memo(
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
}, [])
const appendData = useCallback((data: ITrafficItem) => {
debugLog(
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
data,
)
}, [])
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
)
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle])
const getTimeRangeText = useCallback(() => {
return t('home.components.traffic.patterns.minutes', {
@@ -1142,7 +1104,7 @@ export const EnhancedCanvasTrafficGraph = memo(
{t('home.components.traffic.diagnostics', {
points: displayData.length,
compressed: samplerStats.compressedBufferSize,
fps: currentFPS,
fps: GRAPH_CONFIG.targetFPS,
})}
</Box>
+55 -6
View File
@@ -3,10 +3,14 @@ import {
ArrowUpwardRounded,
CloudDownloadRounded,
CloudUploadRounded,
ErrorOutlineRounded,
LinkRounded,
MemoryRounded,
} from '@mui/icons-material'
import {
Alert,
Box,
Button,
Grid,
PaletteColor,
Paper,
@@ -15,9 +19,9 @@ import {
useTheme,
} from '@mui/material'
import { ReactNode, memo, useMemo, useRef } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
@@ -127,6 +131,50 @@ const CompactStatCard = memo(
// 添加显示名称
CompactStatCard.displayName = 'CompactStatCard'
const fallbackSx = {
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
}
const reloadPage = () => window.location.reload()
const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
const { t } = useTranslation()
return (
<Box sx={fallbackSx}>
<ErrorOutlineRounded color="error" sx={{ fontSize: 48, mb: 2 }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Alert>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" size="small" onClick={resetErrorBoundary}>
{t('shared.actions.retry')}
</Button>
<Button variant="outlined" size="small" onClick={reloadPage}>
{t('shared.actions.refreshPage')}
</Button>
</Box>
</Box>
)
}
export const EnhancedTrafficStats = () => {
const { t } = useTranslation()
const theme = useTheme()
@@ -254,10 +302,11 @@ export const EnhancedTrafficStats = () => {
}, [t, parsedData, displayMemory])
return (
<TrafficErrorBoundary
onError={(error, errorInfo) => {
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
}}
<ErrorBoundary
FallbackComponent={TrafficFallback}
onError={(error, errorInfo) =>
console.error('[EnhancedTrafficStats]', error, errorInfo)
}
>
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
{trafficGraph && (
@@ -273,6 +322,6 @@ export const EnhancedTrafficStats = () => {
</Grid>
))}
</Grid>
</TrafficErrorBoundary>
</ErrorBoundary>
)
}
+6 -13
View File
@@ -86,16 +86,11 @@ export const SystemInfoCard = () => {
}, [verge, patchVerge])
const handleRunningModeClick = useCallback(async () => {
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
if (isSidecarMode) {
await installServiceAndRestartCore()
await mutateSystemState()
}
}, [
isSidecarMode,
isAdminMode,
installServiceAndRestartCore,
mutateSystemState,
])
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState])
const onCheckUpdate = useLockFn(async () => {
try {
@@ -122,18 +117,16 @@ export const SystemInfoCard = () => {
const runningModeStyle = useMemo(
() => ({
cursor:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
cursor: isSidecarMode ? 'pointer' : 'default',
textDecoration: isSidecarMode ? 'underline' : 'none',
display: 'flex',
alignItems: 'center',
gap: 0.5,
'&:hover': {
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
opacity: isSidecarMode ? 0.7 : 1,
},
}),
[isSidecarMode, isAdminMode],
[isSidecarMode],
)
const getModeIcon = () => {
+3 -3
View File
@@ -87,7 +87,7 @@ export const TestCard = () => {
x.uid === uid ? { ...x, ...patch } : x,
)
mutateVerge({ ...verge, test_list: newList }, false)
mutateVerge({ ...verge, test_list: newList })
},
[testList, verge, mutateVerge],
)
@@ -96,7 +96,7 @@ export const TestCard = () => {
(uid: string) => {
const newList = testList.filter((x) => x.uid !== uid)
patchVerge({ test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
mutateVerge({ ...verge, test_list: newList })
},
[testList, verge, patchVerge, mutateVerge],
)
@@ -129,7 +129,7 @@ export const TestCard = () => {
newList.splice(newIndex, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }, false)
mutateVerge({ ...verge, test_list: newList })
const patchFn = () => {
try {
patchVerge({ test_list: newList })
+1 -4
View File
@@ -35,10 +35,7 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
const handleMenuOrderOptimisticUpdate = useCallback(
(order: string[]) => {
mutateVerge(
(prev) => (prev ? { ...prev, menu_order: order } : prev),
false,
)
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev))
},
[mutateVerge],
)
+33 -4
View File
@@ -1,14 +1,15 @@
import {
ArrowDownwardRounded,
ArrowUpwardRounded,
ErrorOutlineRounded,
MemoryRounded,
} from '@mui/icons-material'
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
import { Box, Typography } from '@mui/material'
import { useEffect, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge'
@@ -17,6 +18,30 @@ import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from './traffic-graph'
const unavailableSx = {
p: 1,
minHeight: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}
const TrafficUnavailableFallback = () => {
const { t } = useTranslation()
return (
<Box sx={unavailableSx}>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
)
}
export const LayoutTraffic = () => {
const { t } = useTranslation()
const { verge } = useVerge()
@@ -75,7 +100,12 @@ export const LayoutTraffic = () => {
}
return (
<LightweightTrafficErrorBoundary>
<ErrorBoundary
FallbackComponent={TrafficUnavailableFallback}
onError={(error, errorInfo) =>
console.error('[LayoutTraffic]', error, errorInfo)
}
>
<Box sx={{ position: 'relative' }}>
{trafficGraph && pageVisible && (
<div
@@ -130,7 +160,6 @@ export const LayoutTraffic = () => {
cursor: 'auto',
}}
color={'disabled'}
onClick={async () => {}}
>
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
@@ -139,6 +168,6 @@ export const LayoutTraffic = () => {
)}
</Box>
</Box>
</LightweightTrafficErrorBoundary>
</ErrorBoundary>
)
}
+4 -36
View File
@@ -1,11 +1,6 @@
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from '@mui/material'
import {
forwardRef,
type PointerEvent,
useCallback,
useImperativeHandle,
} from 'react'
import { type PointerEvent, useCallback } from 'react'
import { useWindowControls } from '@/hooks/use-window'
import getSystem from '@/utils/get-system'
@@ -63,36 +58,9 @@ export const WindowResizeHandles = () => {
)
}
export const WindowControls = forwardRef(function WindowControls(props, ref) {
export const WindowControls = () => {
const OS = getSystem()
const {
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls()
useImperativeHandle(
ref,
() => ({
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
}),
[
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
],
)
const { maximized, minimize, close, toggleMaximize } = useWindowControls()
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
@@ -186,4 +154,4 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
)}
</Box>
)
})
}
+3 -3
View File
@@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
}
}, [handleScroll])
const { handleProxyGroupChange } = useProxySelection({
const { select } = useProxySelection({
onSuccess: () => {
onProxies()
},
@@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return
handleProxyGroupChange(group, { name: member.ref.name })
select(group.name, member.ref.name, group.now!, group.fixed!)
},
[handleProxyGroupChange],
[select],
)
const handleLocation = useStableCallback((group: ProxyGroupView) => {
@@ -30,10 +30,8 @@ export const useProxyGroupHeaderLayout = () => {
if (canceled || !operation.source || !operation.target) return
const position = toolsOnLeft ? 'right' : 'left'
mutateVerge(
(prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
false,
mutateVerge((prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
)
try {
await patchVerge({ proxy_group_tools_position: position })
@@ -40,9 +40,9 @@ export const BackupConfigViewer = memo(
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: {
url: webdav_url ?? '',
username: webdav_username ?? '',
password: webdav_password ?? '',
url: webdav_url!,
username: webdav_username!,
password: webdav_password!,
},
})
const url = watch('url')
@@ -50,9 +50,9 @@ export const BackupConfigViewer = memo(
const password = watch('password')
const webdavChanged =
(webdav_url ?? '') !== url ||
(webdav_username ?? '') !== username ||
(webdav_password ?? '') !== password
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password
const webdavSignature = buildWebdavSignature(verge)
const webdavStatus = getWebdavStatus(webdavSignature)
@@ -69,9 +69,9 @@ export const BackupConfigViewer = memo(
}, [webdav_url, webdav_username, webdav_password, onInit, shouldAutoInit])
const checkForm = () => {
const username = usernameRef.current?.value.trim()
const username = usernameRef.current?.value
const password = passwordRef.current?.value
const url = urlRef.current?.value.trim()
const url = urlRef.current?.value
if (!url) {
urlRef.current?.focus()
@@ -95,15 +95,14 @@ export const BackupConfigViewer = memo(
}
const save = useLockFn(async (data: IWebDavConfig) => {
const trimmedUrl = data.url.trim()
const trimmedUsername = data.username.trim()
const isClearing = !trimmedUrl && !trimmedUsername && !data.password
if (!isClearing) checkForm()
checkForm()
const signature = buildWebdavSignature({
webdav_url: data.url,
webdav_username: data.username,
webdav_password: data.password,
})
const trimmedUrl = data.url.trim()
const trimmedUsername = data.username.trim()
try {
setLoading(true)
@@ -114,21 +113,19 @@ export const BackupConfigViewer = memo(
errorNotice: false,
},
)
await mutateVerge(
(current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
false,
await mutateVerge((current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
)
setWebdavStatus(signature, 'unknown')
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
if (!isClearing) await onSaveSuccess(signature)
await onSaveSuccess(signature)
} catch (error) {
showNotice.error(
'settings.modals.backup.messages.webdavConfigSaveFailed',
@@ -224,9 +221,9 @@ export const BackupConfigViewer = memo(
}}
>
{webdavChanged ||
!webdav_url ||
!webdav_username ||
!webdav_password ? (
webdav_url === undefined ||
webdav_username === undefined ||
webdav_password === undefined ? (
<Button
variant="contained"
color={'primary'}
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
showNotice.error(err)
}
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false)
mutateVerge({ ...verge, ...patch })
}
return (
+18 -29
View File
@@ -15,44 +15,33 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service'
const buildInitialValues = (verge?: IVergeConfig | null) => ({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const [open, setOpen] = useState(false)
const [values, setValues] = useState({
appLogLevel: 'warn',
appLogMaxSize: 8,
appLogMaxCount: 12,
autoCloseConnection: true,
autoCheckUpdate: true,
enableBuiltinEnhanced: true,
proxyLayoutColumn: 6,
enableAutoDelayDetection: false,
autoDelayDetectionIntervalMinutes: 5,
defaultLatencyTest: '',
autoLogClean: 2,
defaultLatencyTimeout: 10000,
})
const [values, setValues] = useState(() => buildInitialValues(null))
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValues({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
setValues(buildInitialValues(verge))
},
close: () => setOpen(false),
}))
+14 -21
View File
@@ -87,6 +87,17 @@ const splitBypass = (value?: string) =>
.map((item) => item.trim())
.filter(Boolean)
const buildInitialValues = (verge?: IVergeConfig | null) => ({
guard: verge?.enable_proxy_guard,
enable_bypass_check: verge?.enable_bypass_check ?? true,
bypass: verge?.system_proxy_bypass,
duration: verge?.proxy_guard_duration ?? 10,
use_default: verge?.use_default_bypass ?? true,
pac: verge?.proxy_auto_config,
pac_content: verge?.pac_file_content ?? DEFAULT_PAC,
proxy_host: verge?.proxy_host ?? '127.0.0.1',
})
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const systemName = getSystem()
@@ -116,16 +127,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
proxy_host,
} = verge ?? {}
const [value, setValue] = useState({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
const [value, setValue] = useState(() => buildInitialValues(null))
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
null,
)
@@ -192,16 +194,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValue({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
setValue(buildInitialValues(verge))
fetchNetworkInterfaces()
},
close: () => setOpen(false),
@@ -339,7 +332,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
Promise.resolve().then(async () => {
try {
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch }, false)
mutateVerge({ ...verge, ...patch })
}
if (Object.keys(patch).length > 0) {
await patchVerge(patch)
+12 -18
View File
@@ -76,34 +76,28 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
const buildInitialValues = (): TunnelFormValues => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const { t } = useTranslation()
const { clash, mutateClash, patchClash } = useClash()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState<TunnelFormValues>({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
const [values, setValues] = useState<TunnelFormValues>(buildInitialValues)
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
useImperativeHandle(ref, () => ({
open: () => {
setValues(() => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
}))
setValues(buildInitialValues())
setDraftTunnels(() => clash?.tunnels ?? [])
setOpen(true)
// 如果没有隧道,则自动展开
@@ -72,10 +72,12 @@ const LazyReactMarkdown = lazy(async () => {
{ default: ReactMarkdown },
{ default: rehypeRaw },
{ default: rehypeSanitize, defaultSchema },
{ default: remarkGfm },
] = await Promise.all([
import('react-markdown'),
import('rehype-raw'),
import('rehype-sanitize'),
import('remark-gfm'),
])
const sanitizeSchema = {
@@ -102,6 +104,7 @@ const LazyReactMarkdown = lazy(async () => {
default: (props: ReactMarkdownOptions) => (
<ReactMarkdown
{...props}
remarkPlugins={[remarkGfm, ...(props.remarkPlugins ?? [])]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
/>
),
@@ -50,21 +50,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value]
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
await patchVerge({ web_ui_list: newList })
})
const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList]
newList[index] = value ?? ''
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
await patchVerge({ web_ui_list: newList })
})
const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList]
newList.splice(index, 1)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
await patchVerge({ web_ui_list: newList })
})
+3 -13
View File
@@ -1,14 +1,12 @@
import React, { useRef } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { DialogRef, Switch, TooltipIcon } from '@/components/base'
import { Switch, TooltipIcon } from '@/components/base'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
import { useVerge } from '@/hooks/use-verge'
import { GuardState } from './mods/guard-state'
import { SettingList, SettingItem } from './mods/setting-comp'
import { SysproxyViewer } from './mods/sysproxy-viewer'
import { TunViewer } from './mods/tun-viewer'
interface Props {
onError?: (err: Error) => void
@@ -21,22 +19,16 @@ const SettingSystem = ({ onError }: Props) => {
const { enable_auto_launch, enable_silent_start } = verge ?? {}
const sysproxyRef = useRef<DialogRef>(null)
const tunRef = useRef<DialogRef>(null)
const onSwitchFormat = (
_e: React.ChangeEvent<HTMLInputElement>,
value: boolean,
) => value
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false)
mutateVerge({ ...verge, ...patch })
}
return (
<SettingList title={t('settings.sections.system.title')}>
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<ProxyControlSwitches
label={t('settings.sections.system.toggles.tunMode')}
onError={onError}
@@ -58,8 +50,6 @@ const SettingSystem = ({ onError }: Props) => {
}}
onGuard={async (e) => {
try {
// 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e })
await patchVerge({ enable_auto_launch: e })
return Promise.resolve()
} catch (error) {
@@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
const backupRef = useRef<DialogRef>(null)
const onChangeData = (patch: any) => {
mutateVerge({ ...verge, ...patch }, false)
mutateVerge({ ...verge, ...patch })
}
const onCopyClashEnv = useCallback(async () => {
@@ -163,7 +163,7 @@ const ProxyControlSwitches = ({
})
return false
}
mutateVerge({ ...verge, enable_tun_mode: value }, false)
mutateVerge({ ...verge, enable_tun_mode: value })
await patchVerge({ enable_tun_mode: value })
}
@@ -229,8 +229,8 @@ const ProxyControlSwitches = ({
/>
)}
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />}
{isTunMode && <TunViewer ref={tunRef} />}
</Box>
)
}
@@ -1,301 +0,0 @@
import {
ErrorOutlineRounded,
RefreshRounded,
BugReportRounded,
} from '@mui/icons-material'
import { Box, Typography, Button, Alert, Collapse } from '@mui/material'
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
children: ReactNode
fallbackComponent?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
}
export class TrafficErrorBoundary extends Component<Props, State> {
private retryCount = 0
private maxRetries = 3
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo)
this.setState({
error,
errorInfo,
})
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
this.reportError(error, errorInfo)
}
private reportError = (error: Error, errorInfo: ErrorInfo) => {
const errorReport = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
}
console.error('[TrafficErrorBoundary] 错误报告:', errorReport)
}
private handleRetry = () => {
if (this.retryCount < this.maxRetries) {
this.retryCount++
console.log(
`[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`,
)
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
})
} else {
console.warn('[TrafficErrorBoundary] 已达到最大重试次数')
}
}
private handleRefresh = () => {
window.location.reload()
}
private toggleDetails = () => {
this.setState((prev) => ({ showDetails: !prev.showDetails }))
}
render() {
if (this.state.hasError) {
if (this.props.fallbackComponent) {
return this.props.fallbackComponent
}
return (
<TrafficErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
showDetails={this.state.showDetails}
canRetry={this.retryCount < this.maxRetries}
retryCount={this.retryCount}
maxRetries={this.maxRetries}
onRetry={this.handleRetry}
onRefresh={this.handleRefresh}
onToggleDetails={this.toggleDetails}
/>
)
}
return this.props.children
}
}
interface TrafficErrorFallbackProps {
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
canRetry: boolean
retryCount: number
maxRetries: number
onRetry: () => void
onRefresh: () => void
onToggleDetails: () => void
}
const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
error,
errorInfo,
showDetails,
canRetry,
retryCount,
maxRetries,
onRetry,
onRefresh,
onToggleDetails,
}) => {
const { t } = useTranslation()
return (
<Box
sx={{
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ fontSize: 48, mb: 2, color: 'error.main' }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2, textAlign: 'center' }}
>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="body2">
<strong>{t('shared.feedback.errors.label')}:</strong>{' '}
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Typography>
{retryCount > 0 && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>
{t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries}
</Typography>
)}
</Alert>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{canRetry && (
<Button
variant="contained"
color="primary"
startIcon={<RefreshRounded />}
onClick={onRetry}
size="small"
>
{t('shared.actions.retry')}
</Button>
)}
<Button variant="outlined" onClick={onRefresh} size="small">
{t('shared.actions.refreshPage')}
</Button>
<Button
variant="text"
startIcon={<BugReportRounded />}
onClick={onToggleDetails}
size="small"
>
{showDetails
? t('shared.actions.hideDetails')
: t('shared.actions.showDetails')}
</Button>
</Box>
<Collapse in={showDetails} sx={{ width: '100%', maxWidth: 600 }}>
<Box
sx={{
p: 2,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" gutterBottom>
{t('shared.feedback.errors.details')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{error?.stack}
</Typography>
{errorInfo?.componentStack && (
<>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
{t('shared.feedback.errors.componentStack')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{errorInfo.componentStack}
</Typography>
</>
)}
</Box>
</Collapse>
</Box>
)
}
export const LightweightTrafficErrorBoundary: React.FC<{
children: ReactNode
}> = ({ children }) => {
const { t } = useTranslation()
return (
<TrafficErrorBoundary
fallbackComponent={
<Box
sx={{
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 60,
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
}
>
{children}
</TrafficErrorBoundary>
)
}
+2 -4
View File
@@ -1,5 +1,4 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { useMemo } from 'react'
import { downloadIconCache } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
@@ -23,9 +22,8 @@ export const useIconCache = ({
const iconValue = icon?.trim() ?? ''
const cacheKeyValue = cacheKey?.trim() ?? ''
const isEnabled = useMemo(() => {
return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
}, [enabled, iconValue, cacheKeyValue])
const isEnabled =
enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
const { data } = useQuery({
queryKey: ['icon-cache', iconValue, cacheKeyValue],
+4 -8
View File
@@ -54,8 +54,7 @@ export const useLogData = () => {
const hasLoadedInitialLogsRef = useRef(false)
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
storageKey: 'mihomo_logs_date',
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
subscriptionKey: enableLog ? 'getClashLog' : null,
fallbackData: [],
connect: () => MihomoWebSocket.connect_logs(logLevel),
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
@@ -77,14 +76,13 @@ export const useLogData = () => {
}
const pendingLogs = buffer.splice(0, buffer.length)
flushTimeStr = null
next(null, (current) => appendLogs(current, pendingLogs))
next((current) => appendLogs(current, pendingLogs))
flushTimer = null
}
return {
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data)
void scheduleReconnect()
return
}
@@ -108,9 +106,7 @@ export const useLogData = () => {
if (!flushTimer) {
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
}
} catch (error) {
next(error)
}
} catch {}
},
async onConnected() {
if (hasLoadedInitialLogsRef.current) {
@@ -119,7 +115,7 @@ export const useLogData = () => {
const logs = await getClashLogs()
hasLoadedInitialLogsRef.current = true
if (isMounted()) {
next(null, (current) => {
next((current) => {
if (!current || current.length === 0) {
return clampLogs(filterLogsByLevel(logs, allowedTypes))
}
+3 -7
View File
@@ -33,15 +33,13 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
storageKey: 'mihomo_memory_date',
buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null),
subscriptionKey: enabled ? 'getClashMemory' : null,
fallbackData: FALLBACK_MEMORY_USAGE,
connect: () => MihomoWebSocket.connect_memory(),
throttleMs: 500,
setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data, FALLBACK_MEMORY_USAGE)
void scheduleReconnect()
return
}
@@ -51,10 +49,8 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
if (shouldSkipDuplicateMemory(parsed)) {
return
}
next(null, parsed)
} catch (error) {
next(error, FALLBACK_MEMORY_USAGE)
}
next(parsed)
} catch {}
},
}),
})
+32 -68
View File
@@ -1,12 +1,7 @@
import { useLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import {
removeCacheData,
setCacheData,
useQuery,
} from '@/services/query-client'
import { setCacheData, useQuery } from '@/services/query-client'
const RECONNECT_DELAY_MS = 1000
@@ -31,7 +26,6 @@ interface SharedSubscriptionEntry {
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
const subscriptionSnapshots = new Map<string, unknown>()
const initialSubscriptionDate = Date.now()
const getSubscriptionSnapshot = <T>(key: string) =>
subscriptionSnapshots.get(key) as T | undefined
@@ -136,15 +130,13 @@ const createSharedSubscriptionEntry = (
return entry
}
type Updater<T> = (current?: T) => T
/**
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
* functional updater `(current?: T) => T`. The functional form is resolved
* against the current subscription snapshot before updating SWR.
* Pass either a plain value or a functional updater; the updater is resolved
* against the current subscription snapshot before the cache is written.
*/
type NextFn<T> = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => void
type NextFn<T> = (data: T | Updater<T>) => void
interface HandlerContext<T> {
next: NextFn<T>
@@ -159,8 +151,7 @@ interface HandlerResult {
}
interface UseMihomoWsSubscriptionOptions<T> {
storageKey: string
buildSubscriptKey: (date: number) => string | null
subscriptionKey: string | null
fallbackData: T
connect: () => Promise<MihomoWebSocket>
/**
@@ -178,18 +169,12 @@ interface UseMihomoWsSubscriptionOptions<T> {
export const useMihomoWsSubscription = <T>(
options: UseMihomoWsSubscriptionOptions<T>,
) => {
const {
storageKey,
buildSubscriptKey,
fallbackData,
connect,
throttleMs,
setupHandlers,
} = options
const { subscriptionKey, fallbackData, connect, throttleMs, setupHandlers } =
options
const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate)
const subscriptKey = buildSubscriptKey(date)
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
const subscriptionCacheKey = subscriptionKey
? `$sub$${subscriptionKey}`
: null
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
if (subscriptionCacheKey) {
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
@@ -197,20 +182,9 @@ export const useMihomoWsSubscription = <T>(
const responseCacheKey =
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
const resolveNextData = useCallback(
(
data: T | ((current?: T) => T | undefined) | undefined,
cacheKey: string,
): T => {
if (typeof data === 'function') {
const updater = data as (current?: T) => T | undefined
const current = getSubscriptionSnapshot<T>(cacheKey)
return updater(current) ?? fallbackData
}
return data ?? fallbackData
},
[fallbackData],
)
// Bumping the epoch re-runs the subscription effect, rebuilding the shared
// entry (and its captured `connect`/handler closures) under the same key.
const [refreshEpoch, setRefreshEpoch] = useState(0)
const response = useQuery<T>({
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
@@ -238,44 +212,35 @@ export const useMihomoWsSubscription = <T>(
let throttleCleanup: (() => void) | undefined
let wrappedNext: NextFn<T>
const baseNext: NextFn<T> = (error, data) => {
if (error !== undefined && error !== null) {
return
}
if (data === undefined) return
const resolved = resolveNextData(data, subscriptionCacheKey)
const baseNext: NextFn<T> = (data) => {
const resolved =
typeof data === 'function'
? (data as Updater<T>)(
getSubscriptionSnapshot<T>(subscriptionCacheKey),
)
: (data as T)
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
}
if (throttleMs && throttleMs > 0) {
let pendingData: T | ((current?: T) => T | undefined) | undefined
let hasPending = false
let pendingData: T | Updater<T> | undefined
let timerId: ReturnType<typeof setTimeout> | null = null
const flush = () => {
timerId = null
if (hasPending) {
if (pendingData !== undefined) {
const data = pendingData
pendingData = undefined
hasPending = false
baseNext(undefined, data)
baseNext(data)
}
}
wrappedNext = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => {
if (error !== undefined && error !== null) {
baseNext(error, data)
return
}
wrappedNext = (data) => {
if (!timerId) {
baseNext(undefined, data)
baseNext(data)
timerId = setTimeout(flush, throttleMs)
} else {
pendingData = data
hasPending = true
}
}
@@ -341,15 +306,14 @@ export const useMihomoWsSubscription = <T>(
}
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
}, [subscriptionCacheKey])
}, [subscriptionCacheKey, refreshEpoch])
const refresh = useCallback(() => {
if (subscriptionCacheKey) {
subscriptionSnapshots.delete(subscriptionCacheKey)
void removeCacheData([subscriptionCacheKey])
writeSubscriptionSnapshot(subscriptionCacheKey, fallbackData)
}
setDate(Date.now())
}, [subscriptionCacheKey, setDate])
setRefreshEpoch((epoch) => epoch + 1)
}, [subscriptionCacheKey, fallbackData])
const setData = useCallback(
(data: T) => {
+2 -2
View File
@@ -35,8 +35,8 @@ it('applies and records independent group selections while another group is pend
const secondSuccess = vi.fn()
const first = useProxySelection({ onSuccess: firstSuccess })
const second = useProxySelection({ onSuccess: secondSuccess })
first.changeProxy('Group A', 'Node A')
second.changeProxy('Group B', 'Node B')
first.select('Group A', 'Node A')
second.select('Group B', 'Node B')
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
)
+4 -28
View File
@@ -35,7 +35,6 @@ const cleanupConnections = async (previousProxy: string) => {
interface ProxySelectionOptions {
onSuccess?: () => void
onError?: (error: any) => void
enableConnectionCleanup?: boolean
}
interface ProxyChangeRequest {
@@ -53,7 +52,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
const isProcessingRef = useRef(false)
const { onSuccess, onError, enableConnectionCleanup = true } = options
const { onSuccess, onError } = options
const autoCloseConnection = verge?.auto_close_connection ?? false
@@ -98,7 +97,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
)
}
if (enableConnectionCleanup && autoCloseConnection && previousProxy) {
if (autoCloseConnection && previousProxy) {
void cleanupConnections(previousProxy)
}
} catch (error) {
@@ -112,7 +111,6 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
[
autoCloseConnection,
forgetSelection,
enableConnectionCleanup,
onError,
onSuccess,
recordSelection,
@@ -138,7 +136,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}
}, [executeChange])
const changeProxy = useCallback(
const select = useCallback(
(
groupName: string,
proxyName: string,
@@ -156,27 +154,5 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
[flushChangeQueue],
)
const handleSelectChange = useCallback(
(groupName: string, previousProxy?: string, fixed?: string) =>
(event: { target: { value: string } }) => {
changeProxy(groupName, event.target.value, previousProxy, fixed)
},
[changeProxy],
)
const handleProxyGroupChange = useCallback(
(
group: { name: string; now?: string | null; fixed?: string | null },
proxy: { name: string },
) => {
changeProxy(group.name, proxy.name, group.now!, group.fixed!)
},
[changeProxy],
)
return {
changeProxy,
handleSelectChange,
handleProxyGroupChange,
}
return { select }
}
+4 -6
View File
@@ -52,9 +52,8 @@ export const useSystemProxyState = () => {
const toggleSystemProxy = async (enabled: boolean) => {
// Roll failed optimistic writes back to the latest confirmed state.
let confirmed = verge?.enable_system_proxy ?? false
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
false,
mutateVerge((prev) =>
prev ? { ...prev, enable_system_proxy: enabled } : prev,
)
pendingRef.current = enabled
@@ -78,9 +77,8 @@ export const useSystemProxyState = () => {
}
}
} catch (error) {
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev),
false,
mutateVerge((prev) =>
prev ? { ...prev, enable_system_proxy: confirmed } : prev,
)
// Queued requests were based on a state that never landed.
pendingRef.current = null
+2 -1
View File
@@ -41,7 +41,8 @@ export function useAppUptime() {
const { data: uptime = 0 } = useQuery({
queryKey: appUptimeQueryKey,
queryFn: getAppUptime,
staleTime: 5000,
// The wrapper maps staleTime to SWR's dedupingInterval, which drops
// refetchInterval ticks while it exceeds the interval.
refetchInterval: 3000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
+3 -7
View File
@@ -32,15 +32,13 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
graphData: { appendData },
} = useTrafficMonitorEnhanced({ subscribe: false, enabled })
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
storageKey: 'mihomo_traffic_date',
buildSubscriptKey: (date) => (enabled ? `getClashTraffic-${date}` : null),
subscriptionKey: enabled ? 'getClashTraffic' : null,
fallbackData: FALLBACK_TRAFFIC,
connect: () => MihomoWebSocket.connect_traffic(),
throttleMs: 200,
setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data, FALLBACK_TRAFFIC)
void scheduleReconnect()
return
}
@@ -51,10 +49,8 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
return
}
appendData(parsed)
next(null, parsed)
} catch (error) {
next(error, FALLBACK_TRAFFIC)
}
next(parsed)
} catch {}
},
}),
})
+32 -216
View File
@@ -3,7 +3,6 @@ import { Traffic } from 'tauri-plugin-mihomo-api'
import { useVisibility } from '@/hooks/use-visibility'
import { debugLog } from '@/utils/debug'
import { TrafficDataSampler, isSameTrafficData } from '@/utils/traffic-sampler'
// 引用计数管理器
class ReferenceCounter {
@@ -32,119 +31,11 @@ const WORKER_CONFIG = {
defaultRangeMinutes: 10,
}
class InlineTrafficMonitor {
private config = { ...WORKER_CONFIG }
private sampler = new TrafficDataSampler(this.config)
private throttleTimer: ReturnType<typeof setTimeout> | null = null
private agingTimer: ReturnType<typeof setInterval> | null = null
private currentRange = this.config.defaultRangeMinutes
private lastTimestamp: number | undefined
private lastBroadcast: ITrafficDataPoint[] = []
constructor(
private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void,
) {}
start(rangeMinutes?: number) {
this.currentRange = rangeMinutes ?? this.currentRange
this.handle({
type: 'init',
config: {
...this.config,
defaultRangeMinutes: this.currentRange,
},
})
}
stop() {
if (this.throttleTimer !== null) {
clearTimeout(this.throttleTimer)
this.throttleTimer = null
}
if (this.agingTimer !== null) {
clearInterval(this.agingTimer)
this.agingTimer = null
}
this.sampler.clear()
this.lastTimestamp = undefined
}
handle(message: TrafficWorkerRequestMessage) {
switch (message.type) {
case 'init': {
this.config = { ...message.config }
this.sampler = new TrafficDataSampler(this.config)
this.currentRange = message.config.defaultRangeMinutes
// Re-broadcast when points age out of the range, not on every tick
this.agingTimer = setInterval(() => {
if (this.throttleTimer !== null) return
const slice = this.sampler.getDataForTimeRange(this.currentRange)
if (!isSameTrafficData(this.lastBroadcast, slice)) {
this.emitSnapshot()
}
}, 1000)
this.emitSnapshot()
break
}
case 'append': {
const timestamp = message.payload.timestamp ?? Date.now()
const dataPoint: ITrafficDataPoint = {
up: message.payload.up || 0,
down: message.payload.down || 0,
timestamp,
}
this.lastTimestamp = timestamp
this.sampler.addDataPoint(dataPoint)
this.scheduleSnapshot()
break
}
case 'setRange': {
if (this.currentRange !== message.minutes) {
this.currentRange = message.minutes
this.emitSnapshot()
}
break
}
case 'requestSnapshot': {
this.emitSnapshot()
break
}
default:
break
}
}
private emitSnapshot() {
const dataPoints = this.sampler.getDataForTimeRange(this.currentRange)
this.emit({
type: 'snapshot',
dataPoints,
samplerStats: this.sampler.getStats(),
lastTimestamp: this.lastTimestamp,
})
this.lastBroadcast = dataPoints
}
private scheduleSnapshot() {
if (this.throttleTimer !== null) return
this.throttleTimer = setTimeout(() => {
this.throttleTimer = null
this.emitSnapshot()
}, this.config.snapshotIntervalMs)
}
}
class TrafficWorkerClient {
private worker: Worker | null = null
private inlineMonitor: InlineTrafficMonitor | null = null
private mode: 'worker' | 'inline' | null = null
private listeners = new Set<
(snapshot: ITrafficWorkerSnapshotMessage) => void
>()
private pendingMessages: TrafficWorkerRequestMessage[] = []
private ready = false
private currentRange = WORKER_CONFIG.defaultRangeMinutes
start(rangeMinutes?: number) {
@@ -155,101 +46,52 @@ class TrafficWorkerClient {
this.currentRange = rangeMinutes ?? this.currentRange
if (this.ready) return
if (this.worker) return
const initMessage: TrafficWorkerRequestMessage = {
type: 'init',
config: {
rawDataMinutes: WORKER_CONFIG.rawDataMinutes,
compressedDataMinutes: WORKER_CONFIG.compressedDataMinutes,
compressionRatio: WORKER_CONFIG.compressionRatio,
snapshotIntervalMs: WORKER_CONFIG.snapshotIntervalMs,
defaultRangeMinutes: this.currentRange,
},
config: { ...WORKER_CONFIG, defaultRangeMinutes: this.currentRange },
}
if (typeof Worker !== 'undefined') {
try {
this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' },
)
this.mode = 'worker'
this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' },
)
this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>,
) => {
const message = event.data
if (message.type === 'snapshot') {
if (import.meta.env.MODE === 'perf') {
document.documentElement.dataset.perfWorker = String(
message.lastTimestamp ?? 0,
)
document.documentElement.dataset.perfPoints = String(
message.dataPoints.length,
)
const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String(
last?.up ?? 0,
)
document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0,
)
}
this.listeners.forEach((listener) => {
listener(message)
})
}
this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>,
) => {
const message = event.data
if (message.type === 'snapshot') {
if (import.meta.env.MODE === 'perf') {
document.documentElement.dataset.perfWorker = String(
message.lastTimestamp ?? 0,
)
document.documentElement.dataset.perfPoints = String(
message.dataPoints.length,
)
const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String(last?.up ?? 0)
document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0,
)
}
this.worker.onerror = (error) => {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
}
this.ready = true
this.post(initMessage)
this.flushQueue()
return
} catch (error) {
debugLog(
`[TrafficWorkerClient] Worker initialization failed, falling back to inline sampler: ${String(error)}`,
)
this.worker = null
this.mode = null
this.listeners.forEach((listener) => {
listener(message)
})
}
} else {
debugLog(
'[TrafficWorkerClient] Worker not supported, using inline sampler',
)
}
this.startInline(initMessage)
}
this.worker.onerror = (error) => {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
}
private startInline(initMessage: TrafficWorkerRequestMessage) {
this.inlineMonitor = new InlineTrafficMonitor((snapshot) => {
this.listeners.forEach((listener) => {
listener(snapshot)
})
})
this.mode = 'inline'
this.ready = true
this.post(initMessage)
this.flushQueue()
}
stop() {
if (this.worker) {
this.worker.terminate()
}
if (this.inlineMonitor) {
this.inlineMonitor.stop()
}
this.worker?.terminate()
this.worker = null
this.inlineMonitor = null
this.mode = null
this.ready = false
this.pendingMessages = []
}
onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) {
@@ -260,37 +102,11 @@ class TrafficWorkerClient {
}
private post(message: TrafficWorkerRequestMessage) {
if (!this.ready) {
this.pendingMessages.push(message)
return
}
if (this.mode === 'worker' && this.worker) {
this.worker.postMessage(message)
return
}
if (this.mode === 'inline' && this.inlineMonitor) {
this.inlineMonitor.handle(message)
return
}
this.pendingMessages.push(message)
}
private flushQueue() {
if (!this.ready || this.pendingMessages.length === 0) {
return
}
const queued = [...this.pendingMessages]
this.pendingMessages = []
queued.forEach((message) => {
this.post(message)
})
this.worker?.postMessage(message)
}
private ensureStarted() {
if (!this.ready) {
if (!this.worker) {
this.start(this.currentRange)
}
}
-1
View File
@@ -25,7 +25,6 @@ export const useVerge = () => {
| IVergeConfig
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
| undefined,
_revalidate?: boolean,
) => {
if (updaterOrData === undefined) {
void refetch()
+6 -18
View File
@@ -11,36 +11,24 @@ const useWindow = () => {
}
export const useWindowControls = () => {
const {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} = useWindow()
const { maximized, minimize, toggleMaximize, close, currentWindow } =
useWindow()
return {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} satisfies Pick<
WindowContextType,
| 'maximized'
| 'minimize'
| 'toggleMaximize'
| 'close'
| 'toggleFullscreen'
| 'currentWindow'
'maximized' | 'minimize' | 'toggleMaximize' | 'close' | 'currentWindow'
>
}
export const useWindowDecorations = () => {
const { decorated, toggleDecorations, refreshDecorated } = useWindow()
return { decorated, toggleDecorations, refreshDecorated } satisfies Pick<
const { decorated, toggleDecorations } = useWindow()
return { decorated, toggleDecorations } satisfies Pick<
WindowContextType,
'decorated' | 'toggleDecorations' | 'refreshDecorated'
'decorated' | 'toggleDecorations'
>
}
+4 -21
View File
@@ -1,8 +1,7 @@
import { Paper, ThemeProvider } from '@mui/material'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo } from 'react'
import { Outlet, useNavigate } from 'react-router'
import { BaseErrorBoundary } from '@/components/base'
@@ -38,7 +37,6 @@ const OS = getSystem()
const Layout = () => {
const mode = useThemeMode()
const isDark = mode !== 'light'
const { t } = useTranslation()
const { theme } = useCustomTheme()
const { verge } = useVerge()
const { language } = verge ?? {}
@@ -46,7 +44,6 @@ const Layout = () => {
const { switchLanguage } = useI18n()
const navigate = useNavigate()
const themeReady = useMemo(() => Boolean(theme), [theme])
const windowControlsRef = useRef<any>(null)
const { decorated } = useWindowDecorations()
const customTitlebar = useMemo(
@@ -57,7 +54,7 @@ const Layout = () => {
className="the_titlebar-drag-region"
data-tauri-drag-region="true"
/>
<WindowControls ref={windowControlsRef} />
<WindowControls />
</div>
) : null,
[decorated],
@@ -69,12 +66,12 @@ const Layout = () => {
(payload: [string, string]) => {
const [status, msg] = payload
try {
handleNoticeMessage(status, msg, t, navigate)
handleNoticeMessage(status, msg, navigate)
} catch (error) {
console.error('[通知处理] 失败:', error)
}
},
[t, navigate],
[navigate],
)
useEventBus(handleNotice)
@@ -110,20 +107,6 @@ const Layout = () => {
<NoticeManager position={verge?.notice_position} />
<ServiceMigrationDialog />
<SysproxyPrivilegeDialog />
<div
style={{
animation: 'fadeIn 0.5s',
WebkitAnimation: 'fadeIn 0.5s',
}}
/>
<style>
{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}
</style>
<Paper
square
elevation={0}
+2 -4
View File
@@ -83,7 +83,6 @@ const ProfilePage = () => {
const { t } = useTranslation()
const location = useLocation()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
const [activatings, setActivatings] = useState<string[]>([])
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
@@ -257,7 +256,6 @@ const ProfilePage = () => {
)
}
} finally {
setDisabled(false)
setLoading(false)
}
}
@@ -824,7 +822,7 @@ const ProfilePage = () => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return
}
if (!url || disabled || loading) {
if (!url || loading) {
return
}
event.preventDefault()
@@ -857,7 +855,7 @@ const ProfilePage = () => {
}}
/>
<Button
disabled={!url || disabled}
disabled={!url}
loading={loading}
variant="contained"
size="small"
+1 -4
View File
@@ -24,11 +24,8 @@ const RulesPage = () => {
const [showScrollTop, setShowScrollTop] = useState(false)
const pageVisible = useVisibility()
// 在组件挂载时和页面获得焦点时刷新规则数据
// 页面可见时刷新规则数据:挂载时可见或从隐藏恢复可见各触发一次
useEffect(() => {
refreshRules()
refreshRuleProviders()
if (pageVisible) {
refreshRules()
refreshRuleProviders()
+3 -1
View File
@@ -26,7 +26,9 @@ const TQ_MIHOMO = {
refetchOnReconnect: false,
staleTime: 1500,
retry: 3,
retryDelay: (attempt: number) => Math.min(200 * 2 ** attempt, 3000),
// SWR's default retry waits about 2^retryCount * errorRetryInterval with
// jitter, so 100 reproduces the previous 200 * 2^attempt backoff.
retryDelay: 100,
} as const
const TQ_DEFAULTS = {
-2
View File
@@ -5,11 +5,9 @@ export interface WindowContextType {
decorated: boolean | null
maximized: boolean | null
toggleDecorations: () => Promise<void>
refreshDecorated: () => Promise<boolean>
minimize: () => Promise<void>
close: () => Promise<void>
toggleMaximize: () => Promise<void>
toggleFullscreen: () => Promise<void>
currentWindow: ReturnType<typeof getCurrentWindow>
}
-8
View File
@@ -61,10 +61,6 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
}
}, [currentWindow])
const toggleFullscreen = useCallback(async () => {
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()))
}, [currentWindow])
const refreshDecorated = useCallback(async () => {
const val = await currentWindow.isDecorated()
setDecorated(val)
@@ -87,22 +83,18 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
}),
[
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
],
)
+38 -37
View File
@@ -35,7 +35,6 @@ interface IpInfo {
interface ServiceConfig {
url: string
mapping: (data: any) => IpInfo
timeout?: number // 保留timeout字段(如有需要)
}
// 可用的IP检测服务列表及字段映射
@@ -156,48 +155,52 @@ export const getIpInfo = async (): Promise<
for (const service of shuffledServices) {
debugLog(`尝试IP检测服务: ${service.url}`)
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, service.timeout || serviceTimeout)
try {
return await asyncRetry(
async (bail) => {
console.debug('Fetching IP information:', service.url)
const response = await fetch(service.url, {
method: 'GET',
signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout),
connectTimeout: service.timeout || serviceTimeout,
headers: {
'User-Agent': userAgent,
},
})
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, serviceTimeout)
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
)
}
let data: any
try {
data = await response.json()
} catch {
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
}
if (data && data.ip) {
debugLog(`IP检测成功,使用服务: ${service.url}`)
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
const response = await fetch(service.url, {
method: 'GET',
signal: timeoutController.signal,
connectTimeout: serviceTimeout,
headers: {
'User-Agent': userAgent,
},
})
} else {
return bail(new Error(`无效的响应格式 from ${service.url}`))
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
)
}
let data: any
try {
data = await response.json()
} catch {
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
}
if (data && data.ip) {
debugLog(`IP检测成功,使用服务: ${service.url}`)
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
})
} else {
return bail(new Error(`无效的响应格式 from ${service.url}`))
}
} finally {
clearTimeout(timeoutId)
}
},
{
@@ -210,8 +213,6 @@ export const getIpInfo = async (): Promise<
} catch (error) {
debugLog(`IP检测服务失败: ${service.url}`, error)
lastError = error
} finally {
clearTimeout(timeoutId)
}
}
+3 -3
View File
@@ -51,7 +51,7 @@ const mountBus = async () => {
return createEventBus({
dispatch,
handleNotice: ([status, message]: [string, string]) => {
notice(status, message, (key) => key, vi.fn())
notice(status, message, vi.fn())
},
revalidateKeys: (keys) => void revalidateQueries(keys.map((key) => [key])),
readPendingFailures: () => {},
@@ -72,7 +72,7 @@ it('does not replay a recovered startup error after WebView recreation', async (
kind: 'startFailed',
detail: 'startup failed',
})
first.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
first.handleNoticeMessage('core_start::error', '', vi.fn())
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
@@ -81,7 +81,7 @@ it('does not replay a recovered startup error after WebView recreation', async (
vi.mocked(getCoreStartupError).mockResolvedValue(null)
vi.resetModules()
const recreated = await import('@/services/notice-handlers')
recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
recreated.handleNoticeMessage('core_start::error', '', vi.fn())
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
})
+2 -4
View File
@@ -277,6 +277,8 @@ class DelayManager {
group: string,
timeout: number,
): Promise<DelayUpdate> {
// Entry points own the pending mark; measureDelay only writes results.
this.setDelay(member.ref.name, group, -2)
const update = await this.measureDelay(member, group, timeout)
this.queueGroupNotification(group)
return update
@@ -298,8 +300,6 @@ class DelayManager {
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
this.setDelay(name, group, -2)
const startTime = Date.now()
try {
@@ -359,8 +359,6 @@ class DelayManager {
const currName = currMember.ref.name
try {
this.setDelay(currName, group, -2)
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
await new Promise((resolve) =>
-2
View File
@@ -13,7 +13,6 @@ import { hideNotice, showNotice } from '@/services/notice-service'
import { requestService } from '@/services/service-request'
type NavigateFunction = (path: string, options?: any) => void
type TranslateFunction = (key: string) => string
let shownStartupError: string | null = null
// Reads can settle out of order; only the newest may act, and a reset counts as one.
@@ -241,7 +240,6 @@ const handlers: Record<
export const handleNoticeMessage = (
status: string,
msg: string,
t: TranslateFunction,
navigate: NavigateFunction,
) => {
const handler = handlers[status as NoticeStatus]
-9
View File
@@ -148,15 +148,6 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
raw = extra
continue
}
if (!params && isPlainRecord(extra)) {
params = extra
continue
}
if (duration === undefined && typeof extra === 'number') {
duration = extra
}
}
return {
+2 -18
View File
@@ -18,7 +18,7 @@ type QueryOptions<T> = {
placeholderData?: T | (() => T | undefined)
staleTime?: number
retry?: number | false
retryDelay?: number | ((attempt: number) => number)
retryDelay?: number
refetchInterval?: number | false
refetchIntervalInBackground?: boolean
revalidateOnMount?: boolean
@@ -90,25 +90,9 @@ export function useQuery<T>(options: QueryOptions<T>): QueryResult<T> {
const swr = useSWR<T>(enabled ? queryKey : null, queryFn, {
dedupingInterval: staleTime,
errorRetryCount: retry === false ? 0 : retry,
errorRetryInterval:
typeof retryDelay === 'number'
? retryDelay
: swrConfig.errorRetryInterval,
errorRetryInterval: retryDelay ?? swrConfig.errorRetryInterval,
fallbackData,
keepPreviousData: placeholderData !== undefined,
onErrorRetry: (_error, _key, config, revalidate, { retryCount }) => {
const maxRetries = config.errorRetryCount
if (maxRetries !== undefined && retryCount > maxRetries) return
const interval =
typeof retryDelay === 'function'
? retryDelay(Math.max(retryCount - 1, 0))
: config.errorRetryInterval
setTimeout(() => {
revalidate({ retryCount, dedupe: true })
}, interval)
},
revalidateOnFocus: refetchOnWindowFocus,
revalidateOnMount,
revalidateOnReconnect: refetchOnReconnect,
+1 -5
View File
@@ -43,11 +43,7 @@ export const getWebdavStatus = (signature: string): WebdavStatus => {
}
export const setWebdavStatus = (signature: string, status: WebdavStatus) => {
if (!canUseStorage()) return
if (!signature) {
localStorage.removeItem(WEBDAV_STATUS_KEY)
return
}
if (!signature || !canUseStorage()) return
const payload: WebdavStatusCache = {
signature,
-24
View File
@@ -105,30 +105,6 @@ type IProxyGroupItem = Omit<IProxyItem, 'all'> & {
all: IProxyItem[]
}
interface IProxyProviderItem {
name: string
type: string
proxies: IProxyItem[]
updatedAt: string
vehicleType: string
subscriptionInfo?: {
Upload: number
Download: number
Total: number
Expire: number
}
}
interface IRuleProviderItem {
name: string
behavior: string
format: string
ruleCount: number
type: string
updatedAt: string
vehicleType: string
}
interface ITrafficItem {
up: number
down: number
+1 -3
View File
@@ -14,9 +14,7 @@ export default defineConfig({
legacy({
modernTargets: ['edge>=109', 'safari>=14'],
renderLegacyChunks: false,
// Auto-detected from the emitted bundle, dependencies included,
// so post-Safari-14 APIs never depend on extending a manual list.
modernPolyfills: true,
modernPolyfills: ['es.object.has-own', 'web.structured-clone'],
additionalModernPolyfills: [
path.resolve('./src/polyfills/matchMedia.js'),
path.resolve('./src/polyfills/WeakRef.js'),